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
81 changed files with 13704 additions and 0 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
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+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"
]
}
]
}
}
+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
+5
View File
@@ -0,0 +1,5 @@
module i3
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+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
+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.
@@ -0,0 +1,144 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"lemurs/internal/desktop"
)
// aMachine lays out a login manager in a directory: its configuration naming directories under it,
// two X scripts (one not executable), a package's desktop entry and a cache.
func aMachine(t *testing.T) (lemurs, string, *[]string) {
root := t.TempDir()
for _, d := range []string{"wms", "wayland", "xsessions", "wayland-sessions"} {
os.MkdirAll(filepath.Join(root, d), 0o755)
}
os.WriteFile(filepath.Join(root, "wms", "i3"), []byte("#!/bin/sh\n# the session\nexec /bin/sh \"$HOME/.xinitrc\"\n"), 0o755)
os.WriteFile(filepath.Join(root, "wms", "notes"), []byte("not a session\n"), 0o644)
os.WriteFile(filepath.Join(root, "xsessions", "i3.desktop"), []byte("[Desktop Entry]\nName=i3\nExec=i3\nType=Application\n"), 0o644)
os.WriteFile(filepath.Join(root, "cache"), []byte("i3\nop\n"), 0o644)
config := strings.Join([]string{
`tty = 2`, `cache_path = "` + root + `/cache"`,
`[x11]`, `x11_display = ":1"`, `scripts_path = "` + root + `/wms"`, `xsessions_path = "` + root + `/xsessions"`,
`[wayland]`, `scripts_path = "` + root + `/wayland"`, `wayland_sessions_path = "` + root + `/wayland-sessions"`,
}, "\n")
os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644)
var ran []string
d := desktop.Desk{
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
line := strings.Join(append([]string{name}, args...), " ")
ran = append(ran, line+" <<"+string(stdin))
if name == "journalctl" {
return desktop.Result{Stdout: journal}
}
return desktop.Result{}
},
}
return lemurs{d: d, config: filepath.Join(root, "config.toml"), log: filepath.Join(root, "lemurs.log"), uid: 1000}, root, &ran
}
const journal = `-- Boot a0 --
2026-10-02T13:08:44+02:00 host lemurs[1001]: gkr-pam: unable to locate daemon control file
2026-10-02T13:08:40+02:00 host lemurs[1001]: pam_unix(lemurs:auth): authentication failure; logname= uid=0 euid=0 tty=tty2 ruser= rhost= user=op
2026-10-02T13:08:44+02:00 host lemurs[2141]: pam_unix(lemurs:session): session opened for user op(uid=1000) by op(uid=0)
2026-10-02T18:00:01+02:00 host lemurs[2141]: pam_unix(lemurs:session): session closed for user op
`
func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) {
l, root, _ := aMachine(t)
got, err := l.sessions(context.Background(), desktop.Args{})
if err != nil {
t.Fatal(err)
}
s := got.(map[string]any)["sessions"].([]Session)
if len(s) != 3 || s[0].Source != "desktop-entry" || s[0].Exec != "i3" || s[1].Name != "i3" || s[1].Source != "script" {
t.Fatalf("%+v", s)
}
if s[1].Exec != `exec /bin/sh "$HOME/.xinitrc"` || !s[1].Offered {
t.Fatalf("a script answers what it runs: %+v", s[1])
}
if s[2].Name != "notes" || s[2].Executable || s[2].Offered {
t.Fatalf("a script that is not executable is not offered: %+v", s[2])
}
if d := got.(map[string]any)["default"].(Cached); d.Session != "i3" || d.Account != "op" {
t.Fatalf("%+v", d)
}
_ = root
}
func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
if err != nil {
t.Fatal(err)
}
c := ParseConfig(string(raw))
want := Config{Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/etc/lemurs/xsessions",
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/etc/lemurs/wayland-sessions", Display: ":1", TTY: 2}
if c != want {
t.Fatalf("%+v", c)
}
}
func TestTheDefaultSessionIsOneLemursOffersAndIsWrittenThroughSudo(t *testing.T) {
l, root, ran := aMachine(t)
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "notes"}); err == nil {
t.Fatal("a session lemurs does not offer is refused")
}
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3", "account": "op; rm"}); err == nil {
t.Fatal("an account that is not a name is refused")
}
got, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
if err != nil {
t.Fatal(err)
}
if len(*ran) != 1 || (*ran)[0] != "sudo -n tee "+root+"/cache <<i3\nop\n" {
t.Fatalf("%q", *ran)
}
if !strings.Contains(asJSON(got), `"shown"`) {
t.Fatalf("it says when it shows: %s", asJSON(got))
}
l.uid = 0
*ran = nil
l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
if !strings.HasPrefix((*ran)[0], "tee ") {
t.Fatalf("as root, no sudo: %q", *ran)
}
}
func TestLoginsAreReadFromTheJournalAndLemursLog(t *testing.T) {
l, root, _ := aMachine(t)
os.WriteFile(filepath.Join(root, "lemurs.log"), []byte(
"[2026-10-02T11:08:44Z INFO lemurs] Starting new session for 'op' in environment 'X { xinitrc_path: \"i3\" }'\n"+
"[2026-10-02T11:08:44Z INFO lemurs::auth] Login attempt for 'op'\n"), 0o644)
got, err := l.logins(context.Background(), desktop.Args{"limit": float64(2)})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
ev := m["events"].([]Login)
if len(ev) != 2 || ev[0].Event != "opened" || ev[1].Event != "closed" || ev[1].Account != "op" || m["cut"] != true {
t.Fatalf("the newest two, cut: %+v", ev)
}
all := ParseJournal(journal)
if len(all) != 3 || all[0].Event != "failed" || all[0].Account != "op" {
t.Fatalf("%+v", all)
}
st := m["started"].([]Started)
if len(st) != 1 || st[0].Environment != `X { xinitrc_path: "i3" }` {
t.Fatalf("%+v", st)
}
if _, err := l.logins(context.Background(), desktop.Args{"since": "-1d; reboot"}); err == nil {
t.Fatal("since is a time")
}
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
+69
View File
@@ -0,0 +1,69 @@
// lemurs's tools (novox/hq ADR 0208, research 026/05): node-login-manager's verb `sessions`, and the
// module's own `default_session` and `logins`.
//
// None of them needs the graphical session: they read the login manager's configuration, its session
// directories, its cache and the journal. Changing the default session writes the login manager's
// cache, which is root's, through `sudo -n` as the packet filter's tools escalate (to-be 38 WP4).
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"lemurs/internal/desktop"
)
func main() {
l := lemurs{d: desktop.Machine(), config: "/etc/lemurs/config.toml", log: "/var/log/lemurs.log", uid: os.Getuid()}
if err := stdio.Serve("", tools(l)); 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(l lemurs) []stdio.Tool {
return []stdio.Tool{
{
Name: "node-login-manager.sessions",
Description: "The sessions the login screen offers on this machine — each with its name, X11 or Wayland, " +
"the file it runs and whether that file is executable (lemurs skips one that is not) — and the " +
"session and account it starts with by default (its cache).",
Input: desktop.Schema(map[string]any{}),
Run: call(l.sessions),
},
{
Name: "lemurs_default_session",
Description: "The session the login screen preselects. With session (one of the names sessions lists), " +
"make it the default: written into lemurs's cache, which lemurs reads when it starts, so it shows at " +
"the next start of the login screen (a reboot, or lemurs restarted). Escalates with sudo -n.",
Input: desktop.Schema(map[string]any{
"session": desktop.Str("the session to preselect (optional)"),
"account": desktop.Str("the account to preselect with it (optional; the cached one)"),
}),
Run: call(l.defaultSession),
},
{
Name: "lemurs_logins",
Description: "Who logged in through the login screen and when, newest last: sessions opened and closed " +
"and failed passwords, from the journal, and the sessions lemurs started (which entry, from its own " +
"log since it last started).",
Input: desktop.Schema(map[string]any{
"since": desktop.Str("how far back, as journalctl reads it (default -7d)"),
"limit": desktop.Int("at most this many events (default 50, at most 500)"),
}),
Run: call(l.logins),
},
}
}
@@ -0,0 +1,116 @@
package main
// lemurs's shape (novox/hq ADR 0208): it holds node-login-manager; it owns the configuration in
// lemurs 0.4's format and enables the service without ever starting, stopping or restarting it,
// because the running login manager is the operator's way in.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
Seats any `json:"seats"`
Tools []string `json:"tools"`
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 TestItHoldsTheLoginManagerSeatAndServesSessions(t *testing.T) {
m := readManifest(t)
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-login-manager" || strings.Join(m.Claims[0].Serves, ",") != "sessions" {
t.Fatalf("%+v", m.Claims)
}
served := map[string]bool{}
for _, tool := range tools(lemurs{}) {
served[tool.Name] = true
}
if !served["node-login-manager.sessions"] || len(served) != 1+len(m.Tools) {
t.Fatalf("served %v, manifest %v", served, m.Tools)
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("%s is listed and not served", name)
}
}
}
func TestTheOfficialPackageAndItsServiceEnabledButNeverRestarted(t *testing.T) {
m := readManifest(t)
if p := resource(t, m, "package"); p["package"] != "lemurs" {
t.Fatalf("the official package, not lemurs-git: %v", p)
}
s := resource(t, m, "service")
if s["unit"] != "lemurs.service" || s["boot"] != "enabled" {
t.Fatalf("%v", s)
}
for _, k := range []string{"state", "restart-on", "reload-on"} {
if _, ok := s[k]; ok {
t.Fatalf("the login manager is never started, stopped or restarted by a push (%s): a change applies at its next start", k)
}
}
if strings.Join(m.Capabilities, ",") != "package-manager,service-manager,seat" {
t.Fatalf("%v", m.Capabilities)
}
}
func TestTheConfigurationIsTheModulesFileAndOffersOnlyTheSessionsModulesPlace(t *testing.T) {
m := readManifest(t)
c := resource(t, m, "config")
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
if c["path"] != "/etc/lemurs/config.toml" || c["content"] != string(raw) || c["into"] != nil {
t.Fatal("the manifest carries config/config.toml whole, as an owned file")
}
text := c["content"].(string)
for _, want := range []string{"[x11]", "[wayland]", `xsetup_path = "/etc/lemurs/xsetup.sh"`, `scripts_path = "/etc/lemurs/wms"`,
`scripts_path = "/etc/lemurs/wayland"`, `xsessions_path = "/etc/lemurs/xsessions"`, `wayland_sessions_path = "/etc/lemurs/wayland-sessions"`,
`tty = 2`, `x11_display = ":1"`, `remember = true`} {
if !strings.Contains(text, want) {
t.Errorf("the configuration lacks %s", want)
}
}
for _, l := range strings.Split(text, "\n") {
if (strings.HasPrefix(l, "xsessions_path") || strings.HasPrefix(l, "wayland_sessions_path")) && strings.Contains(l, "/usr/share") {
t.Fatalf("a package's bare desktop entry would be offered: %s", l)
}
}
for _, id := range []string{"xsessions", "wayland-sessions"} {
if d := resource(t, m, id); d["type"] != "directory" || d["path"] != "/etc/lemurs/"+id {
t.Fatalf("%v", d)
}
}
}
+343
View File
@@ -0,0 +1,343 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"lemurs/internal/desktop"
)
type lemurs struct {
d desktop.Desk
config string
log string
uid int
}
// Config is the part of lemurs's configuration the tools read, with lemurs 0.4's defaults.
type Config struct {
Cache string `json:"cache_path"`
X11Scripts string `json:"x11_scripts"`
X11Sessions string `json:"x11_desktop_entries"`
WaylandScripts string `json:"wayland_scripts"`
WaylandEntries string `json:"wayland_desktop_entries"`
Display string `json:"x11_display"`
TTY int `json:"tty"`
}
// ParseConfig reads the keys it needs from lemurs's TOML: `[section]` headers and `key = value`
// lines, a quoted value unquoted. Enough for this file, which holds no nested values it needs.
func ParseConfig(text string) Config {
c := Config{
Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/usr/share/xsessions",
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/usr/share/wayland-sessions", Display: ":1", TTY: 2,
}
section := ""
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
section = strings.Trim(line, "[] ")
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if uq, err := strconv.Unquote(v); err == nil {
v = uq
}
switch section + "." + k {
case ".cache_path":
c.Cache = v
case ".tty":
if n, err := strconv.Atoi(v); err == nil {
c.TTY = n
}
case "x11.scripts_path":
c.X11Scripts = v
case "x11.xsessions_path":
c.X11Sessions = v
case "x11.x11_display":
c.Display = v
case "wayland.scripts_path":
c.WaylandScripts = v
case "wayland.wayland_sessions_path":
c.WaylandEntries = v
}
}
return c
}
// Session is one entry of the login screen.
type Session struct {
Name string `json:"name"`
Kind string `json:"kind"` // x11 or wayland
Source string `json:"source"` // script or desktop-entry
Path string `json:"path"`
Exec string `json:"exec,omitempty"`
Executable bool `json:"executable"`
Offered bool `json:"offered"`
}
// ListSessions is every entry lemurs offers, in its order: X desktop entries, Wayland desktop
// entries, X scripts, Wayland scripts (lemurs's get_envs). A script that is not executable is listed
// as not offered, because lemurs skips it with only a warning in its log.
func ListSessions(c Config) []Session {
var out []Session
entries := func(dir, kind string) {
files, _ := os.ReadDir(dir)
for _, f := range files {
p := filepath.Join(dir, f.Name())
name, exec, ok := desktopEntry(p)
if !ok {
continue
}
out = append(out, Session{Name: name, Kind: kind, Source: "desktop-entry", Path: p, Exec: exec, Executable: true, Offered: true})
}
}
scripts := func(dir, kind string) {
files, _ := os.ReadDir(dir)
for _, f := range files {
p := filepath.Join(dir, f.Name())
info, err := os.Stat(p)
if err != nil || info.IsDir() {
continue
}
x := info.Mode()&0o111 != 0
out = append(out, Session{Name: f.Name(), Kind: kind, Source: "script", Path: p, Exec: firstCommand(p), Executable: x, Offered: x})
}
}
entries(c.X11Sessions, "x11")
entries(c.WaylandEntries, "wayland")
scripts(c.X11Scripts, "x11")
scripts(c.WaylandScripts, "wayland")
return out
}
// desktopEntry reads Name and Exec from a session's desktop entry, as lemurs does.
func desktopEntry(path string) (string, string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", "", false
}
in, name, exec := false, "", ""
for _, l := range strings.Split(string(b), "\n") {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "[") {
in = l == "[Desktop Entry]"
continue
}
if !in {
continue
}
if v, ok := strings.CutPrefix(l, "Name="); ok && name == "" {
name = v
}
if v, ok := strings.CutPrefix(l, "Exec="); ok && exec == "" {
exec = v
}
}
if exec == "" {
return "", "", false
}
if name == "" {
name = exec
}
return name, exec, true
}
// firstCommand is a script's first line that is neither blank nor a comment: what it runs.
func firstCommand(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, l := range strings.Split(string(b), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
return l
}
}
return ""
}
// Cached is lemurs's cache file: the session on its first line, the account on its second.
type Cached struct {
Session string `json:"session"`
Account string `json:"account"`
}
func readCache(path string) (Cached, error) {
b, err := os.ReadFile(path)
if err != nil {
return Cached{}, err
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
c := Cached{Session: strings.TrimSpace(lines[0])}
if len(lines) > 1 {
c.Account = strings.TrimSpace(lines[1])
}
return c, nil
}
func (l lemurs) readConfig() (Config, error) {
b, err := os.ReadFile(l.config)
if err != nil {
return Config{}, fmt.Errorf("lemurs's configuration cannot be read (%v): is the lemurs module's package installed?", err)
}
return ParseConfig(string(b)), nil
}
func (l lemurs) sessions(ctx context.Context, a desktop.Args) (any, error) {
c, err := l.readConfig()
if err != nil {
return nil, err
}
answer := map[string]any{"config": l.config, "sessions": ListSessions(c), "directories": c}
if cached, err := readCache(c.Cache); err == nil {
answer["default"] = cached
} else {
answer["default"] = nil
}
return answer, nil
}
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
func (l lemurs) defaultSession(ctx context.Context, a desktop.Args) (any, error) {
c, err := l.readConfig()
if err != nil {
return nil, err
}
cached, _ := readCache(c.Cache)
want := a.Opt("session", "")
if want == "" {
return map[string]any{"default": cached, "cache": c.Cache}, nil
}
known := false
var names []string
for _, s := range ListSessions(c) {
if s.Offered {
names = append(names, s.Name)
known = known || s.Name == want
}
}
if !known {
sort.Strings(names)
return nil, fmt.Errorf("%q is not a session lemurs offers; it offers %s", want, strings.Join(names, ", "))
}
account := a.Opt("account", cached.Account)
if account == "" {
account = os.Getenv("MESH_OPERATOR_ACCOUNT")
}
if !accountName.MatchString(account) {
return nil, fmt.Errorf("%q is not an account name", account)
}
content := []byte(want + "\n" + account + "\n")
var res desktop.Result
if l.uid == 0 {
res = l.d.Run(ctx, l.d.Base, content, "tee", c.Cache)
} else {
res = l.d.Run(ctx, l.d.Base, content, "sudo", "-n", "tee", c.Cache)
}
if !res.OK() {
return nil, fmt.Errorf("writing %s: %w", c.Cache, res.Err())
}
return map[string]any{
"default": Cached{Session: want, Account: account}, "was": cached, "cache": c.Cache,
"shown": "when lemurs next starts (a reboot, or the login manager restarted); lemurs rewrites it after each login when remember is on",
}, nil
}
// Login is one event of the login screen.
type Login struct {
Time string `json:"time"`
Event string `json:"event"` // opened, closed or failed
Account string `json:"account,omitempty"`
}
var (
opened = regexp.MustCompile(`pam_unix\(lemurs:session\): session opened for user ([^(\s]+)`)
closed = regexp.MustCompile(`pam_unix\(lemurs:session\): session closed for user ([^(\s]+)`)
failed = regexp.MustCompile(`pam_unix\(lemurs:auth\): authentication failure;.*?user=(\S+)`)
started = regexp.MustCompile(`^\[(\S+) INFO\s+lemurs\] Starting new session for '([^']*)' in environment '(.*)'$`)
)
// ParseJournal reads `journalctl -o short-iso` lines of lemurs into login events.
func ParseJournal(text string) []Login {
var out []Login
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 || strings.HasPrefix(line, "--") {
continue
}
for _, p := range []struct {
re *regexp.Regexp
event string
}{{opened, "opened"}, {closed, "closed"}, {failed, "failed"}} {
if m := p.re.FindStringSubmatch(line); m != nil {
out = append(out, Login{Time: f[0], Event: p.event, Account: m[1]})
}
}
}
return out
}
// Started is one session lemurs started, from its own log.
type Started struct {
Time string `json:"time"`
Account string `json:"account"`
Environment string `json:"environment"`
}
// ParseStarts reads lemurs's own log for the sessions it started and which entry each ran.
func ParseStarts(text string) []Started {
var out []Started
for _, line := range strings.Split(text, "\n") {
if m := started.FindStringSubmatch(line); m != nil {
out = append(out, Started{Time: m[1], Account: m[2], Environment: m[3]})
}
}
return out
}
var since = regexp.MustCompile(`^[-+]?[0-9A-Za-z :.]{1,40}$`)
func (l lemurs) logins(ctx context.Context, a desktop.Args) (any, error) {
from := a.Opt("since", "-7d")
if !since.MatchString(from) {
return nil, fmt.Errorf("since is a time journalctl reads, e.g. -7d or 2026-10-01")
}
limit, err := a.Whole("limit", 50, 1, 500)
if err != nil {
return nil, err
}
res := l.d.Plain(ctx, "journalctl", "_COMM=lemurs", "--since", from, "-o", "short-iso", "--no-pager", "-q")
if !res.OK() {
return nil, res.Err()
}
events := ParseJournal(res.Stdout)
cut := false
if len(events) > limit {
events, cut = events[len(events)-limit:], true
}
answer := map[string]any{"since": from, "events": events}
if cut {
answer["cut"] = true
}
if b, err := os.ReadFile(l.log); err == nil {
answer["started"] = ParseStarts(string(b))
}
return answer, nil
}
+368
View File
@@ -0,0 +1,368 @@
# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced
# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with
# every option, as lemurs requires); what the mesh changes from it is marked "mesh:".
#
# The sessions offered are the executable files other modules place in the two scripts directories
# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there
# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are
# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is
# where the account's environment, the X resources and every module's session lines are.
#
# Lemurs configuration file.
# Contains all the customization options of lemurs.
#
# Note: that as of now you need to have all options in the selected
# configuration file. Otherwise Lemurs will not work.
#
# Colors:
# ---------
# There is a list of predefined colors. These include:
# - black
# - white
# - (dark) gray
# - (light) red
# - (light) blue
# - (light) green
# - (light) magenta
# - (light) cyan
# - (light) yellow
# - orange
#
# You can also utilize custom colors with hex color codes.
# "#87CEEB" will create a Sky Blue color.
#
# Note: If the color wasn't recognized, it will default to white.
# ---------
#
# Modifiers:
# ---------
# There is a number of modifiers you can use. These can be combined by
# delimiting them with a comma (e.g. "bold,italic"). The modifiers are:
# - bold
# - dim
# - italic
# - underlined
# - reverse
# - crossed out
# - hidden
# ---------
#
# The tty which contains lemurs. This has to be mirrored in the lemurs.service
tty = 2
# Where to log the main lemurs control flow.
main_log_path = "/var/log/lemurs.log"
# Where to log to for the client. The Client is the Desktop Environment or
# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.
client_log_path = "/var/log/lemurs.client.log"
# At which point to point the cache. If you want to disable the cache globally
# you can use `/dev/null`.
cache_path = "/var/cache/lemurs"
# Disable all logging. This is overwritten by the `--no-log` flag.
do_log = true
# The PAM service that should be used to login
pam_service = "lemurs"
# Path to system shell that gets used to execute linux commands. In almost all
# cases, this should refer to a bash shell.
system_shell = "/bin/sh"
# Initial state of the `PATH` environment variable.
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
# The type flag that will be appended to the shell that calls the session
# environment. This may depend on your shell. Options:
# - 'none'. Disables calling a login shell
# - 'short'. Produces the `-l` flag. Supported by most shells.
# - 'long'. This produces the `--login` flag and is suited for bash and zsh.
shell_login_flag = "short"
# Focus behaviour of fields when Lemurs is initially started
#
# Possible values:
# - default: Initially focus on first non-cached value
# - no-focus: No initial focus
# - environment: Initially focus on the environment selector
# - username: Initially focus on the username field
# - password: Initially focus on the password field
focus_behaviour = "default"
# General settings for background style
[background]
# Control whether to render background widget or not
show_background = false
[background.style]
# Allow to set the default background color for the login shell
color = "black"
# Settings for the background block's borders
show_border = true
border_color = "white"
[power_controls]
# The margin between hints
hint_margin = 2
# There are no additional entries by default
entries = []
# Example
# Reboot to another os option
#[[power_controls.entries]]
## The text in the top-left to display how to reboot.
#hint = "Reboot to OS"
#
## The color and modifiers of the hint in the top-left corner
#hint_color = "dark gray"
#hint_modifiers = ""
#
## The key used to reboot. Possibilities are F1 to F12.
#key = "F3"
## The command that is executed when the key is pressed
#cmd = "efibootmgr -n0 && systemctl reboot -l"
# If you want to remove the base_entries
# base_entries = []
# Shutdown option
[[power_controls.base_entries]]
# The text in the top-left to display how to shutdown.
hint = "Shutdown"
# The color and modifiers of the hint in the top-left corner
hint_color = "dark gray"
hint_modifiers = ""
# The key used to shutdown. Possibilities are F1 to F12.
key = "F1"
# The command that is executed when the key is pressed
cmd = "systemctl poweroff -l"
# Reboot option
[[power_controls.base_entries]]
# The text in the top-left to display how to reboot.
hint = "Reboot"
# The color and modifiers of the hint in the top-left corner
hint_color = "dark gray"
hint_modifiers = ""
# The key used to reboot. Possibilities are F1 to F12.
key = "F2"
# The command that is executed when the key is pressed
cmd = "systemctl reboot -l"
# Setting for the selector of the desktop environment you are using.
[environment_switcher]
# Terms:
# ---------
# Movers: indicators which show which direction one can move whilst selecting
# the desktop environment
# Selected: The currently selected desktop environment.
# Neighbours: The adjacent desktop environment to the one current selected
#
# Visualisation:
#
# < i3 bspwm awesome >
#
# ^ ^ ^ ^ ^
# | | | | |
# mover | selected | mover
# | |
# neighbour neighbour
# ---------
#
# Control the visibility of the switcher
# Options:
# - "visible" - Always show the switcher [default]
# - "hidden" - Always hide the switcher
# - [key] - F1-F12 to be able to toggle the visibility
# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.
switcher_visibility = "F3"
# The text in the top-left to display how to toggle the switcher. The text
# '%key%' will be replaced with the switcher_visibility key. This is not shown
# if switcher_visibility is set to "visible" or "hidden".
toggle_hint = "Switcher %key%"
# The color and modifiers of the hint in the top-left corner
toggle_hint_color = "dark gray"
toggle_hint_modifiers = ""
# Show an option for the TTY shell when logging in as one of the environments.
# NOTE: it is always shown when no viable options are found.
include_tty_shell = false
# Remember the selected environment after logging in for the next time
remember = true
# Enables showing the movers
show_movers = true
# Mover's color and modifiers whilst the selector is unfocused
mover_color = "dark gray"
mover_modifiers = ""
# Mover's color and modifiers whilst the selector is focused
mover_color_focused = "orange"
mover_modifiers_focused = "bold"
# The characters used to display the movers. Suggestions are:
# - "<" ">"
# - "<-" "->"
# - "<<" ">>"
# - "[" "]"
left_mover = "<"
right_mover = ">"
# The margin between the movers and the neighbours or selected (depending on
# `show_neighbours`)
mover_margin = 1
# Enables showing the neighbours
show_neighbours = true
# Neighbours' color and modifiers whilst the selector is unfocused
neighbour_color = "dark gray"
neighbour_modifiers = ""
# Neighbours' color and modifiers whilst the selector is focused
neighbour_color_focused = "gray"
neighbour_modifiers_focused = ""
# Margin between neighbours and selected
neighbour_margin = 1
# Selected's color and modifiers whilst the selector is unfocused
selected_color = "gray"
selected_modifiers = "underlined"
# Selected's color and modifiers whilst the selector is focused
selected_color_focused = "white"
selected_modifiers_focused = "bold"
# The length of the name of the desktop environment which is displayed.
max_display_length = 8
# The text used when no desktop environments are available
no_envs_text = "No environments..."
# The color and modifiers of the 'no desktop environments available text'
# whilst the selector is unfocused
no_envs_color = "white"
no_envs_modifiers = ""
# The color and modifiers of the 'no desktop environments available text'
# whilst the selector is focused
no_envs_color_focused = "red"
no_envs_modifiers_focused = ""
[username_field]
# Remember the username for the next time after a successful login attempt.
remember = true
[username_field.style]
# Enables showing a title
show_title = true
# The text used within the title
title = "Login"
# The title's color and modifiers whilst the username field is unfocused
title_color = "white"
content_color = "white"
# The title's color and modifiers whilst the username field is focused
title_color_focused = "orange"
content_color_focused = "orange"
# Enables showing the borders
show_border = true
# The borders' color and modifiers whilst the username field is unfocused
border_color = "white"
# The borders' color and modifiers whilst the username field is focused
border_color_focused = "orange"
# Constrain the width of the username field
use_max_width = true
# The constraint of the username field's width
max_width = 48
[password_field]
# The character used for replacement when typing a password. Leave empty for no
# feedback.
# Note: Only one character is accepted.
content_replacement_character = "*"
[password_field.style]
# Enables showing a title
show_title = true
# The text used within the title
title = "Password"
# The title's color and modifiers whilst the password field is unfocused
title_color = "white"
content_color = "white"
# The title's color and modifiers whilst the password field is focused
title_color_focused = "orange"
content_color_focused = "orange"
# Enables showing the borders
show_border = true
# The borders' color and modifiers whilst the password field is unfocused
border_color = "white"
# The borders' color and modifiers whilst the password field is focused
border_color_focused = "orange"
# Constrain the width of the password field
use_max_width = true
# The constraint of the password field's width
max_width = 48
[x11]
# Where to log to for the XServer.
xserver_log_path = "/var/log/lemurs.xorg.log"
# The value of the `DISPLAY` environment variable for X11 sessions
x11_display = ":1"
# How many seconds to give the X server to start. To make it infinitely, put it
# to 0.
xserver_timeout_secs = 60
# Where to find the X11 server binary
xserver_path = "/usr/bin/X"
# Where to find the X11 xauth binary
xauth_path = "/usr/bin/xauth"
# Path to the directory where the startup scripts for the X11 sessions are found
scripts_path = "/etc/lemurs/wms"
# Path to the xsetup script that is needed for the environment setup of the
# window manager.
xsetup_path = "/etc/lemurs/xsetup.sh"
# The directory to use for desktop entries X11 sessions.
# mesh: an empty directory of the module's own, so no package's desktop entry is offered.
xsessions_path = "/etc/lemurs/xsessions"
[wayland]
# Path to the directory where the startup scripts for the Wayland sessions are
# found
scripts_path = "/etc/lemurs/wayland"
# The directory to use for desktop entries wayland sessions.
# mesh: likewise for Wayland.
wayland_sessions_path = "/etc/lemurs/wayland-sessions"
+5
View File
@@ -0,0 +1,5 @@
module lemurs
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+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)
}
}
File diff suppressed because one or more lines are too long
+189
View File
@@ -0,0 +1,189 @@
# xorg
The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2 step 2).
- **Claims `node-display-server`** and serves its verbs `displays` and `layout`.
- **Provides `x11-display` with the machine's reach**: i3, xterm, picom and the X lock screen require
it, and a requirement for it is met only by this module on the same machine. It is never pulled in
for whatever asked.
- **Gated by the capability `seat`**, which the host reports for a machine with a graphics device
and a connected display. See *Blockers* for why not `graphical-session`.
- **Packages:** `xorg-server`, `xorg-xinit`, `xorg-xrandr`, `xorg-xset`, `xorg-xrdb`, `xorg-xinput`,
`xorg-setxkbmap`, `xorg-xwd` and `autorandr`. The GPU's driver is not here. It follows the machine,
so it belongs to that machine model's hardware module.
## What it owns
| path | class (ADR 0182) | what |
|---|---|---|
| `~/.xinitrc`, a block at the start | written into (`block`, `at: start`) | the session's start, below |
| `~/.config/xorg/` | owned directory | |
| `~/.config/xorg/xresources` | owned | the mesh's X resources: font rendering (`Xft.*`, DPI 96) and the three `xresources` slots |
**The session's start**, in ADR 0208 §5's order:
1. It sources `~/.config/mesh/environment.sh` (ADR 0203). If pam did not hand over a session bus, it
takes the user manager's socket, never a second bus.
2. It imports an explicit list of the session's words into the user manager and D-Bus activation,
only those that are set. The list is `DISPLAY`, `XAUTHORITY`, `XDG_SESSION_TYPE`,
`XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`, `XDG_CONFIG_HOME`, `XDG_DATA_DIRS`, `GTK_THEME`,
`GTK2_RC_FILES`, `QT_QPA_PLATFORMTHEME`, `QT_STYLE_OVERRIDE`, `QT_SELECT`, `XCURSOR_THEME`,
`XCURSOR_SIZE` and `TERMINAL`. It never uses `--all`.
3. It merges `~/.config/xorg/xresources` with `xrdb -nocpp`, then `~/.Xresources` if you keep one,
so yours win.
4. It runs `autorandr --change`, which applies the saved profile matching the connected monitors.
5. It runs the `xinitrc` slots `first` and `normal`, then sources **`~/.xinitrc.local`** if it exists.
6. It runs the `last` slot, where the holder of `node-display-session` starts the session (`exec i3`).
**Who contributes where**, among the desktop's modules:
| slot | module | what |
|---|---|---|
| `first` | `gnome-keyring` | `SSH_AUTH_SOCK` |
| `normal` | `adwaita` | its GSettings keys |
| `normal` | `clipmenu` | `clipmenud &` |
| `normal` | `feh` | the wallpaper |
| `normal` | `i3status-rust` | the bar watchdog |
| `normal` | `screen-lock` | the timeouts and the `xss-lock` loop |
| `last` | `i3` | `exec i3` |
**Slot `last` is the session's.** A module contributing session lines uses `first` or `normal`.
Code in `last` that sorts after the session holder's name would come after its `exec` and never run.
**Why `~/.xinitrc.local`.** The block ends by starting the session, so no line below it runs. Your own
session lines go into `~/.xinitrc.local`. Like `~/.zshrc.local`, it is yours: found, never written.
This is the one step this module adds to ADR 0208 §5. Without it, a line no module carries yet (a
`DOTNET_ROOT`, a wallpaper) would have nowhere to run.
**Resources without the preprocessor.** `xrdb -nocpp` needs no C compiler on the machine, and it
skips a line starting with `#`. The controller precedes each contribution with a `# <module>` line,
which is a cpp directive error under plain `xrdb -merge` but is skipped here.
## Tools
| tool | | what |
|---|---|---|
| `node-display-server.displays` | r | the screen and every output: position, rotation, size, DPI, the monitor's EDID identity (manufacturer, product, serial, name, fingerprint), current and preferred mode, every mode and rate, the autorandr profile in force |
| `node-display-server.layout` | r/a | autorandr profiles: `list` (each with the monitors it is for, which match now, which is current), `save` (`replace` to overwrite), `apply` |
| `xorg_set_mode` | a | one output's mode, rate, rotation, scale, position (x/y or beside another), off, primary; `dry_run` |
| `xorg_primary` | r/a | which output is primary; set it |
| `xorg_dpi` | r/a | `Xft.dpi` beside each monitor's physical DPI; set it for the running session |
| `xorg_input_devices` | r | the input devices, and each pointer's libinput settings |
| `xorg_input_set` | a | tap, tap drag, natural scroll, disable while typing, left handed, middle emulation, enabled, acceleration, by device id or name |
| `xorg_keyboard` | r/a | the XKB map; set layout, variant, model, options |
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`) |
| `xorg_x_log` | r | the X server's log for the running session: version, start, errors (and warnings) |
All answers are structured. Each tool that changes the running server says how long the change lasts.
**How a tool reaches the session** (`internal/desktop`, the same copy in every desktop module). The
runtime is a system service running as the operator account. It has no `DISPLAY`, no `XAUTHORITY` and
no session bus. The tool reads them from the session's own processes:
1. It looks at the account's processes, preferring the window manager.
2. It reads only a fixed list of words, never the rest. A session's environment held secrets on these
machines.
3. It asks logind whether that session is active and local.
4. It checks that the display's socket still exists.
The bus it hands on is the user manager's (`unix:path=/run/user/<uid>/bus`). With no session, a tool
answers `{"error": "no-graphical-session", "reason": …, "looked": […]}`.
The screenshot needs no screenshot program. `xwd` dumps the screen, and the module turns the dump
into a PNG itself.
## What it improves
- **One environment.** The session sources the shells' environment file. It no longer exports a
hand-kept second copy, and never sources the predecessor's secrets file.
- **No compiler needed for the mesh's resources**, and contributions are placed in order rather than
`#include`d.
- **Monitor layouts by the monitors' identity** (autorandr, research 026/04), not scripts with port
names baked in. The package's own udev rule and service apply the matching profile on hotplug.
- **No second bus.** The `dbus-launch` fallback that once ran the whole session on a private bus is
gone. That stale session can still be seen today on one workstation: `session_bus` in a tool's
answer shows it.
## What it leaves found
`~/.xprofile`, `~/.Xresources`, `~/.Xresources.d/`, `~/.screenlayout/`, the arandr scripts, every
line of yours below the block, and `/etc/X11/xinit/xinitrc.d/`.
## The one-off migration (ADR 0182)
**Assign the desktop's modules together** (`xorg`, `lemurs`, `i3`, `xterm`, `adwaita` and the
companions above) and do this migration first. **Until `i3` is assigned, nothing changes for you.** The block's `last` slot is empty, so the block
runs and falls through to your own lines below it, which still end in `exec i3`. The environment is
sourced twice and the import runs twice, which is harmless.
**Once `i3` is assigned, nothing below the block runs.** Before that push, sort today's
`~/.xinitrc` lines (both workstations hold the same file):
| today's line | where it goes |
|---|---|
| the `/etc/X11/xinit/xinitrc.d/?*.sh` loop | **delete**: the block imports `DISPLAY` and `XAUTHORITY` itself, and the login manager's X setup runs that directory too |
| `eval $(gnome-keyring-daemon --start …)` and `export SSH_AUTH_SOCK` | **delete** once `gnome-keyring` is assigned. PAM starts and unlocks the keyring, and that module names the ssh agent's socket in the `xinitrc` slot `first`. Until then, `~/.xinitrc.local` |
| `export PATH=…` (nine entries) | `~/.local/bin`, `~/scripts` and `~/scripts/bin` come from `zsh`'s environment already. **Delete** `~/scripts/i3-sessions/commands` and `~/scripts/mediahuis`: neither exists on either workstation. Move `~/.cargo/bin`, `~/.config/rofi/scripts`, `~/.dotnet` and `~/.dotnet/tools` to `~/.xinitrc.local` until a module carries them |
| `DOTNET_ROOT`, `DOTNET_CLI_TELEMETRY_OPTOUT` | `~/.xinitrc.local` |
| `XDG_CONFIG_HOME` | **delete**: `zsh` contributes it |
| `XDG_DATA_DIRS` with the flatpak directories | `~/.xinitrc.local` until the `flatpak` module |
| `QT_QPA_PLATFORMTHEME`, `GTK_THEME`, `QT_STYLE_OVERRIDE`, `GTK2_RC_FILES`, `QT_SELECT` | **delete** once `adwaita` is assigned (its environment contributions) |
| `XDG_SESSION_DESKTOP`, `XDG_CURRENT_DESKTOP` and their comment | **delete** once `i3` is assigned |
| `dbus-update-activation-environment --systemd …` and its comment | **delete**: the block's step 2 |
| `~/scripts/xdg-appearance \|\| true` | **delete** once `adwaita` is assigned |
| `MY_KV_PATH`, `MY_LIB_PATH`, `MY_STREAMING_PATH` | `~/.xinitrc.local` (they are yours) |
| `[ -f "$HOME/.config/hal/env" ] && . …` (the secrets file) | **delete** (ADR 0208 §5, research 027 Q2). Whatever in the session needed one of those tokens gets it the way research 027 settles |
| `xset s 1800`, `xset dpms 1800 1800 3600` | **delete** once `screen-lock` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` |
| `~/.fehbg &` | **delete** once `feh` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` |
| the `xss-lock` respawn loop | **delete** once `screen-lock` is assigned. Until then, `~/.xinitrc.local` |
| `exec i3 --shmlog-size=26214400` | **delete**: `i3` contributes `exec i3` to the `last` slot |
Then **delete everything below the block**. The old `#!/bin/sh` line now sits below the block too and
means nothing there. The file is run with `sh` (by the login manager's entry and by `startx`), never
executed by its first line.
**`~/.xprofile`.** The login manager's X setup sources it, and it sources `~/.xinitrc`. Once `i3` and
`lemurs` are assigned, the session entry `/etc/lemurs/wms/i3` runs `~/.xinitrc` itself, so **delete
`~/.xprofile`**. Its bus logic is the block's now. If you keep it, delete its `. ~/.xinitrc` line, or
the session starts from `.xprofile` and the login manager's entry is never reached. Either way works
once, but only one should.
**`~/.Xresources`** holds `#include ".Xresources.d/xterm"`, `#include ".Xresources.d/xft"` and the
two `Xcursor` lines:
- The `xft` include and `~/.Xresources.d/xft` are **deleted** now. This module's file carries the
same five values.
- The `xterm` include and `~/.Xresources.d/xterm` are **deleted** once `xterm` is assigned. Kept,
they win over the module's font and colours.
- The `Xcursor` lines are **deleted** once `adwaita` is assigned.
- A file left empty is deleted.
**Monitor layouts.** For each place you use, arrange the monitors (`xorg_set_mode`, or arandr once
more), then `layout` `save` it under a name. Once every place has a profile, these retire: the
`~/.screenlayout/` and `~/scripts/.screenlayouts/` scripts, the laptop's hotplug rule, and its i3
bindings. Those bindings belong to that machine's hardware module, not here.
## What changes when it is assigned
| | g14 (laptop) | shanks (desktop) |
|---|---|---|
| packages | `autorandr` installed; the rest are there already | the same |
| files | `~/.config/xorg/xresources` new; a block added at the start of `~/.xinitrc` | the same |
| running session | nothing: everything takes effect at the next login | nothing |
| next login, before `i3` is assigned | the block runs, then the old file below it. Fonts unchanged (96 DPI); `autorandr --change` does nothing with no profiles | the same. Its session moves to the user manager's bus at the next login, as it should have |
| next login, after `i3` is assigned | only the block: what the table above did not move is gone | the same |
## Blockers
- **The capability.** ADR 0208 §3 says `graphical-session` gates the display server. The host
reports that capability from its own environment. It is a root daemon with no `DISPLAY`, so the
capability is **no on both workstations** (`node show`, 2026-10-04). Gated on it, `xorg` could
never be assigned. This manifest uses **`seat`** instead: graphics hardware with a connected
display, yes on both. The ADR's wording needs a progressive insight, or the host needs a probe
that finds the session the way `internal/desktop` does.
- **Unassigning removes the packages.** The host takes a package away when its declaration goes,
and `xorg-server` is among them. Do not unassign `xorg` from a workstation you are sitting at
without another display server assigned.
- **The `# <module>` naming lines in the `xresources` slots** are safe only because this module
merges with `-nocpp`. If the controller rendered `!` for `xresources`, that would be the
format's own comment.
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
)
// Screen is the X screen as xrandr reports it.
type Screen struct {
Width int `json:"width"`
Height int `json:"height"`
MaxWidth int `json:"max_width"`
MaxHeight int `json:"max_height"`
}
// Output is one connector: a monitor when connected.
type Output struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Primary bool `json:"primary,omitempty"`
Enabled bool `json:"enabled"`
Geometry *Geometry `json:"geometry,omitempty"`
Rotation string `json:"rotation,omitempty"`
Reflect string `json:"reflect,omitempty"`
WidthMM int `json:"width_mm,omitempty"`
HeightMM int `json:"height_mm,omitempty"`
// DPI is the physical density of the current mode, from the size the monitor reports.
DPI float64 `json:"dpi,omitempty"`
Monitor *Identity `json:"monitor,omitempty"`
Current *ModeRate `json:"current,omitempty"`
Preferred *ModeRate `json:"preferred,omitempty"`
Modes []Mode `json:"modes,omitempty"`
edid []byte
}
// Geometry is where an output's picture sits on the screen.
type Geometry struct {
Width int `json:"width"`
Height int `json:"height"`
X int `json:"x"`
Y int `json:"y"`
}
// Mode is one resolution and the refresh rates it is offered at.
type Mode struct {
Size string `json:"size"`
Rates []float64 `json:"rates"`
}
// ModeRate is one resolution at one rate.
type ModeRate struct {
Size string `json:"size"`
Rate float64 `json:"rate"`
}
// Identity is who the monitor is, from its EDID: what an autorandr profile is keyed by, and a name a
// person recognises.
type Identity struct {
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
Serial string `json:"serial,omitempty"`
Name string `json:"name,omitempty"`
Year int `json:"year,omitempty"`
// Fingerprint is the first 12 hex characters of the EDID's sha256: one monitor, whatever port.
Fingerprint string `json:"fingerprint"`
}
var (
screenLine = regexp.MustCompile(`^Screen \d+: minimum \d+ x \d+, current (\d+) x (\d+), maximum (\d+) x (\d+)`)
geometryRe = regexp.MustCompile(`^(\d+)x(\d+)\+(-?\d+)\+(-?\d+)$`)
sizeMM = regexp.MustCompile(`\)\s+(\d+)mm x (\d+)mm`)
)
// ParseXrandr reads `xrandr --prop` (or `--query`): the screen, and every output with its modes and,
// where the properties carry one, its EDID decoded.
func ParseXrandr(text string) (Screen, []Output) {
var screen Screen
var outs []Output
var cur *Output
inEDID := false
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 1<<20), 1<<22)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "Screen "):
if m := screenLine.FindStringSubmatch(line); m != nil {
screen = Screen{atoi(m[1]), atoi(m[2]), atoi(m[3]), atoi(m[4])}
}
inEDID = false
case line != "" && line[0] != ' ' && line[0] != '\t':
outs = append(outs, parseOutputLine(line))
cur = &outs[len(outs)-1]
inEDID = false
case cur == nil:
case strings.HasPrefix(line, "\t\t"):
if inEDID {
if b, err := hex.DecodeString(strings.TrimSpace(line)); err == nil {
cur.edid = append(cur.edid, b...)
}
}
case strings.HasPrefix(line, "\t"):
inEDID = strings.HasPrefix(strings.TrimSpace(line), "EDID:")
case strings.HasPrefix(line, " "):
inEDID = false
parseModeLine(cur, line)
}
}
for i := range outs {
o := &outs[i]
if len(o.edid) >= 128 {
o.Monitor = DecodeEDID(o.edid)
}
if o.Geometry != nil && o.WidthMM > 0 {
w := o.Geometry.Width
if o.Rotation == "left" || o.Rotation == "right" {
w = o.Geometry.Height
}
o.DPI = float64(int(float64(w)/(float64(o.WidthMM)/25.4)*10+0.5)) / 10
}
}
return screen, outs
}
func parseOutputLine(line string) Output {
f := strings.Fields(line)
o := Output{Name: f[0], Connected: len(f) > 1 && f[1] == "connected"}
for _, tok := range f[2:] {
if strings.HasPrefix(tok, "(") {
break
}
switch {
case tok == "primary":
o.Primary = true
case geometryRe.MatchString(tok):
g := geometryRe.FindStringSubmatch(tok)
o.Geometry = &Geometry{atoi(g[1]), atoi(g[2]), atoi(g[3]), atoi(g[4])}
o.Enabled = true
case tok == "normal" || tok == "left" || tok == "inverted" || tok == "right":
o.Rotation = tok
case tok == "X" || tok == "Y" || tok == "and" || tok == "axis":
o.Reflect = strings.TrimSpace(o.Reflect + " " + tok)
}
}
if o.Enabled && o.Rotation == "" {
o.Rotation = "normal"
}
if m := sizeMM.FindStringSubmatch(line); m != nil {
o.WidthMM, o.HeightMM = atoi(m[1]), atoi(m[2])
}
return o
}
// parseModeLine reads ` 2880x1800 60.00*+ 120.00 +`: a rate marked * is in use, + preferred,
// and xrandr writes the + as a token of its own after a rate not in use. A panel may mark several
// rates preferred; the first is the monitor's own preference.
func parseModeLine(o *Output, line string) {
f := strings.Fields(line)
if len(f) == 0 || !strings.Contains(f[0], "x") {
return
}
mode := Mode{Size: f[0]}
for _, tok := range f[1:] {
if tok == "+" {
if n := len(mode.Rates); n > 0 && o.Preferred == nil {
o.Preferred = &ModeRate{mode.Size, mode.Rates[n-1]}
}
continue
}
current := strings.Contains(tok, "*")
preferred := strings.Contains(tok, "+")
rate, err := strconv.ParseFloat(strings.Trim(tok, "*+"), 64)
if err != nil {
continue
}
mode.Rates = append(mode.Rates, rate)
if current {
o.Current = &ModeRate{mode.Size, rate}
}
if preferred && o.Preferred == nil {
o.Preferred = &ModeRate{mode.Size, rate}
}
}
o.Modes = append(o.Modes, mode)
}
// DecodeEDID reads the vendor block and the descriptors of an EDID.
func DecodeEDID(e []byte) *Identity {
if len(e) < 128 {
return nil
}
sum := sha256.Sum256(e)
id := &Identity{Fingerprint: hex.EncodeToString(sum[:])[:12]}
v := uint16(e[8])<<8 | uint16(e[9])
id.Manufacturer = string([]byte{
byte('A' - 1 + (v>>10)&0x1f), byte('A' - 1 + (v>>5)&0x1f), byte('A' - 1 + v&0x1f),
})
id.Product = fmt.Sprintf("%04X", uint16(e[10])|uint16(e[11])<<8)
if serial := uint32(e[12]) | uint32(e[13])<<8 | uint32(e[14])<<16 | uint32(e[15])<<24; serial != 0 {
id.Serial = strconv.FormatUint(uint64(serial), 10)
}
if e[17] != 0 {
id.Year = 1990 + int(e[17])
}
for _, at := range []int{54, 72, 90, 108} {
d := e[at : at+18]
if d[0] != 0 || d[1] != 0 || d[2] != 0 {
continue
}
text := strings.TrimSpace(strings.SplitN(string(d[5:18]), "\n", 2)[0])
switch d[3] {
case 0xfc:
id.Name = text
case 0xff:
if text != "" {
id.Serial = text
}
}
}
return id
}
func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"bufio"
"regexp"
"strconv"
"strings"
)
// Device is one input device the X server knows.
type Device struct {
ID int `json:"id"`
Name string `json:"name"`
Role string `json:"role"` // master or slave
Kind string `json:"kind"` // pointer or keyboard
Settings map[string]any `json:"settings,omitempty"`
}
var deviceLine = regexp.MustCompile(`^[^A-Za-z0-9]*(.+?)\s+id=(\d+)\s+\[(master|slave|floating)\s+(pointer|keyboard)?`)
// ParseDevices reads `xinput list`, the tree's drawing characters stripped.
func ParseDevices(text string) []Device {
var out []Device
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
m := deviceLine.FindStringSubmatch(sc.Text())
if m == nil {
continue
}
id, _ := strconv.Atoi(m[2])
kind := m[4]
if kind == "" {
kind = "floating"
}
out = append(out, Device{ID: id, Name: strings.TrimSpace(m[1]), Role: m[3], Kind: kind})
}
return out
}
// Knob is one setting input-set changes, and the libinput property that holds it.
type Knob struct {
Arg, Property, Kind string // Kind: bool or float
}
// Knobs are what input-set may change, by libinput's own property names (libinput(4)).
var Knobs = []Knob{
{"enabled", "Device Enabled", "bool"},
{"tap", "libinput Tapping Enabled", "bool"},
{"tap_drag", "libinput Tapping Drag Enabled", "bool"},
{"natural_scroll", "libinput Natural Scrolling Enabled", "bool"},
{"disable_while_typing", "libinput Disable While Typing Enabled", "bool"},
{"left_handed", "libinput Left Handed Enabled", "bool"},
{"middle_emulation", "libinput Middle Emulation Enabled", "bool"},
{"accel_speed", "libinput Accel Speed", "float"},
}
var propLine = regexp.MustCompile(`^\s+(.+?) \(\d+\):\s*(.*)$`)
// ParseProps reads `xinput list-props` into the settings Knobs name, as booleans and numbers. A
// device without a property (a keyboard has no tapping) simply lacks the setting.
func ParseProps(text string) map[string]any {
byProp := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if m := propLine.FindStringSubmatch(sc.Text()); m != nil {
byProp[m[1]] = strings.TrimSpace(m[2])
}
}
out := map[string]any{}
for _, k := range Knobs {
v, ok := byProp[k.Property]
if !ok {
continue
}
first := strings.TrimSpace(strings.Split(v, ",")[0])
switch k.Kind {
case "bool":
out[k.Arg] = first == "1"
case "float":
if f, err := strconv.ParseFloat(first, 64); err == nil {
out[k.Arg] = f
}
}
}
if v, ok := byProp["libinput Accel Profile Enabled"]; ok {
profiles := []string{"adaptive", "flat", "custom"}
for i, bit := range strings.Split(v, ",") {
if strings.TrimSpace(bit) == "1" && i < len(profiles) {
out["accel_profile"] = profiles[i]
}
}
}
return out
}
// Keyboard is the X keyboard map as setxkbmap reports it.
type Keyboard struct {
Rules string `json:"rules,omitempty"`
Model string `json:"model,omitempty"`
Layout string `json:"layout,omitempty"`
Variant string `json:"variant,omitempty"`
Options []string `json:"options"`
}
// ParseKeyboard reads `setxkbmap -query`.
func ParseKeyboard(text string) Keyboard {
k := Keyboard{Options: []string{}}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
key, value, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
value = strings.TrimSpace(value)
switch strings.TrimSpace(key) {
case "rules":
k.Rules = value
case "model":
k.Model = value
case "layout":
k.Layout = value
case "variant":
k.Variant = value
case "options":
for _, o := range strings.Split(value, ",") {
if o = strings.TrimSpace(o); o != "" {
k.Options = append(k.Options, o)
}
}
}
}
return k
}
// xkbName is what setxkbmap accepts as a layout, variant, model or option: letters, digits and the
// punctuation XKB names use. Anything else is refused before it reaches a command line.
var xkbName = regexp.MustCompile(`^[A-Za-z0-9_:+(),.-]+$`)
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Profile is one saved autorandr layout: the monitors it is for, by output and EDID fingerprint.
type Profile struct {
Name string `json:"name"`
Path string `json:"path"`
Monitors map[string]string `json:"monitors"`
Detected bool `json:"detected,omitempty"`
Current bool `json:"current,omitempty"`
}
// profileName is what a profile may be called: it becomes a directory name.
var profileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// ProfileDirs are where autorandr keeps profiles, the account's first (autorandr(1)).
func ProfileDirs(home string) []string {
cfg := os.Getenv("XDG_CONFIG_HOME")
if cfg == "" {
cfg = filepath.Join(home, ".config")
}
return []string{filepath.Join(cfg, "autorandr"), "/etc/xdg/autorandr"}
}
// ReadProfiles lists the profiles in dirs; a name in an earlier directory hides a later one, as in
// autorandr itself. Each profile's monitors come from its `setup` file: `<output> <edid>` per line.
func ReadProfiles(dirs []string) []Profile {
seen := map[string]bool{}
var out []Profile
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() || seen[e.Name()] {
continue
}
setup := filepath.Join(dir, e.Name(), "setup")
f, err := os.Open(setup)
if err != nil {
continue
}
p := Profile{Name: e.Name(), Path: filepath.Join(dir, e.Name()), Monitors: map[string]string{}}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) == 2 {
p.Monitors[fields[0]] = shortEDID(fields[1])
}
}
f.Close()
seen[e.Name()] = true
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// shortEDID is the fingerprint DecodeEDID gives for the same monitor, so a profile's monitors and the
// connected ones are compared by one value.
func shortEDID(hexEDID string) string {
b := make([]byte, 0, len(hexEDID)/2)
for i := 0; i+1 < len(hexEDID); i += 2 {
var v byte
for _, c := range hexEDID[i : i+2] {
v <<= 4
switch {
case c >= '0' && c <= '9':
v |= byte(c - '0')
case c >= 'a' && c <= 'f':
v |= byte(c - 'a' + 10)
case c >= 'A' && c <= 'F':
v |= byte(c - 'A' + 10)
default:
return hexEDID
}
}
b = append(b, v)
}
if id := DecodeEDID(b); id != nil {
return id.Fingerprint
}
return hexEDID
}
// names reads autorandr's one-name-per-line answers (--detected, --current).
func names(text string) map[string]bool {
out := map[string]bool{}
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimSpace(l); l != "" {
out[strings.Fields(l)[0]] = true
}
}
return out
}
+167
View File
@@ -0,0 +1,167 @@
// xorg's tools (novox/hq ADR 0208, research 026/05): node-display-server's verbs `displays` and
// `layout`, and the module's own tools for one output's mode, the primary output, DPI, input devices,
// the keyboard map, a screenshot and the server's log.
//
// Every tool that touches the screen acts in the operator's running session, which it finds through
// internal/desktop: the node's runtime has none of its own. With no session, the answer says so as
// structured data. A tool that changes the running server changes it until the next login; the
// answer says what makes it last.
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"xorg/internal/desktop"
)
// The session's holders this module prefers as the session's own process.
var sessionHolders = []string{"i3", "sway", "openbox", "bspwm", "awesome", "xmonad"}
func main() {
if err := stdio.Serve("", tools(desktop.Machine(sessionHolders...))); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// call bounds one tool call below the runtime's 30 s limit.
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return run(ctx, desktop.Args(args))
}
}
func tools(d desktop.Desk) []stdio.Tool {
x := xorg{d: d}
return []stdio.Tool{
{
Name: "node-display-server.displays",
Description: "The X screen and every output: connected or not, primary, where it sits, its rotation, " +
"physical size and DPI, the monitor's identity from its EDID (manufacturer, product, serial, name, " +
"fingerprint), the current and preferred mode, and every mode with its rates. Disconnected outputs " +
"are listed by name only unless all is true. Also the autorandr profile in force, when one matches.",
Input: desktop.Schema(map[string]any{
"all": desktop.Flag("list disconnected outputs in full (default false)"),
"modes": desktop.Flag("list every mode of each output (default true)"),
}),
Run: call(x.displays),
},
{
Name: "node-display-server.layout",
Description: "Monitor layout profiles (autorandr), keyed by the connected monitors' identities, so one " +
"profile needs no machine's name. list: every profile with the monitors it is for, which match " +
"the monitors connected now and which is in force. save: the current arrangement under a name " +
"(replace true to overwrite). apply: load one. The profiles are the operator's data, never the mesh's.",
Input: desktop.Schema(map[string]any{
"action": desktop.Enum("list, save or apply", "list", "save", "apply"),
"name": desktop.Str("the profile to save or apply"),
"replace": desktop.Flag("save over an existing profile of that name (default false)"),
}, "action"),
Run: call(x.layout),
},
{
Name: "xorg_set_mode",
Description: "Change one output now, through xrandr: its mode (WxH, or auto for the preferred one), rate, " +
"rotation, scale, position (x/y, or beside another output), on or off, primary. dry_run answers " +
"the command without running it. Lasts until the next login or hotplug: save a layout profile to keep it.",
Input: desktop.Schema(map[string]any{
"output": desktop.Str("the output, as displays names it (e.g. eDP-1)"),
"mode": desktop.Str("WxH, or auto for the monitor's preferred mode"),
"rate": desktop.Num("refresh rate in Hz"),
"rotate": desktop.Enum("rotation", "normal", "left", "right", "inverted"),
"scale": desktop.Num("scale factor, 0.25 to 4 (1 = none)"),
"x": desktop.Int("left edge on the screen, in pixels"),
"y": desktop.Int("top edge on the screen, in pixels"),
"relation": desktop.Enum("place it beside another output instead of at x/y", "right-of", "left-of", "above", "below", "same-as"),
"of": desktop.Str("the output the relation is to"),
"off": desktop.Flag("switch the output off"),
"primary": desktop.Flag("make it the primary output"),
"dry_run": desktop.Flag("answer the command, run nothing"),
}, "output"),
Run: call(x.setMode),
},
{
Name: "xorg_primary",
Description: "Which output is primary (the one the bar's tray and new windows prefer); with output, make it primary now.",
Input: desktop.Schema(map[string]any{"output": desktop.Str("the output to make primary (optional)")}),
Run: call(x.primary),
},
{
Name: "xorg_dpi",
Description: "The DPI X programs are told (Xft.dpi) beside each monitor's physical DPI. With value, set " +
"Xft.dpi and the screen's DPI now: programs started after it use it. The module's resources file " +
"sets it again at the next login (96 until it is a setting).",
Input: desktop.Schema(map[string]any{"value": desktop.Int("dots per inch, 48 to 480 (optional)")}),
Run: call(x.dpi),
},
{
Name: "xorg_input_devices",
Description: "The input devices the X server knows: id, name, pointer or keyboard, master or slave, and " +
"for each pointer its libinput settings (tap, natural scroll, acceleration, disable while typing, " +
"left handed, enabled).",
Input: desktop.Schema(map[string]any{"name": desktop.Str("only devices whose name contains this (optional)")}),
Run: call(x.inputDevices),
},
{
Name: "xorg_input_set",
Description: "Change libinput settings of a device now: tap, tap_drag, natural_scroll, disable_while_typing, " +
"left_handed, middle_emulation, enabled (true/false), accel_speed (-1 to 1). The device is an id or a " +
"name; a name applies to every device of that name that has the setting. Lasts until the device " +
"reconnects or the next login.",
Input: desktop.Schema(map[string]any{
"device": desktop.Str("the device's id or exact name"),
"tap": desktop.Flag("tap to click"),
"tap_drag": desktop.Flag("tap and drag"),
"natural_scroll": desktop.Flag("natural (reversed) scrolling"),
"disable_while_typing": desktop.Flag("ignore the touchpad while typing"),
"left_handed": desktop.Flag("swap the buttons"),
"middle_emulation": desktop.Flag("both buttons together are the middle one"),
"enabled": desktop.Flag("the device takes input at all"),
"accel_speed": desktop.Num("pointer acceleration, -1 to 1"),
}, "device"),
Run: call(x.inputSet),
},
{
Name: "xorg_keyboard",
Description: "The X keyboard map: rules, model, layout, variant and options. With layout, variant, model " +
"or options, set them now (options replace the current ones; an empty list clears them). Lasts " +
"until the next login.",
Input: desktop.Schema(map[string]any{
"layout": desktop.Str("e.g. us, be, us,be"),
"variant": desktop.Str("e.g. intl"),
"model": desktop.Str("e.g. pc105"),
"options": desktop.List("e.g. [\"caps:escape\", \"compose:ralt\"]"),
}),
Run: call(x.keyboard),
},
{
Name: "xorg_screenshot",
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " +
"Written under the account's home (default ~/Pictures/Screenshots/screenshot-<time>.png). Answers " +
"the path, size and bytes, never the image.",
Input: desktop.Schema(map[string]any{
"output": desktop.Str("only this output (optional)"),
"window": desktop.Str("only this window, by its X id, e.g. 0x3a00007 (optional)"),
"path": desktop.Str("where to write it, inside the home (optional)"),
}),
Run: call(x.screenshot),
},
{
Name: "xorg_x_log",
Description: "The X server's log for the running session (or the newest one): its version, when it " +
"started, and its errors — with warnings when asked — newest last.",
Input: desktop.Schema(map[string]any{
"warnings": desktop.Flag("include warnings (default false)"),
"limit": desktop.Int("at most this many lines (default 100, at most 1000)"),
}),
Run: call(x.xlog),
},
}
}
+243
View File
@@ -0,0 +1,243 @@
package main
// xorg's shape (novox/hq ADR 0208): it holds node-display-server and provides the display with the
// machine's reach; it writes the session's start as a block at the start of ~/.xinitrc in the order
// §5 gives, and the resources file every module contributes to. The block is also run, with the
// programs it calls replaced by recorders, to prove what it does rather than what it says.
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Provides []map[string]any `json:"provides"`
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 `json:"tools"`
Environment map[string]any `json:"environment"`
Shell []map[string]any `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
dec := json.NewDecoder(strings.NewReader(string(raw)))
if err := dec.Decode(&m); err != nil {
t.Fatal(err)
}
return m
}
func (m manifest) resource(t *testing.T, 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 TestItHoldsTheDisplayServerSeatAndServesItsVerbs(t *testing.T) {
m := readManifest(t)
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-server" || m.Claims[0].Scope != "node" {
t.Fatalf("claims: %+v", m.Claims)
}
if strings.Join(m.Claims[0].Serves, ",") != "displays,layout" {
t.Fatalf("serves: %v", m.Claims[0].Serves)
}
served := map[string]bool{}
for _, tool := range tools((&fakeDesk{}).desk(false)) {
served[tool.Name] = true
}
for _, v := range m.Claims[0].Serves {
if !served["node-display-server."+v] {
t.Errorf("the bundle does not serve the seat's %s", v)
}
}
own := 0
for name := range served {
if !strings.HasPrefix(name, "node-display-server.") {
own++
}
}
for _, name := range m.Tools {
if !served[name] || !strings.HasPrefix(name, "xorg_") {
t.Errorf("the manifest lists %s, which the bundle does not serve as its own", name)
}
}
if own != len(m.Tools) {
t.Errorf("the bundle serves %d tools of its own, the manifest lists %d", own, len(m.Tools))
}
}
func TestTheDisplayIsProvidedOnThisMachineOnlyAndGatedByASeatOfHardware(t *testing.T) {
m := readManifest(t)
if len(m.Provides) != 1 || m.Provides[0]["name"] != "x11-display" || m.Provides[0]["reach"] != "machine" {
t.Fatalf("provides: %v", m.Provides)
}
caps := strings.Join(m.Capabilities, ",")
if caps != "package-manager,seat" {
t.Fatalf("capabilities %s: seat is the hardware a display server needs; graphical-session is whether one runs now", caps)
}
}
func TestItInstallsTheServerItsStartAndTheToolsItsVerbsRun(t *testing.T) {
m := readManifest(t)
var got []string
for _, r := range m.Resources {
if r["type"] == "package" {
got = append(got, r["package"].(string))
}
}
sort.Strings(got)
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd"
if strings.Join(got, ",") != want {
t.Fatalf("packages: %v", got)
}
}
func TestTheResourcesFileHoldsTheDefaultsAndTheThreeSlots(t *testing.T) {
r := readManifest(t).resource(t, "xresources")
c := r["content"].(string)
if r["path"] != "${machine:account-home}/.config/xorg/xresources" || r["into"] != nil {
t.Fatalf("an owned file: %v", r)
}
first, normal, last := strings.Index(c, "${shell:xresources:first}"), strings.Index(c, "${shell:xresources:normal}"), strings.Index(c, "${shell:xresources:last}")
if !(first > strings.Index(c, "Xft.dpi: 96") && first < normal && normal < last) {
t.Fatal("the defaults, then first, normal and last")
}
for _, line := range strings.Split(c, "\n") {
if strings.HasPrefix(line, "#") {
t.Errorf("merged without cpp, a # line is skipped: %q", line)
}
}
}
func TestTheSessionStartIsABlockAtTheStartInTheOrderTheDecisionGives(t *testing.T) {
r := readManifest(t).resource(t, "xinitrc")
if r["path"] != "${machine:account-home}/.xinitrc" || r["into"] != "block" || r["at"] != "start" {
t.Fatalf("%v", r)
}
c := r["content"].(string)
order := []string{
". \"${machine:account-home}/.config/mesh/environment.sh\"",
"dbus-update-activation-environment --systemd $mesh_words",
"xrdb -nocpp -merge \"${machine:account-home}/.config/xorg/xresources\"",
"autorandr --change",
"${shell:xinitrc:first}",
"${shell:xinitrc:normal}",
". \"$HOME/.xinitrc.local\"",
"${shell:xinitrc:last}",
}
at := -1
for _, step := range order {
i := strings.Index(c, step)
if i <= at {
t.Fatalf("%q is not after the step before it", step)
}
at = i
if strings.Count(c, step) != 1 {
t.Fatalf("%q appears more than once", step)
}
}
var code []string
for _, line := range strings.Split(c, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
code = append(code, line)
}
}
lines := strings.Join(code, "\n")
if strings.Contains(lines, "--all") || strings.Contains(lines, "exec ") || strings.Contains(lines, "dbus-launch") {
t.Fatal("never --all, no exec of the block's own (the session's is contributed), no second bus")
}
if strings.Contains(c, "hal/env") || strings.Contains(c, "gnome-keyring") {
t.Fatal("the predecessor's secrets file and the keyring are not the display server's")
}
}
// render fills the block's placeholders as the controller would, with one contribution per slot.
func render(c, home string, slots map[string]string) string {
c = strings.ReplaceAll(c, "${machine:account-home}", home)
for _, slot := range []string{"first", "normal", "last"} {
code := ""
if s, ok := slots[slot]; ok {
code = "# a-module\n" + s + "\n"
}
c = strings.ReplaceAll(c, "${shell:xinitrc:"+slot+"}", code)
}
return c
}
func TestTheBlockRunsTheEnvironmentTheImportTheResourcesTheSlotsAndEndsInTheSession(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("no sh")
}
home := t.TempDir()
bin := filepath.Join(home, "bin")
log := filepath.Join(home, "log")
os.MkdirAll(filepath.Join(home, ".config", "mesh"), 0o755)
os.MkdirAll(bin, 0o755)
os.WriteFile(filepath.Join(home, ".config", "mesh", "environment.sh"),
[]byte("export XDG_CURRENT_DESKTOP=i3\nexport GTK_THEME=Adwaita:dark\n"), 0o644)
os.WriteFile(filepath.Join(home, ".xinitrc.local"), []byte("echo local >> \""+log+"\"\n"), 0o644)
for _, prog := range []string{"dbus-update-activation-environment", "xrdb", "autorandr", "the-session"} {
os.WriteFile(filepath.Join(bin, prog), []byte("#!/bin/sh\necho \""+prog+" $*\" >> \""+log+"\"\n"), 0o755)
}
block := render(readManifest(t).resource(t, "xinitrc")["content"].(string), home, map[string]string{
"normal": "echo normal >> \"" + log + "\"",
"last": "exec the-session",
})
file := "#!/bin/sh\n# BEGIN mesh xorg.xinitrc\n" + block + "# END mesh xorg.xinitrc\n" +
"export SECRET_TOKEN=x\necho operator-below >> \"" + log + "\"\nexec i3\n"
os.WriteFile(filepath.Join(home, ".xinitrc"), []byte(file), 0o755)
os.WriteFile(filepath.Join(home, ".Xresources"), []byte("XTerm*saveLines: 1\n"), 0o644)
cmd := exec.Command(sh, filepath.Join(home, ".xinitrc"))
cmd.Env = []string{"HOME=" + home, "PATH=" + bin + ":/usr/bin:/bin", "DISPLAY=:7", "XAUTHORITY=" + home + "/.Xauthority",
"XDG_SESSION_TYPE=x11", "DBUS_SESSION_BUS_ADDRESS=unix:path=/nowhere", "SECRET_KEY=y"}
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v: %s", err, out)
}
raw, _ := os.ReadFile(log)
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
want := []string{
"dbus-update-activation-environment --systemd DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP GTK_THEME",
"xrdb -nocpp -merge " + home + "/.config/xorg/xresources",
"xrdb -merge " + home + "/.Xresources",
"autorandr --change",
"normal",
"local",
"the-session",
}
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
t.Fatalf("ran:\n%s\nwant:\n%s", strings.Join(lines, "\n"), strings.Join(want, "\n"))
}
}
func TestTheBundleIsGoBuiltForArch(t *testing.T) {
a := readManifest(t).Build.Artifacts
if len(a) != 1 || a[0]["language"] != "go" || a[0]["system"] != "arch" || a[0]["from"] != "cmd/xorg" || a[0]["binary"] != "xorg" {
t.Fatalf("%v", a)
}
}
+3
View File
@@ -0,0 +1,3 @@
rules: evdev
model: pc105
layout: us
+25
View File
@@ -0,0 +1,25 @@
⎡ Virtual core pointer id=2 [master pointer (3)]
⎜ ↳ Virtual core XTEST pointer id=4 [slave pointer (2)]
⎜ ↳ Logitech MX Master 3 id=7 [slave pointer (2)]
⎜ ↳ Logitech MX Master 3 id=8 [slave pointer (2)]
⎜ ↳ Logitech MX Keys id=9 [slave pointer (2)]
⎜ ↳ Razer Razer Basilisk V3 Pro id=10 [slave pointer (2)]
⎜ ↳ Razer Razer Basilisk V3 Pro id=11 [slave pointer (2)]
⎜ ↳ ITE Tech. Inc. ITE Device(8910) id=13 [slave pointer (2)]
⎜ ↳ ASUP1208:00 093A:3011 Mouse id=15 [slave pointer (2)]
⎜ ↳ ASUP1208:00 093A:3011 Touchpad id=16 [slave pointer (2)]
⎜ ↳ ydotoold virtual device id=20 [slave pointer (2)]
⎣ Virtual core keyboard id=3 [master keyboard (2)]
↳ Virtual core XTEST keyboard id=5 [slave keyboard (3)]
↳ Video Bus id=6 [slave keyboard (3)]
↳ Razer Razer Basilisk V3 Pro id=12 [slave keyboard (3)]
↳ Video Bus id=14 [slave keyboard (3)]
↳ Power Button id=17 [slave keyboard (3)]
↳ Sleep Button id=18 [slave keyboard (3)]
↳ Asus WMI hotkeys id=19 [slave keyboard (3)]
↳ Logitech MX Master 3 id=21 [slave keyboard (3)]
↳ Logitech MX Master 3 id=22 [slave keyboard (3)]
↳ Logitech MX Keys id=23 [slave keyboard (3)]
↳ Razer Razer Basilisk V3 Pro id=24 [slave keyboard (3)]
↳ ITE Tech. Inc. ITE Device(8910) id=25 [slave keyboard (3)]
↳ ydotoold virtual device id=26 [slave keyboard (3)]
+48
View File
@@ -0,0 +1,48 @@
Device 'ASUP1208:00 093A:3011 Touchpad':
Device Enabled (187): 1
Coordinate Transformation Matrix (189): 1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000
libinput Tapping Enabled (347): 1
libinput Tapping Enabled Default (348): 0
libinput Tapping Drag Enabled (349): 1
libinput Tapping Drag Enabled Default (350): 1
libinput Tapping Drag Lock Enabled (351): 0
libinput Tapping Drag Lock Enabled Default (352): 0
libinput Tapping Button Mapping Enabled (353): 1, 0
libinput Tapping Button Mapping Default (354): 1, 0
libinput Natural Scrolling Enabled (318): 1
libinput Natural Scrolling Enabled Default (319): 0
libinput Disable While Typing Enabled (355): 1
libinput Disable While Typing Enabled Default (356): 1
libinput Scroll Methods Available (320): 1, 1, 0
libinput Scroll Method Enabled (321): 1, 0, 0
libinput Scroll Method Enabled Default (322): 1, 0, 0
libinput Click Methods Available (357): 1, 1
libinput Click Method Enabled (358): 1, 0
libinput Click Method Enabled Default (359): 1, 0
libinput Clickfinger Button Mapping Enabled (360): 1, 0
libinput Clickfinger Button Mapping Default (361): 1, 0
libinput Middle Emulation Enabled (327): 0
libinput Middle Emulation Enabled Default (328): 0
libinput Accel Speed (329): 0.150000
libinput Accel Speed Default (330): 0.000000
libinput Accel Profiles Available (331): 1, 1, 1
libinput Accel Profile Enabled (332): 1, 0, 0
libinput Accel Profile Enabled Default (333): 1, 0, 0
libinput Accel Custom Fallback Points (334): <no items>
libinput Accel Custom Fallback Step (335): 0.000000
libinput Accel Custom Motion Points (336): <no items>
libinput Accel Custom Motion Step (337): 0.000000
libinput Accel Custom Scroll Points (338): <no items>
libinput Accel Custom Scroll Step (339): 0.000000
libinput Left Handed Enabled (340): 0
libinput Left Handed Enabled Default (341): 0
libinput Send Events Modes Available (303): 1, 1
libinput Send Events Mode Enabled (304): 0, 0
libinput Send Events Mode Enabled Default (305): 0, 0
Device Node (306): "/dev/input/event20"
Device Product ID (307): 2362, 12305
libinput Drag Lock Buttons (342): <no items>
libinput Horizontal Scroll Enabled (343): 1
libinput Scrolling Pixel Distance (344): 15
libinput Scrolling Pixel Distance Default (345): 15
libinput High Resolution Wheel Scroll Enabled (346): 1
+437
View File
@@ -0,0 +1,437 @@
Screen 0: minimum 320 x 200, current 2880 x 1800, maximum 16384 x 16384
eDP-1 connected primary 2880x1800+0+0 (normal left inverted right x axis y axis) 302mm x 189mm
EDID:
00ffffffffffff004c839c4100000000
00210104b51e137803cfd1ae513eb623
0b505400000001010101010101010101
010101010101cbfe4064b00838772008
88002ebd1000001b000000fd003078da
da420100000000000000000000fe0053
44430a202020202020202020000000fc
0041544e413430435530352d30200127
702079020020000c4c83009c41000000
00002100260009040000000000500000
220014e7f309853f0b63001f00070007
071700070007002b000c270030770000
2700303b000081001f731a0000030330
7800a07402600278000000008de30580
00e60605017460022e00061810405ed0
6000000000000000000000000000fc90
panel_type: OLED
supported: unknown, OLED
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 1
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 383
supported: 383
non-desktop: 0
range: (0, 1)
2880x1800 60.00*+ 120.00 +
2880x1620 59.96 59.97
2560x1600 59.99 59.97
2560x1440 59.99 59.99 59.96 59.95
2048x1536 85.00 75.00 60.00
1920x1440 85.00 75.00 60.00
1856x1392 75.00 60.01
1792x1344 75.00 60.01
2048x1152 59.99 59.98 59.90 59.91
1920x1200 120.00 59.88 59.95
1920x1080 120.00 60.01 59.97 59.96 59.93
1600x1200 120.00 85.00 75.00 70.00 65.00 60.00
1680x1050 120.00 59.95 59.88
1400x1050 74.76 59.98
1600x900 59.99 59.94 59.95 59.82
1280x1024 120.00 85.02 75.02 60.02
1440x900 120.00
1400x900 59.96 59.88
1280x960 85.00 60.00
1440x810 60.00 59.97
1368x768 59.88 59.85
1280x800 120.00 59.99 59.97 59.81 59.91
1152x864 75.00
1280x720 120.00 60.00 59.99 59.86 59.74
1024x768 120.00 85.00 75.05 60.04 85.00 75.03 70.07 60.00
1024x768i 86.96
960x720 85.00 75.00 60.00
928x696 75.00 60.05
896x672 75.05 60.01
1024x576 59.95 59.96 59.90 59.82
960x600 59.93 60.00
832x624 74.55
960x540 59.96 59.99 59.63 59.82
800x600 120.00 85.00 75.00 70.00 65.00 60.00 85.14 72.19 75.00 60.32 56.25
840x525 60.01 59.88
864x486 59.92 59.57
700x525 74.76 59.98
800x450 59.95 59.82
640x512 85.02 75.02 60.02
700x450 59.96 59.88
640x480 120.00 85.09 60.00 85.01 72.81 75.00 59.94
720x405 59.51 58.99
720x400 85.04
684x384 59.88 59.85
640x400 59.88 59.98 85.08
576x432 75.00
640x360 59.86 59.83 59.84 59.32
640x350 85.08
512x384 85.00 75.03 70.07 60.00
512x384i 87.06
512x288 60.00 59.92
416x312 74.66
480x270 59.63 59.82
400x300 85.27 72.19 75.12 60.32 56.34
432x243 59.92 59.57
320x240 85.18 72.81 75.00 60.05
360x202 59.51 59.13
360x200 85.04
320x200 85.27
320x180 59.84 59.32
320x175 85.27
DP-1 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 391
supported: 391
non-desktop: 0
range: (0, 1)
DP-2 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 398
supported: 398
non-desktop: 0
range: (0, 1)
DP-3 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 405
supported: 405
non-desktop: 0
range: (0, 1)
DP-4 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 411
supported: 411
non-desktop: 0
range: (0, 1)
DP-5 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 417
supported: 417
non-desktop: 0
range: (0, 1)
DP-6 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 423
supported: 423
non-desktop: 0
range: (0, 1)
DP-7 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 429
supported: 429
non-desktop: 0
range: (0, 1)
DP-8 disconnected (normal left inverted right x axis y axis)
subconnector: Unknown
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
HDCP Content Type: HDCP Type0
supported: HDCP Type0, HDCP Type1
Content Protection: Undesired
supported: Undesired, Desired, Enabled
vrr_capable: 0
range: (0, 1)
Colorspace: Default
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
max bpc: 16
range: (8, 16)
underscan vborder: 0
range: (0, 128)
underscan hborder: 0
range: (0, 128)
underscan: off
supported: off, on, auto
Broadcast RGB: Automatic
supported: Automatic, Full, Limited 16:235
scaling mode: None
supported: None, Full, Center, Full aspect
link-status: Good
supported: Good, Bad
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CONNECTOR_ID: 435
supported: 435
non-desktop: 0
range: (0, 1)
DP-1-0 disconnected (normal left inverted right x axis y axis)
PRIME Synchronization: 1
supported: 0, 1
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
BorderDimensions: 4
supported: 4
Border: 0 0 0 0
range: (0, 65535)
SignalFormat: DisplayPort
supported: DisplayPort
ConnectorType: DisplayPort
ConnectorNumber: 0
_ConnectorLocation: 0
non-desktop: 0
supported: 0, 1
DP-1-1 disconnected (normal left inverted right x axis y axis)
PRIME Synchronization: 1
supported: 0, 1
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
BorderDimensions: 4
supported: 4
Border: 0 0 0 0
range: (0, 65535)
SignalFormat: TMDS
supported: TMDS
ConnectorType: DisplayPort
ConnectorNumber: 0
_ConnectorLocation: 0
non-desktop: 0
supported: 0, 1
HDMI-1-0 disconnected (normal left inverted right x axis y axis)
PRIME Synchronization: 1
supported: 0, 1
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
BorderDimensions: 4
supported: 4
Border: 0 0 0 0
range: (0, 65535)
SignalFormat: TMDS
supported: TMDS
ConnectorType: HDMI
ConnectorNumber: 2
_ConnectorLocation: 2
non-desktop: 0
supported: 0, 1
DP-1-2 disconnected (normal left inverted right x axis y axis)
PRIME Synchronization: 1
supported: 0, 1
CTM: 1.000000 0.000000 0.000000
0.000000 1.000000 0.000000
0.000000 0.000000 1.000000
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
BorderDimensions: 4
supported: 4
Border: 0 0 0 0
range: (0, 65535)
SignalFormat: DisplayPort
supported: DisplayPort
ConnectorType: Panel
ConnectorNumber: 3
_ConnectorLocation: 3
non-desktop: 0
supported: 0, 1
+602
View File
@@ -0,0 +1,602 @@
package main
import (
"bytes"
"context"
"fmt"
"image"
"image/draw"
"image/png"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"xorg/internal/desktop"
)
type xorg struct{ d desktop.Desk }
// restored is what every answer that changed the running server says about lasting.
const untilLogin = "until the next login: the session's start applies the declared defaults and the matching layout profile again"
var outputName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
func (x xorg) query(ctx context.Context) (Screen, []Output, *desktop.Session, error) {
res, s, err := x.d.InSession(ctx, "xrandr", "--prop")
if err != nil {
return Screen{}, nil, nil, err
}
if !res.OK() {
return Screen{}, nil, s, res.Err()
}
screen, outs := ParseXrandr(res.Stdout)
return screen, outs, s, nil
}
func (x xorg) displays(ctx context.Context, a desktop.Args) (any, error) {
all, _, err := a.Bool("all")
if err != nil {
return nil, err
}
modes, given, err := a.Bool("modes")
if err != nil {
return nil, err
}
if !given {
modes = true
}
screen, outs, s, err := x.query(ctx)
if err != nil {
return nil, err
}
var shown []Output
var off []string
for _, o := range outs {
if !o.Connected && !all {
off = append(off, o.Name)
continue
}
if !modes {
o.Modes = nil
}
shown = append(shown, o)
}
answer := map[string]any{"session": s.Display, "screen": screen, "outputs": shown, "disconnected": off}
if r := x.d.Run(ctx, s.Env(x.d.Base), nil, "autorandr", "--current"); r.OK() {
answer["profile"] = firstName(r.Stdout)
}
return answer, nil
}
func firstName(text string) string {
for n := range names(text) {
return n
}
return ""
}
func (x xorg) layout(ctx context.Context, a desktop.Args) (any, error) {
action, err := a.OneOf("action", "", "list", "save", "apply")
if err != nil {
return nil, err
}
dirs := ProfileDirs(desktop.Home())
if action == "list" {
profiles := ReadProfiles(dirs)
answer := map[string]any{"profiles": profiles, "where": dirs[0]}
s, err := x.d.Find()
if err != nil {
answer["session"] = nil
answer["note"] = "no graphical session: which profile matches the monitors connected is not known"
return answer, nil
}
env := s.Env(x.d.Base)
det := x.d.Run(ctx, env, nil, "autorandr", "--detected")
if det.Code == 127 {
return nil, fmt.Errorf("autorandr is not installed here; the xorg module's package resource installs it")
}
detected, current := names(det.Stdout), names(x.d.Run(ctx, env, nil, "autorandr", "--current").Stdout)
for i := range profiles {
profiles[i].Detected, profiles[i].Current = detected[profiles[i].Name], current[profiles[i].Name]
}
answer["profiles"] = profiles
return answer, nil
}
name, err := a.Text("name")
if err != nil {
return nil, err
}
if !profileName.MatchString(name) {
return nil, fmt.Errorf("a profile name is letters, digits, dot, dash and underscore, up to 64")
}
args := []string{"--load", name}
if action == "save" {
replace, _, err := a.Bool("replace")
if err != nil {
return nil, err
}
args = []string{"--save", name}
if replace {
args = append(args, "--force")
}
}
res, s, err := x.d.InSession(ctx, "autorandr", args...)
if err != nil {
return nil, err
}
if res.Code == 127 {
return nil, fmt.Errorf("autorandr is not installed here; the xorg module's package resource installs it")
}
if !res.OK() {
return nil, res.Err()
}
answer := map[string]any{"action": action, "name": name, "session": s.Display, "said": strings.TrimSpace(res.Stdout + res.Stderr)}
if action == "save" {
answer["kept"] = "in " + dirs[0] + ": the operator's data; the session's start applies it whenever these monitors are connected"
}
return answer, nil
}
// ModeArgs is the xrandr command line set-mode asks for, refused before anything runs when it is not
// one xrandr would take.
func ModeArgs(a desktop.Args) ([]string, error) {
output, err := a.Text("output")
if err != nil {
return nil, err
}
if !outputName.MatchString(output) {
return nil, fmt.Errorf("%q is not an output name", output)
}
args := []string{"--output", output}
off, _, err := a.Bool("off")
if err != nil {
return nil, err
}
if off {
return append(args, "--off"), nil
}
switch mode := a.Opt("mode", ""); {
case mode == "auto":
args = append(args, "--auto")
case mode != "":
if !regexp.MustCompile(`^\d{2,5}x\d{2,5}i?$`).MatchString(mode) {
return nil, fmt.Errorf("mode is WxH, e.g. 2560x1440, or auto")
}
args = append(args, "--mode", mode)
}
if rate, given, err := a.Number("rate"); err != nil {
return nil, err
} else if given {
if rate < 1 || rate > 1000 {
return nil, fmt.Errorf("rate is in Hz, 1 to 1000")
}
args = append(args, "--rate", strconv.FormatFloat(rate, 'f', 2, 64))
}
if a.Has("rotate") {
r, err := a.OneOf("rotate", "", "normal", "left", "right", "inverted")
if err != nil {
return nil, err
}
args = append(args, "--rotate", r)
}
if scale, given, err := a.Number("scale"); err != nil {
return nil, err
} else if given {
if scale < 0.25 || scale > 4 {
return nil, fmt.Errorf("scale is from 0.25 to 4")
}
f := strconv.FormatFloat(scale, 'f', -1, 64)
args = append(args, "--scale", f+"x"+f)
}
if a.Has("relation") {
rel, err := a.OneOf("relation", "", "right-of", "left-of", "above", "below", "same-as")
if err != nil {
return nil, err
}
of, err := a.Text("of")
if err != nil || !outputName.MatchString(of) {
return nil, fmt.Errorf("relation needs of: the output it is beside")
}
args = append(args, "--"+rel, of)
} else if a.Has("x") || a.Has("y") {
px, err := a.Whole("x", 0, -32768, 32768)
if err != nil {
return nil, err
}
py, err := a.Whole("y", 0, -32768, 32768)
if err != nil {
return nil, err
}
args = append(args, "--pos", fmt.Sprintf("%dx%d", px, py))
}
if p, _, err := a.Bool("primary"); err != nil {
return nil, err
} else if p {
args = append(args, "--primary")
}
if len(args) == 2 {
return nil, fmt.Errorf("nothing to change: give a mode, rate, rotate, scale, position, off or primary")
}
return args, nil
}
func (x xorg) setMode(ctx context.Context, a desktop.Args) (any, error) {
args, err := ModeArgs(a)
if err != nil {
return nil, err
}
if dry, _, _ := a.Bool("dry_run"); dry {
return map[string]any{"dry_run": true, "command": append([]string{"xrandr"}, args...)}, nil
}
res, s, err := x.d.InSession(ctx, "xrandr", args...)
if err != nil {
return nil, err
}
if !res.OK() {
return nil, res.Err()
}
_, outs, _, _ := x.query(ctx)
var now *Output
for i := range outs {
if outs[i].Name == args[1] {
o := outs[i]
o.Modes = nil
now = &o
}
}
return map[string]any{"session": s.Display, "command": res.Command, "output": now, "lasts": untilLogin}, nil
}
func (x xorg) primary(ctx context.Context, a desktop.Args) (any, error) {
if name := a.Opt("output", ""); name != "" {
if !outputName.MatchString(name) {
return nil, fmt.Errorf("%q is not an output name", name)
}
res, _, err := x.d.InSession(ctx, "xrandr", "--output", name, "--primary")
if err != nil {
return nil, err
}
if !res.OK() {
return nil, res.Err()
}
}
_, outs, s, err := x.query(ctx)
if err != nil {
return nil, err
}
answer := map[string]any{"session": s.Display, "primary": nil}
for _, o := range outs {
if o.Primary {
answer["primary"] = o.Name
}
}
if a.Has("output") {
answer["lasts"] = untilLogin
}
return answer, nil
}
// ResourceValue is one resource's value from `xrdb -query` output.
func ResourceValue(query, name string) string {
for _, l := range strings.Split(query, "\n") {
if k, v, ok := strings.Cut(l, ":"); ok && strings.TrimSpace(k) == name {
return strings.TrimSpace(v)
}
}
return ""
}
func (x xorg) dpi(ctx context.Context, a desktop.Args) (any, error) {
value, err := a.Whole("value", 0, 48, 480)
if err != nil {
return nil, err
}
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
if value != 0 {
v := strconv.Itoa(value)
if r := x.d.Run(ctx, env, []byte("Xft.dpi: "+v+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
return nil, r.Err()
}
if r := x.d.Run(ctx, env, nil, "xrandr", "--dpi", v); !r.OK() {
return nil, r.Err()
}
}
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
if !q.OK() {
return nil, q.Err()
}
_, outs, _, err := x.query(ctx)
if err != nil {
return nil, err
}
physical := map[string]float64{}
for _, o := range outs {
if o.DPI > 0 {
physical[o.Name] = o.DPI
}
}
answer := map[string]any{"session": s.Display, "xft_dpi": ResourceValue(q.Stdout, "Xft.dpi"), "physical": physical}
if value != 0 {
answer["set"] = value
answer["lasts"] = "for programs started from now on, " + untilLogin
}
return answer, nil
}
func (x xorg) devices(ctx context.Context, env []string) ([]Device, error) {
r := x.d.Run(ctx, env, nil, "xinput", "list")
if !r.OK() {
return nil, r.Err()
}
return ParseDevices(r.Stdout), nil
}
func (x xorg) inputDevices(ctx context.Context, a desktop.Args) (any, error) {
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
all, err := x.devices(ctx, env)
if err != nil {
return nil, err
}
filter := strings.ToLower(a.Opt("name", ""))
var out []Device
for _, dev := range all {
if filter != "" && !strings.Contains(strings.ToLower(dev.Name), filter) {
continue
}
if dev.Role == "slave" && dev.Kind == "pointer" && !strings.Contains(dev.Name, "XTEST") {
if r := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID)); r.OK() {
dev.Settings = ParseProps(r.Stdout)
}
}
out = append(out, dev)
}
return map[string]any{"session": s.Display, "devices": out}, nil
}
func (x xorg) inputSet(ctx context.Context, a desktop.Args) (any, error) {
which, err := a.Text("device")
if err != nil {
return nil, err
}
type change struct {
knob Knob
value string
}
var changes []change
for _, k := range Knobs {
if !a.Has(k.Arg) {
continue
}
switch k.Kind {
case "bool":
b, _, err := a.Bool(k.Arg)
if err != nil {
return nil, err
}
changes = append(changes, change{k, map[bool]string{true: "1", false: "0"}[b]})
case "float":
f, _, err := a.Number(k.Arg)
if err != nil {
return nil, err
}
if f < -1 || f > 1 {
return nil, fmt.Errorf("%s is from -1 to 1", k.Arg)
}
changes = append(changes, change{k, strconv.FormatFloat(f, 'f', 3, 64)})
}
}
if len(changes) == 0 {
return nil, fmt.Errorf("nothing to change: name at least one setting")
}
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
all, err := x.devices(ctx, env)
if err != nil {
return nil, err
}
var ids []Device
for _, dev := range all {
if dev.Role == "slave" && (strconv.Itoa(dev.ID) == which || dev.Name == which) {
ids = append(ids, dev)
}
}
if len(ids) == 0 {
return nil, fmt.Errorf("no input device is %q: xorg_input_devices lists them", which)
}
type outcome struct {
ID int `json:"id"`
Name string `json:"name"`
Set []string `json:"set"`
Skipped []string `json:"skipped,omitempty"`
Now map[string]any `json:"now"`
}
var done []outcome
for _, dev := range ids {
props := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID))
have := ParseProps(props.Stdout)
o := outcome{ID: dev.ID, Name: dev.Name, Set: []string{}}
for _, c := range changes {
if _, ok := have[c.knob.Arg]; !ok {
o.Skipped = append(o.Skipped, c.knob.Arg)
continue
}
r := x.d.Run(ctx, env, nil, "xinput", "set-prop", strconv.Itoa(dev.ID), c.knob.Property, c.value)
if !r.OK() {
return nil, r.Err()
}
o.Set = append(o.Set, c.knob.Arg)
}
after := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID))
o.Now = ParseProps(after.Stdout)
done = append(done, o)
}
return map[string]any{"session": s.Display, "devices": done, "lasts": "until the device reconnects or the next login"}, nil
}
func (x xorg) keyboard(ctx context.Context, a desktop.Args) (any, error) {
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
var args []string
for _, k := range []string{"layout", "variant", "model"} {
if v := a.Opt(k, ""); v != "" {
if !xkbName.MatchString(v) {
return nil, fmt.Errorf("%s %q is not an XKB name", k, v)
}
args = append(args, "-"+k, v)
}
}
if a.Has("options") {
opts, err := a.Strings("options")
if err != nil {
return nil, err
}
args = append(args, "-option", "")
for _, o := range opts {
if !xkbName.MatchString(o) {
return nil, fmt.Errorf("option %q is not an XKB name", o)
}
args = append(args, "-option", o)
}
}
if len(args) > 0 {
if r := x.d.Run(ctx, env, nil, "setxkbmap", args...); !r.OK() {
return nil, r.Err()
}
}
q := x.d.Run(ctx, env, nil, "setxkbmap", "-query")
if !q.OK() {
return nil, q.Err()
}
answer := map[string]any{"session": s.Display, "keyboard": ParseKeyboard(q.Stdout)}
if len(args) > 0 {
answer["lasts"] = untilLogin
}
return answer, nil
}
var windowID = regexp.MustCompile(`^(0x[0-9a-fA-F]{1,8}|[0-9]{1,10})$`)
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
stamp := time.Now().Format("20060102-150405")
path, err := desktop.InHome(a.Opt("path", "~/Pictures/Screenshots/screenshot-"+stamp+".png"))
if err != nil {
return nil, err
}
if !strings.HasSuffix(strings.ToLower(path), ".png") {
return nil, fmt.Errorf("the screenshot is a PNG: the path ends in .png")
}
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
dump, err := os.CreateTemp("", "xorg-screenshot-*.xwd")
if err != nil {
return nil, err
}
dump.Close()
defer os.Remove(dump.Name())
args := []string{"-silent", "-out", dump.Name()}
if w := a.Opt("window", ""); w != "" {
if !windowID.MatchString(w) {
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
}
args = append(args, "-id", w)
} else {
args = append(args, "-root")
}
if r := x.d.Run(ctx, env, nil, "xwd", args...); !r.OK() {
if r.Code == 127 {
return nil, fmt.Errorf("xwd is not installed here; the xorg module's package resource (xorg-xwd) installs it")
}
return nil, r.Err()
}
raw, err := os.ReadFile(dump.Name())
if err != nil {
return nil, err
}
img, err := DecodeXWD(raw)
if err != nil {
return nil, fmt.Errorf("the dump xwd wrote: %w", err)
}
var shot image.Image = img
if name := a.Opt("output", ""); name != "" {
_, outs, _, err := x.query(ctx)
if err != nil {
return nil, err
}
var g *Geometry
for _, o := range outs {
if o.Name == name {
g = o.Geometry
}
}
if g == nil {
return nil, fmt.Errorf("output %q is not on: displays lists the outputs in use", name)
}
r := image.Rect(g.X, g.Y, g.X+g.Width, g.Y+g.Height).Intersect(img.Bounds())
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
shot = crop
}
var buf bytes.Buffer
if err := png.Encode(&buf, shot); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return nil, err
}
b := shot.Bounds()
return map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}, nil
}
func (x xorg) xlog(ctx context.Context, a desktop.Args) (any, error) {
warnings, _, err := a.Bool("warnings")
if err != nil {
return nil, err
}
limit, err := a.Whole("limit", 100, 1, 1000)
if err != nil {
return nil, err
}
display := -1
if s, err := x.d.Find(); err == nil {
if n, ok := desktop.DisplayNumber(s.Display); ok {
display = n
}
}
files := XLogFiles(display, desktop.Home())
if display >= 0 {
files = append(files, XLogFiles(-1, desktop.Home())...)
}
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
continue
}
return ParseXLog(f, string(raw), warnings, limit), nil
}
sort.Strings(files)
return nil, fmt.Errorf("no X server log is readable here; looked at %s", strings.Join(files, ", "))
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// LogLine is one marked line of the X server's log.
type LogLine struct {
At float64 `json:"at_seconds"`
Level string `json:"level"`
Text string `json:"text"`
}
// XLog is what x-log answers.
type XLog struct {
File string `json:"file"`
Server string `json:"server,omitempty"`
Started string `json:"started,omitempty"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Lines []LogLine `json:"lines"`
Cut bool `json:"cut,omitempty"`
}
var (
logMark = regexp.MustCompile(`^\[\s*([0-9.]+)\] \((EE|WW)\) (.*)$`)
logStart = regexp.MustCompile(`\(==\) Log file: "[^"]*", Time: (.+)$`)
)
// ParseXLog reads an Xorg log: the server's version, when it started, and its errors and (when
// asked) warnings, the last `limit` of them. The legend line that explains the markers is not one.
func ParseXLog(file, text string, warnings bool, limit int) XLog {
out := XLog{File: file, Lines: []LogLine{}}
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 1<<20), 1<<22)
for sc.Scan() {
line := sc.Text()
if out.Server == "" && strings.HasPrefix(strings.TrimSpace(line), "X.Org X Server ") {
out.Server = strings.TrimSpace(line)
}
if m := logStart.FindStringSubmatch(line); m != nil && out.Started == "" {
out.Started = m[1]
}
m := logMark.FindStringSubmatch(line)
if m == nil {
continue
}
at, _ := strconv.ParseFloat(m[1], 64)
level := map[string]string{"EE": "error", "WW": "warning"}[m[2]]
if level == "error" {
out.Errors++
} else {
out.Warnings++
if !warnings {
continue
}
}
out.Lines = append(out.Lines, LogLine{At: at, Level: level, Text: m[3]})
}
if limit > 0 && len(out.Lines) > limit {
out.Lines = out.Lines[len(out.Lines)-limit:]
out.Cut = true
}
return out
}
// XLogFiles are where an X server on display n logs, best first: a root server's log, then a
// rootless one's under the account's data directory. With no display, every log found, newest first.
func XLogFiles(display int, home string) []string {
dirs := []string{"/var/log", filepath.Join(home, ".local/share/xorg")}
if display >= 0 {
var out []string
for _, d := range dirs {
out = append(out, filepath.Join(d, "Xorg."+strconv.Itoa(display)+".log"))
}
return out
}
type found struct {
path string
mod int64
}
var all []found
for _, d := range dirs {
matches, _ := filepath.Glob(filepath.Join(d, "Xorg.*.log"))
for _, p := range matches {
if info, err := os.Stat(p); err == nil {
all = append(all, found{p, info.ModTime().UnixNano()})
}
}
}
sort.Slice(all, func(i, j int) bool { return all[i].mod > all[j].mod })
var out []string
for _, f := range all {
out = append(out, f.path)
}
return out
}
+338
View File
@@ -0,0 +1,338 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"image/color"
"os"
"path/filepath"
"strings"
"testing"
"xorg/internal/desktop"
)
func fixture(t *testing.T, name string) string {
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
// fakeDesk answers commands from a table keyed by the command line, and records what ran.
type fakeDesk struct {
answers map[string]desktop.Result
ran []string
env [][]string
}
func (f *fakeDesk) desk(session bool) desktop.Desk {
return desktop.Desk{
Find: func() (*desktop.Session, error) {
if !session {
return nil, &desktop.NoSession{Reason: "no process of uid 1000 carries a display"}
}
return &desktop.Session{UID: 1000, Display: ":1", Type: "x11", XAuthority: "/home/op/.Xauthority", RuntimeDir: "/run/user/1000"}, nil
},
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)
f.env = append(f.env, env)
if r, ok := f.answers[line]; ok {
r.Command = append([]string{name}, args...)
return r
}
return desktop.Result{Command: append([]string{name}, args...), Code: 127, Stderr: "not in the table: " + line}
},
Base: []string{"PATH=/usr/bin"},
}
}
func byName(t *testing.T, d desktop.Desk, name string) func(map[string]any) (any, error) {
for _, tool := range tools(d) {
if tool.Name == name {
return tool.Run
}
}
t.Fatalf("no tool %s", name)
return nil
}
func TestTheOutputsAndTheMonitorsIdentityAreReadFromXrandr(t *testing.T) {
screen, outs := ParseXrandr(fixture(t, "xrandr-prop.txt"))
if screen.Width != 2880 || screen.Height != 1800 || screen.MaxWidth != 16384 {
t.Fatalf("screen: %+v", screen)
}
if len(outs) != 13 {
t.Fatalf("%d outputs", len(outs))
}
e := outs[0]
if e.Name != "eDP-1" || !e.Connected || !e.Primary || !e.Enabled || e.Rotation != "normal" || e.WidthMM != 302 {
t.Fatalf("eDP-1: %+v", e)
}
if *e.Geometry != (Geometry{2880, 1800, 0, 0}) || e.DPI != 242.2 {
t.Fatalf("geometry and DPI: %+v %v", e.Geometry, e.DPI)
}
if e.Current == nil || e.Current.Rate != 60 || e.Preferred == nil || e.Preferred.Size != "2880x1800" || e.Preferred.Rate != 60 {
t.Fatalf("current %+v preferred %+v", e.Current, e.Preferred)
}
if e.Monitor == nil || e.Monitor.Manufacturer != "SDC" || e.Monitor.Product != "419C" || e.Monitor.Name != "ATNA40CU05-0" || len(e.Monitor.Fingerprint) != 12 {
t.Fatalf("identity: %+v", e.Monitor)
}
if len(e.Modes) < 20 || e.Modes[0].Size != "2880x1800" || len(e.Modes[0].Rates) != 2 {
t.Fatalf("modes: %+v", e.Modes[:2])
}
if outs[1].Connected || outs[1].Enabled || outs[1].Name != "DP-1" {
t.Fatalf("a disconnected output: %+v", outs[1])
}
}
func TestARotatedSecondMonitorIsReadWithItsPlaceAndDensity(t *testing.T) {
text := "Screen 0: minimum 8 x 8, current 4000 x 2560, maximum 32767 x 32767\n" +
"DP-2 connected 1440x2560+2560+0 left (normal left inverted right x axis y axis) 597mm x 336mm\n" +
" 2560x1440 59.95 + 74.97*\n"
_, outs := ParseXrandr(text)
o := outs[0]
if o.Rotation != "left" || o.Geometry.X != 2560 || o.Current.Rate != 74.97 || o.Preferred.Rate != 59.95 {
t.Fatalf("%+v %+v %+v", o, o.Current, o.Preferred)
}
if o.DPI < 108 || o.DPI > 110 {
t.Fatalf("a rotated output's density is its long side over its long side: %v", o.DPI)
}
}
func TestSetModeBuildsOnlyCommandsXrandrTakes(t *testing.T) {
args, err := ModeArgs(desktop.Args{"output": "DP-2", "mode": "2560x1440", "rate": 144.0, "rotate": "left", "scale": 1.5, "relation": "right-of", "of": "eDP-1", "primary": true})
want := "--output DP-2 --mode 2560x1440 --rate 144.00 --rotate left --scale 1.5x1.5 --right-of eDP-1 --primary"
if err != nil || strings.Join(args, " ") != want {
t.Fatalf("%v %v", args, err)
}
if args, _ := ModeArgs(desktop.Args{"output": "HDMI-1", "off": true, "mode": "1x1"}); strings.Join(args, " ") != "--output HDMI-1 --off" {
t.Fatalf("off wins: %v", args)
}
if args, _ := ModeArgs(desktop.Args{"output": "DP-1", "x": 1920.0, "y": 0.0}); strings.Join(args, " ") != "--output DP-1 --pos 1920x0" {
t.Fatalf("position: %v", args)
}
for _, bad := range []desktop.Args{
{"output": "DP-1; rm -rf ~"}, {"output": "DP-1"}, {"output": "DP-1", "mode": "big"},
{"output": "DP-1", "scale": 9.0}, {"output": "DP-1", "relation": "right-of"}, {"output": "DP-1", "rotate": "sideways"},
} {
if _, err := ModeArgs(bad); err == nil {
t.Errorf("accepted %v", bad)
}
}
}
func TestDryRunRunsNothing(t *testing.T) {
f := &fakeDesk{}
got, err := byName(t, f.desk(true), "xorg_set_mode")(map[string]any{"output": "eDP-1", "mode": "auto", "dry_run": true})
if err != nil || len(f.ran) != 0 || !strings.Contains(asJSON(got), `"--auto"`) {
t.Fatalf("%v %v %v", got, err, f.ran)
}
}
func TestInputDevicesAndTheirLibinputSettingsAreRead(t *testing.T) {
devs := ParseDevices(fixture(t, "xinput-list.txt"))
if len(devs) != 25 {
t.Fatalf("%d devices", len(devs))
}
var pad *Device
for i := range devs {
if devs[i].ID == 16 {
pad = &devs[i]
}
}
if pad == nil || pad.Name != "ASUP1208:00 093A:3011 Touchpad" || pad.Role != "slave" || pad.Kind != "pointer" {
t.Fatalf("%+v", pad)
}
if devs[0].Name != "Virtual core pointer" || devs[0].Role != "master" {
t.Fatalf("the tree's drawing is stripped: %+v", devs[0])
}
s := ParseProps(fixture(t, "xinput-props-16.txt"))
if s["tap"] != true || s["natural_scroll"] != true || s["accel_speed"] != 0.15 || s["accel_profile"] != "adaptive" || s["left_handed"] != false {
t.Fatalf("%v", s)
}
}
func TestInputSetChangesOnlyWhatADeviceHasAndSaysHowLongItLasts(t *testing.T) {
props := fixture(t, "xinput-props-16.txt")
f := &fakeDesk{answers: map[string]desktop.Result{
"xinput list": {Stdout: fixture(t, "xinput-list.txt")},
"xinput list-props 16": {Stdout: props},
"xinput list-props 15": {Stdout: "Device 'Mouse':\n\tDevice Enabled (187):\t1\n"},
"xinput set-prop 16 libinput Tapping Enabled 0": {},
}}
got, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16", "tap": false})
if err != nil || !strings.Contains(asJSON(got), `"set":["tap"]`) || !strings.Contains(asJSON(got), "lasts") {
t.Fatalf("%s %v", asJSON(got), err)
}
if _, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16"}); err == nil {
t.Fatal("nothing to change is refused")
}
if _, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16", "accel_speed": 3.0}); err == nil {
t.Fatal("accel_speed out of range")
}
got, err = byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "15", "tap": true})
if err != nil || !strings.Contains(asJSON(got), `"skipped":["tap"]`) {
t.Fatalf("a mouse has no tapping: %s %v", asJSON(got), err)
}
}
func TestTheKeyboardMapIsReadAndSetOnlyWithXKBNames(t *testing.T) {
k := ParseKeyboard(fixture(t, "setxkbmap.txt") + "options: caps:escape,compose:ralt\n")
if k.Layout != "us" || k.Model != "pc105" || len(k.Options) != 2 {
t.Fatalf("%+v", k)
}
f := &fakeDesk{answers: map[string]desktop.Result{
"setxkbmap -layout us,be -option -option caps:escape": {},
"setxkbmap -query": {Stdout: fixture(t, "setxkbmap.txt")},
}}
if _, err := byName(t, f.desk(true), "xorg_keyboard")(map[string]any{"layout": "us,be", "options": []any{"caps:escape"}}); err != nil {
t.Fatal(err, f.ran)
}
if _, err := byName(t, f.desk(true), "xorg_keyboard")(map[string]any{"layout": "us; xterm"}); err == nil {
t.Fatal("a layout that is not an XKB name")
}
}
// xwdOf writes a w×h dump of 32-bit pixels in the given byte order, coloured by colourAt.
func xwdOf(w, h int, lsb bool, colourAt func(x, y int) (r, g, b uint8)) []byte {
var buf bytes.Buffer
name := []byte("xwdump\x00\x00")
header := []uint32{uint32(100 + len(name)), 7, 2, 24, uint32(w), uint32(h), 0, 0, 32, 0, 32, 32, uint32(w * 4), 4,
0xff0000, 0x00ff00, 0x0000ff, 8, 256, 0, uint32(w), uint32(h), 0, 0, 0}
if lsb {
header[7] = 0
} else {
header[7] = 1
}
for _, v := range header {
binary.Write(&buf, binary.BigEndian, v)
}
buf.Write(name)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, b := colourAt(x, y)
p := uint32(r)<<16 | uint32(g)<<8 | uint32(b)
if lsb {
binary.Write(&buf, binary.LittleEndian, p)
} else {
binary.Write(&buf, binary.BigEndian, p)
}
}
}
return buf.Bytes()
}
func TestAWindowDumpBecomesThePictureItHolds(t *testing.T) {
paint := func(x, y int) (uint8, uint8, uint8) { return uint8(x * 40), uint8(y * 60), 0xde }
for _, lsb := range []bool{true, false} {
img, err := DecodeXWD(xwdOf(5, 3, lsb, paint))
if err != nil {
t.Fatal(err)
}
if img.Bounds().Dx() != 5 || img.Bounds().Dy() != 3 {
t.Fatalf("bounds %v", img.Bounds())
}
if got := img.RGBAAt(4, 2); got != (color.RGBA{160, 120, 0xde, 0xff}) {
t.Fatalf("lsb=%v: %v", lsb, got)
}
}
dump := xwdOf(2, 2, true, paint)
if _, err := DecodeXWD(dump[:len(dump)-3]); err == nil {
t.Fatal("a cut dump is refused")
}
binary.BigEndian.PutUint32(dump[4:], 6)
if _, err := DecodeXWD(dump); err == nil {
t.Fatal("another file version is refused")
}
}
func TestTheServersLogAnswersItsErrorsAndNotTheLegend(t *testing.T) {
text := "[ 16.118] \nX.Org X Server 1.21.1.24\n[ 16.118] Markers: (--) probed,\n" +
"[ 16.118] \t(WW) warning, (EE) error, (NI) not implemented, (??) unknown.\n" +
"[ 16.118] (==) Log file: \"/var/log/Xorg.1.log\", Time: Fri Oct 2 13:08:45 2026\n" +
"[ 16.123] (WW) The directory \"/usr/share/fonts/misc\" does not exist.\n" +
"[ 16.181] (EE) Failed to load module \"ati\" (module does not exist, 0)\n" +
"[ 16.184] (EE) Failed to load module \"nouveau\" (module does not exist, 0)\n"
got := ParseXLog("/var/log/Xorg.1.log", text, false, 1)
if got.Server != "X.Org X Server 1.21.1.24" || got.Started != "Fri Oct 2 13:08:45 2026" || got.Errors != 2 || got.Warnings != 1 {
t.Fatalf("%+v", got)
}
if len(got.Lines) != 1 || !got.Cut || !strings.Contains(got.Lines[0].Text, "nouveau") || got.Lines[0].At != 16.184 {
t.Fatalf("the newest error, cut to the limit: %+v", got.Lines)
}
if all := ParseXLog("", text, true, 0); len(all.Lines) != 3 {
t.Fatalf("with warnings: %+v", all.Lines)
}
}
func TestProfilesAreReadWithTheMonitorsTheyAreFor(t *testing.T) {
dir := t.TempDir()
_, outs := ParseXrandr(fixture(t, "xrandr-prop.txt"))
os.MkdirAll(filepath.Join(dir, "laptop"), 0o755)
os.WriteFile(filepath.Join(dir, "laptop", "setup"), []byte("eDP-1 "+hex.EncodeToString(outs[0].edid)+"\n"), 0o644)
os.MkdirAll(filepath.Join(dir, "empty"), 0o755) // no setup: not a profile
got := ReadProfiles([]string{dir, filepath.Join(dir, "missing")})
if len(got) != 1 || got[0].Name != "laptop" || got[0].Monitors["eDP-1"] != outs[0].Monitor.Fingerprint {
t.Fatalf("%+v", got)
}
if !profileName.MatchString("home-office_2") || profileName.MatchString("../x") || profileName.MatchString("") {
t.Fatal("profile names")
}
}
func TestEveryToolThatNeedsTheScreenSaysPlainlyThatThereIsNoSession(t *testing.T) {
f := &fakeDesk{}
d := f.desk(false)
for _, call := range []struct {
tool string
args map[string]any
}{
{"node-display-server.displays", nil},
{"node-display-server.layout", map[string]any{"action": "apply", "name": "home"}},
{"xorg_set_mode", map[string]any{"output": "eDP-1", "mode": "auto"}},
{"xorg_primary", nil}, {"xorg_dpi", nil}, {"xorg_input_devices", nil},
{"xorg_input_set", map[string]any{"device": "16", "tap": true}}, {"xorg_keyboard", nil},
{"xorg_screenshot", nil},
} {
_, err := byName(t, d, call.tool)(orEmpty(call.args))
if !desktop.IsNoSession(err) {
t.Errorf("%s: %v", call.tool, err)
}
}
got, err := byName(t, d, "node-display-server.layout")(map[string]any{"action": "list"})
if err != nil || !strings.Contains(asJSON(got), "no graphical session") {
t.Fatalf("listing profiles needs no session, and says what it could not tell: %v %v", got, err)
}
if len(f.ran) != 0 {
t.Fatalf("nothing ran without a session: %v", f.ran)
}
}
func TestACommandRunsWithTheSessionsDisplay(t *testing.T) {
f := &fakeDesk{answers: map[string]desktop.Result{"xrandr --prop": {Stdout: fixture(t, "xrandr-prop.txt")}}}
if _, err := byName(t, f.desk(true), "node-display-server.displays")(map[string]any{}); err != nil {
t.Fatal(err)
}
env := strings.Join(f.env[0], " ")
if !strings.Contains(env, "DISPLAY=:1") || !strings.Contains(env, "XAUTHORITY=/home/op/.Xauthority") {
t.Fatalf("%s", env)
}
}
func orEmpty(m map[string]any) map[string]any {
if m == nil {
return map[string]any{}
}
return m
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"math/bits"
)
// DecodeXWD reads an X window dump as `xwd` writes it (XWDFile.h, file version 7): the screenshot
// tool's one dependency is xwd, from the X apps the display server already ships, and the PNG is made
// here — no screenshot program, no image library.
//
// Only what an X server dumps in practice is read: a ZPixmap of 24 or 32 bits per pixel with a
// TrueColor or DirectColor visual, in either byte order. Anything else is refused by name.
func DecodeXWD(b []byte) (*image.RGBA, error) {
const headerWords = 25
if len(b) < headerWords*4 {
return nil, errors.New("too short for an xwd header")
}
h := make([]uint32, headerWords)
for i := range h {
h[i] = binary.BigEndian.Uint32(b[i*4:])
}
headerSize, version, format := h[0], h[1], h[2]
width, height := int(h[4]), int(h[5])
byteOrder, bpp, bytesPerLine, class := h[7], int(h[11]), int(h[12]), h[13]
red, green, blue := h[14], h[15], h[16]
ncolors := int(h[19])
switch {
case version != 7:
return nil, fmt.Errorf("xwd file version %d; 7 is read", version)
case format != 2:
return nil, fmt.Errorf("pixmap format %d; ZPixmap (2) is read", format)
case bpp != 24 && bpp != 32:
return nil, fmt.Errorf("%d bits per pixel; 24 and 32 are read", bpp)
case class != 4 && class != 5:
return nil, fmt.Errorf("visual class %d; TrueColor and DirectColor are read", class)
case width <= 0 || height <= 0 || width > 32768 || height > 32768:
return nil, fmt.Errorf("a %dx%d image", width, height)
case bytesPerLine < width*bpp/8:
return nil, fmt.Errorf("%d bytes per line for %d pixels", bytesPerLine, width)
}
start := int(headerSize) + ncolors*12
if start < 0 || len(b) < start+bytesPerLine*height {
return nil, fmt.Errorf("the image data is cut short: %d bytes, %d needed", len(b), start+bytesPerLine*height)
}
pixels := b[start:]
step := bpp / 8
channel := func(p, mask uint32) uint8 {
if mask == 0 {
return 0
}
shift := bits.TrailingZeros32(mask)
width := bits.OnesCount32(mask)
v := (p & mask) >> shift
if width >= 8 {
return uint8(v >> (width - 8))
}
return uint8(v << (8 - width))
}
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
row := pixels[y*bytesPerLine:]
for x := 0; x < width; x++ {
px := row[x*step : x*step+step]
var p uint32
if byteOrder == 0 { // LSBFirst
for i := step - 1; i >= 0; i-- {
p = p<<8 | uint32(px[i])
}
} else {
for i := 0; i < step; i++ {
p = p<<8 | uint32(px[i])
}
}
img.SetRGBA(x, y, color.RGBA{channel(p, red), channel(p, green), channel(p, blue), 0xff})
}
}
return img, nil
}
+5
View File
@@ -0,0 +1,5 @@
module xorg
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+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)
}
}
+121
View File
@@ -0,0 +1,121 @@
{
"module": "xorg",
"version": "1",
"capabilities": [
"package-manager",
"seat"
],
"provides": [
{
"name": "x11-display",
"reach": "machine"
}
],
"claims": [
{
"name": "node-display-server",
"scope": "node",
"serves": [
"displays",
"layout"
]
}
],
"tools": [
"xorg_set_mode",
"xorg_primary",
"xorg_dpi",
"xorg_input_devices",
"xorg_input_set",
"xorg_keyboard",
"xorg_screenshot",
"xorg_x_log"
],
"resources": [
{
"id": "package-xorg-server",
"type": "package",
"package": "xorg-server"
},
{
"id": "package-xorg-xinit",
"type": "package",
"package": "xorg-xinit"
},
{
"id": "package-xorg-xrandr",
"type": "package",
"package": "xorg-xrandr"
},
{
"id": "package-xorg-xset",
"type": "package",
"package": "xorg-xset"
},
{
"id": "package-xorg-xrdb",
"type": "package",
"package": "xorg-xrdb"
},
{
"id": "package-xorg-xinput",
"type": "package",
"package": "xorg-xinput"
},
{
"id": "package-xorg-setxkbmap",
"type": "package",
"package": "xorg-setxkbmap"
},
{
"id": "package-xorg-xwd",
"type": "package",
"package": "xorg-xwd"
},
{
"id": "package-autorandr",
"type": "package",
"package": "autorandr"
},
{
"id": "config",
"type": "directory",
"path": "${machine:account-home}/.config/xorg",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "xresources",
"type": "file",
"path": "${machine:account-home}/.config/xorg/xresources",
"owner": "${machine:account}",
"mode": "0644",
"content": "! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's\n! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without\n! the preprocessor there is no #include or #define here, and a line starting with # is skipped,\n! which is what the line naming each contributing module is.\n!\n! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi\n! changes it for the running session.\nXft.dpi: 96\nXft.antialias: 1\nXft.hinting: 1\nXft.hintstyle: hintslight\nXft.rgba: rgb\n${shell:xresources:first}${shell:xresources:normal}${shell:xresources:last}\n"
},
{
"id": "xinitrc",
"type": "file",
"path": "${machine:account-home}/.xinitrc",
"owner": "${machine:account}",
"mode": "0755",
"into": "block",
"at": "start",
"content": "# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at\n# every push. It ends by starting the session, so no line below it in this file runs any more. A line\n# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources\n# just before the session starts (the module's README lists where each of today's lines went).\n\n# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment.\nif [ -r \"${machine:account-home}/.config/mesh/environment.sh\" ]; then\n . \"${machine:account-home}/.config/mesh/environment.sh\"\nfi\n\n# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take\n# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service\n# twice (two notifiers, two portals) and hides one copy from the other.\nif [ -z \"$DBUS_SESSION_BUS_ADDRESS\" ] && [ -S \"/run/user/$(id -u)/bus\" ]; then\n DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/$(id -u)/bus\"\n export DBUS_SESSION_BUS_ADDRESS\nfi\n\n# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier\n# and every user service see the display and the desktop's identity. An explicit list and never\n# --all: a session's environment may hold things no user service should be able to read back.\nmesh_words=\nfor mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \\\n XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \\\n QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do\n if printenv \"$mesh_word\" >/dev/null 2>&1; then\n mesh_words=\"$mesh_words $mesh_word\"\n fi\ndone\ndbus-update-activation-environment --systemd $mesh_words\nunset mesh_word mesh_words\n\n# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged\n# without the C preprocessor, so no compiler is needed for it.\nxrdb -nocpp -merge \"${machine:account-home}/.config/xorg/xresources\"\nif [ -r \"$HOME/.Xresources\" ]; then\n xrdb -merge \"$HOME/.Xresources\"\nfi\n\n# 4. The monitors: the saved layout profile that matches the monitors connected, if one does.\nif command -v autorandr >/dev/null 2>&1; then\n autorandr --change >/dev/null 2>&1 || true\nfi\n\n# 5. Other modules' session lines (ADR 0208 §4), then yours.\n${shell:xinitrc:first}${shell:xinitrc:normal}if [ -r \"$HOME/.xinitrc.local\" ]; then\n . \"$HOME/.xinitrc.local\"\nfi\n\n# 6. The session itself: the holder of node-display-session starts it from the last slot.\n${shell:xinitrc:last}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xorg",
"binary": "xorg",
"loads": [
"xorg"
]
}
]
}
}
+60
View File
@@ -0,0 +1,60 @@
# xterm
The terminal as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 5).
- **Claims `node-terminal-emulator`** and serves its verb `open`.
- **Requires `x11-display`.** In a Wayland session, `xwayland` would provide it.
- **Package `xterm`.**
- **Environment** (ADR 0203): `TERMINAL=xterm`. i3's `$mod+Return` runs `i3-sensible-terminal`, which
starts whatever `$TERMINAL` names, so the window manager no longer names the terminal.
- **X resources**, contributed to the `xresources` slot `normal` and placed by `xorg` in its resources
file. It owns no file of its own.
## The resources
They are those both workstations ran, with three changes:
- **face:** `JetBrainsMono Nerd Font` 12, replacing Hack (research 026/04);
- **scope:** every resource is under `XTerm*`. Today they were `*faceName`, `*background` and so on,
which reached every Xt program. `XTerm*` also outranks a `*name` resource of yours, so the old
`~/.Xresources.d/xterm` cannot win over them while it is still merged;
- **scrollback:** `saveLines` 10000 instead of 2000.
The palette (background, foreground and the sixteen colours) is today's, unchanged. So are the
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it. The bundle's
`Resources()` is the one source of these values. The manifest test holds the manifest to it, and
`xterm_colours` compares against it.
## Tools
| tool | | what |
|---|---|---|
| `node-terminal-emulator.open` | d | a terminal in the session: the login shell, or a command run through it (`$SHELL -lc`), in a directory (the home by default), with an optional title, and `hold` to keep it open. It starts as a transient unit of the account's service manager (`systemd-run --user`), so it outlives the call and every runtime restart |
| `xterm_font` | r/a | the face and size in force and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's default |
## What it leaves found
`~/.Xresources.d/xterm`, and the `#include` of it in `~/.Xresources`.
## The one-off migration
**Delete `~/.Xresources.d/xterm` and its `#include` line in `~/.Xresources`** once `xterm` is
assigned (see `xorg`'s README for the rest of that file). Until then they are merged after the
mesh's. Being `*`-scoped, they lose to this module's `XTerm*` values, and still reach every other Xt
program.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| package | none (`xterm` present) | the same |
| `xorg`'s resources file | gains this module's resources | the same |
| environment | gains `TERMINAL=xterm` | the same |
| running terminals | **nothing**. The resources are merged at the next login | the same |
| next login | new terminals in JetBrains Mono, with 10000 lines of scrollback | the same. The desktop has a hand-copied build of the face until `fonts` installs the package |
## Blockers
None. It needs `xorg` assigned for `x11-display`, and `fonts` for the face. Without `fonts`,
fontconfig picks the nearest installed face, and `xterm_font` says which one in `resolves_to`.
+68
View File
@@ -0,0 +1,68 @@
// xterm's tools (novox/hq ADR 0208, research 026/05): node-terminal-emulator's verb `open`, and the
// module's own `font` and `colours`.
//
// A terminal opened by `open` is handed to the account's own service manager (internal/desktop's
// Launch), never left a child of the runtime, which the service manager empties at every restart.
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"xterm/internal/desktop"
)
func main() {
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); 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(x xterm) []stdio.Tool {
return []stdio.Tool{
{
Name: "node-terminal-emulator.open",
Description: "Open a terminal window in the operator's session: the account's login shell, or a command " +
"run through it, in a directory (the home when none is named). hold keeps the window open after " +
"the command ends. Answers the unit it runs as.",
Input: desktop.Schema(map[string]any{
"command": desktop.Str("what to run in it (optional; the login shell when absent)"),
"directory": desktop.Str("where it starts (optional; the account's home)"),
"title": desktop.Str("the window's title (optional)"),
"hold": desktop.Flag("keep the window open after the command ends (default false)"),
}),
Run: call(x.open),
},
{
Name: "xterm_font",
Description: "The face and size xterm uses, and the installed font that face resolves to. With face " +
"and/or size, set them for terminals opened from now on (an installed face only); the module's " +
"resources set the default again at the next login.",
Input: desktop.Schema(map[string]any{
"face": desktop.Str("a font family, as fc-list names it"),
"size": desktop.Num("points, 6 to 48"),
}),
Run: call(x.font),
},
{
Name: "xterm_colours",
Description: "xterm's colours in force: background, foreground, cursor and the sixteen palette entries, " +
"as #rrggbb, each beside the module's default and whether it differs.",
Input: desktop.Schema(map[string]any{}),
Run: call(x.colours),
},
}
}
+214
View File
@@ -0,0 +1,214 @@
package main
import (
"context"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"xterm/internal/desktop"
)
type xterm struct{ d desktop.Desk }
// The module's defaults: the face research 026/04 chose and both workstations' palette.
const (
Face = "JetBrainsMono Nerd Font"
Size = 12
)
// Palette is the colours both workstations ran, by resource name, in the order they are written.
var Palette = [][2]string{
{"background", "#000000"}, {"foreground", "#a8a8a8"},
{"color0", "#000000"}, {"color8", "#666666"},
{"color1", "#9e1828"}, {"color9", "#bc5766"},
{"color2", "#008800"}, {"color10", "#61a171"},
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
{"color4", "#414171"}, {"color12", "#5085af"},
{"color5", "#963c59"}, {"color13", "#a97a99"},
{"color6", "#418179"}, {"color14", "#6ba4a4"},
{"color7", "#bebebe"}, {"color15", "#ffffff"},
}
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
// manifest carries it; the manifest test holds the two to one text.
func Resources() string {
var b strings.Builder
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
for _, kv := range [][2]string{
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
} {
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
}
for _, kv := range Palette {
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
}
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
return b.String()
}
// openScript runs in the new terminal: into the directory, then the login shell, or a command
// through it so the account's environment and aliases are there.
const openScript = `cd -- "$1" || exit 1
if [ -n "$2" ]; then exec "${SHELL:-/bin/sh}" -lc "$2"; fi
exec "${SHELL:-/bin/sh}" -l`
// OpenArgs is the xterm command line open starts.
func OpenArgs(dir, command, title string, hold bool) []string {
args := []string{"xterm"}
if title != "" {
args = append(args, "-title", title)
}
if hold {
args = append(args, "-hold")
}
return append(args, "-e", "/bin/sh", "-c", openScript, "sh", dir, command)
}
func (x xterm) open(ctx context.Context, a desktop.Args) (any, error) {
s, err := x.d.Find()
if err != nil {
return nil, err
}
dir := a.Opt("directory", desktop.Home())
if strings.HasPrefix(dir, "~") {
if dir, err = desktop.InHome(dir); err != nil {
return nil, err
}
}
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory here", dir)
}
hold, _, err := a.Bool("hold")
if err != nil {
return nil, err
}
argv := OpenArgs(dir, a.Opt("command", ""), a.Opt("title", ""), hold)
launched, err := x.d.Launch(ctx, s, "xterm", argv...)
if err != nil {
return nil, err
}
return map[string]any{"session": s.Display, "directory": dir, "command": a.Opt("command", "(the login shell)"), "launched": launched}, nil
}
// Effective is a resource's value as an XTerm sees it in `xrdb -query`: its XTerm*-scoped value, else
// the global *-scoped one.
func Effective(query, name string) string {
var global string
for _, l := range strings.Split(query, "\n") {
k, v, ok := strings.Cut(l, ":")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "XTerm*" + name, "XTerm." + name, "XTerm*vt100." + name, "XTerm*VT100." + name:
return strings.TrimSpace(v)
case "*" + name:
global = strings.TrimSpace(v)
}
}
return global
}
var faceName = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,64}$`)
func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
face := a.Opt("face", "")
size, sized, err := a.Number("size")
if err != nil {
return nil, err
}
if face != "" || sized {
var res []string
if face != "" {
if !faceName.MatchString(face) {
return nil, fmt.Errorf("a face is a family name, as fc-list names it")
}
if fam := x.d.Run(ctx, env, nil, "fc-list", face, "family"); strings.TrimSpace(fam.Stdout) == "" {
return nil, fmt.Errorf("no installed font is %q: the fonts module installs the mesh's faces", face)
}
res = append(res, "XTerm*faceName: "+face)
}
if sized {
if size < 6 || size > 48 {
return nil, fmt.Errorf("size is from 6 to 48 points")
}
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
}
if r := x.d.Run(ctx, env, []byte(strings.Join(res, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
return nil, r.Err()
}
}
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
if !q.OK() {
return nil, q.Err()
}
inForce := Effective(q.Stdout, "faceName")
answer := map[string]any{
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
"default": map[string]any{"face": Face, "size": Size},
}
if inForce != "" {
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
if parts := strings.SplitN(m.Stdout, "|", 3); m.OK() && len(parts) == 3 {
answer["resolves_to"] = map[string]any{"family": parts[0], "style": parts[1], "file": parts[2],
"exact": strings.EqualFold(parts[0], inForce)}
}
}
if face != "" || sized {
answer["lasts"] = "for terminals opened from now on, until the next login"
}
return answer, nil
}
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
func Hex(v string) string {
v = strings.TrimSpace(v)
if m := rgbSpec.FindStringSubmatch(v); m != nil {
out := "#"
for _, c := range m[1:] {
n, _ := strconv.ParseUint(c, 16, 32)
max := uint64(1)<<(4*len(c)) - 1
out += fmt.Sprintf("%02x", (n*255+max/2)/max)
}
return out
}
return strings.ToLower(v)
}
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
if err != nil {
return nil, err
}
if !res.OK() {
return nil, res.Err()
}
type colour struct {
Name string `json:"name"`
Value string `json:"value"`
Default string `json:"default"`
Differs bool `json:"differs,omitempty"`
}
var out []colour
for _, kv := range Palette {
v := Hex(Effective(res.Stdout, kv[0]))
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
}
cursor := Hex(Effective(res.Stdout, "cursorColor"))
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"xterm/internal/desktop"
)
type manifest struct {
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `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"`
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 TestItHoldsTheTerminalSeatOnAnXDisplayAndNamesItselfTheTerminal(t *testing.T) {
m := readManifest(t)
if len(m.Claims) != 1 || m.Claims[0].Name != "node-terminal-emulator" || strings.Join(m.Claims[0].Serves, ",") != "open" {
t.Fatalf("%+v", m.Claims)
}
if strings.Join(m.Requires, ",") != "x11-display" || m.Environment.Variables["TERMINAL"] != "xterm" || len(m.Environment.Variables) != 1 {
t.Fatalf("%v %v", m.Requires, m.Environment)
}
served := map[string]bool{}
for _, tool := range tools(xterm{}) {
served[tool.Name] = true
}
if !served["node-terminal-emulator.open"] || len(served) != len(m.Tools)+1 {
t.Fatalf("%v %v", served, m.Tools)
}
for _, n := range m.Tools {
if !served[n] {
t.Errorf("%s", n)
}
}
if len(m.Resources) != 1 || m.Resources[0]["package"] != "xterm" {
t.Fatalf("%v", m.Resources)
}
if a := m.Build.Artifacts[0]; a["binary"] != "xterm-tools" || a["from"] != "cmd/xterm-tools" {
t.Fatalf("a binary not named xterm, so nothing looking for xterm by name finds the tools: %v", a)
}
}
func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
t.Fatalf("%+v", m.Shell)
}
if m.Shell[0].Code != Resources() {
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
}
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
switch {
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
case strings.HasPrefix(l, "#"):
t.Errorf("merged with -nocpp, a # line is skipped: %q", l)
case !strings.HasPrefix(l, "XTerm*"):
t.Errorf("a resource of another program, or of every one: %q", l)
}
}
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
t.Fatal("the chosen face and the clipboard keys")
}
}
type fake struct {
ran []string
stdin []string
query string
}
func (f *fake) desk(session bool) desktop.Desk {
return desktop.Desk{
Find: func() (*desktop.Session, error) {
if !session {
return nil, &desktop.NoSession{Reason: "none"}
}
return &desktop.Session{Display: ":1", XAuthority: "/h/.Xauthority", Type: "x11", RuntimeDir: "/run/user/1000", Bus: "unix:path=/run/user/1000/bus"}, nil
},
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
f.ran = append(f.ran, strings.Join(append([]string{name}, args...), " "))
f.stdin = append(f.stdin, string(stdin))
switch name {
case "xrdb":
return desktop.Result{Stdout: f.query}
case "fc-list":
if strings.Contains(args[0], "Missing") {
return desktop.Result{}
}
return desktop.Result{Stdout: args[0] + "\n"}
case "fc-match":
return desktop.Result{Stdout: "JetBrainsMono Nerd Font|Regular|/usr/share/fonts/TTF/J.ttf"}
}
return desktop.Result{}
},
}
}
func TestOpenHandsTheTerminalToTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
f := &fake{}
dir := t.TempDir()
got, err := xterm{d: f.desk(true)}.open(context.Background(), desktop.Args{"directory": dir, "command": "htop", "hold": true, "title": "top"})
if err != nil {
t.Fatal(err)
}
if len(f.ran) != 1 || !strings.HasPrefix(f.ran[0], "systemd-run --user --collect --quiet --unit=mesh-xterm-") {
t.Fatalf("%q", f.ran)
}
for _, want := range []string{"--setenv=DISPLAY=:1", "--setenv=XAUTHORITY=/h/.Xauthority", "-- xterm -title top -hold -e /bin/sh -c", "sh " + dir + " htop"} {
if !strings.Contains(f.ran[0], want) {
t.Errorf("lacks %q: %s", want, f.ran[0])
}
}
if !strings.Contains(asJSON(got), `"unit":"mesh-xterm-`) {
t.Fatalf("%s", asJSON(got))
}
if _, err := (xterm{d: f.desk(true)}).open(context.Background(), desktop.Args{"directory": "/no/such/dir"}); err == nil {
t.Fatal("a directory that is not there")
}
if _, err := (xterm{d: (&fake{}).desk(false)}).open(context.Background(), desktop.Args{}); !desktop.IsNoSession(err) {
t.Fatalf("%v", err)
}
}
func TestTheTerminalsScriptStartsInTheDirectoryAndRunsTheCommandThroughTheShell(t *testing.T) {
dir := t.TempDir()
args := OpenArgs(dir, "pwd > out", "", false)
sh := args[len(args)-6:] // /bin/sh -c script sh dir command
cmd := exec.Command(sh[0], sh[1:]...)
cmd.Env = []string{"SHELL=/bin/sh", "PATH=/usr/bin:/bin", "HOME=" + dir}
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
b, _ := os.ReadFile(filepath.Join(dir, "out"))
if strings.TrimSpace(string(b)) != dir {
t.Fatalf("%q", b)
}
}
func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
q := "*faceName:\tHack Nerd Font\nXTerm*faceName:\tJetBrainsMono Nerd Font\nXTerm*faceSize:\t12\n" +
"XTerm*background:\t#000000\n*foreground:\trgb:a8/a8/a8\nXTerm*color1:\trgb:ff/00/00\n"
if Effective(q, "faceName") != "JetBrainsMono Nerd Font" || Effective(q, "foreground") != "rgb:a8/a8/a8" {
t.Fatal("XTerm's own value outranks the global one; the global one stands alone")
}
for in, want := range map[string]string{"rgb:9e/18/28": "#9e1828", "rgb:f/0/8": "#ff0088", "rgb:ffff/0000/8080": "#ff0080", "#ABCDEF": "#abcdef", "red": "red"} {
if got := Hex(in); got != want {
t.Errorf("%s: %s", in, got)
}
}
f := &fake{query: q}
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
if err != nil {
t.Fatal(err)
}
j := asJSON(got)
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
t.Fatalf("%s", j)
}
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
t.Fatal("a face that is not installed")
}
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
t.Fatal("size out of range")
}
f.ran, f.stdin = nil, nil
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
if err != nil {
t.Fatal(err)
}
if f.stdin[1] != "XTerm*faceName: Inter\nXTerm*faceSize: 13\n" || !strings.Contains(asJSON(got), `"lasts"`) {
t.Fatalf("%q %s", f.stdin, asJSON(got))
}
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
+5
View File
@@ -0,0 +1,5 @@
module xterm
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+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)
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"module": "xterm",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"claims": [
{
"name": "node-terminal-emulator",
"scope": "node",
"serves": [
"open"
]
}
],
"tools": [
"xterm_font",
"xterm_colours"
],
"environment": {
"variables": {
"TERMINAL": "xterm"
}
},
"shell": [
{
"for": "xresources",
"slot": "normal",
"code": "! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n! outrank any `*name` resource of yours and touch no other X program.\nXTerm*termName: xterm-256color\nXTerm*saveLines: 10000\nXTerm*renderFont: true\nXTerm*faceName: JetBrainsMono Nerd Font\nXTerm*faceSize: 12\nXTerm*boldMode: false\nXTerm*alwaysBoldMode: false\nXTerm*allowBoldFonts: true\nXTerm*background: #000000\nXTerm*foreground: #a8a8a8\nXTerm*color0: #000000\nXTerm*color8: #666666\nXTerm*color1: #9e1828\nXTerm*color9: #bc5766\nXTerm*color2: #008800\nXTerm*color10: #61a171\nXTerm*color3: #d2bb4b\nXTerm*color11: #e7db52\nXTerm*color4: #414171\nXTerm*color12: #5085af\nXTerm*color5: #963c59\nXTerm*color13: #a97a99\nXTerm*color6: #418179\nXTerm*color14: #6ba4a4\nXTerm*color7: #bebebe\nXTerm*color15: #ffffff\nXTerm*VT100.Translations: #override \\n\\\n Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "xterm"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xterm-tools",
"binary": "xterm-tools",
"loads": [
"xterm-tools"
]
}
]
}
}