Compare commits

..
Author SHA1 Message Date
jochen 0062d3f39b WIP: power module (in progress) 2026-10-04 16:05:31 +02:00
mesh-admin 1e4706f5ba Merge pull request 'Phase 3: asus-zephyrus-g14 and memory-pressure (hq to-be 42), the laptop's keys owned' (#275) from feat/phase-3-laptop-and-memory into main 2026-10-04 13:51:21 +00:00
jochen 1059f12ee0 asus-zephyrus-g14: the laptop's keys, scripts and i3 lines are the module's, with a resume backstop
The i3 and laptop READMEs each pointed at the other for 10-asus.conf and 20-g14.conf,
so nobody owned them. The module now writes both (adopted paths, so no duplicate
binding breaks i3's config check), ships the scripts they call, runs the media keys
with notifications again, and brings back the touchpad reset after resume as a unit
the sleep services want. zephyrus_keys answers what each custom key runs; the check
flags as-user, thd's account and the resume unit. xorg-xinput is the xorg module's.
2026-10-04 15:49:46 +02:00
jochen b098b64b22 Phase 3: asus-zephyrus-g14 and memory-pressure, with Go tools and long-running code
The laptop model's hardware module and a memory-pressure module for any
machine (hq research 027/03, 026/05, to-be 42 phase 3). The predecessor's
polling auto-profile and mem-guard user scripts become each module's own
Go code launched by the node runtime (ADR 0198): a profile switcher woken
by the kernel's power-supply uevents, and a guard that warns on RAM, swap
or PSI before systemd-oomd acts, on the desktop over the account's bus and
always as an event. supergfxctl and triggerhappy are kept as found
(research 027 Q1).
2026-10-04 15:42:56 +02:00
mesh-admin f2601e40de Merge pull request 'The licence manager's verb is public-key' (#274) from fix/the-verb-is-public-key into main 2026-10-04 13:33:34 +00:00
jochen 0dc13965e5 The licence manager's verb is public-key: a seat's verb is lower-case letters, digits and hyphens
public_key failed the builder's manifest check and stopped the manager's
rebuild; claude-code asks the new name.
2026-10-04 15:33:28 +02:00
mesh-admin 948e794e8b Merge pull request 'The licence manager's seat declares public_key' (#273) from fix/the-seat-serves-public-key into main 2026-10-04 13:22:27 +00:00
jochen e8562b58ce The licence manager's seat declares public_key, which claude_code_add_api_key asks (novox/hq ADR 0209) 2026-10-04 15:22:15 +02:00
mesh-admin 1db3fdeb2f Merge pull request 'lemurs, i3status-rust: what the first assignment refused' (#272) from fix/desktop-first-assign into main 2026-10-04 13:17:21 +00:00
jochen c790ee24d3 lemurs, i3status-rust: what the first assignment refused
The host refuses boot on a service that leaves its state to the machine, so lemurs.service
is declared running (no trigger, so a push still never restarts it). pacman-contrib is the
pacman module's, and two modules declaring one package make a node unresolvable.
2026-10-04 15:17:07 +02:00
mesh-admin c5c47b92f4 Merge pull request 'A login moves its node; an API key is added from any node, sealed (hq ADR 0209)' (#271) from feat/a-login-moves-its-node into main 2026-10-04 13:12:03 +00:00
jochen 4ef4983d0d A login moves its node; an API key is added from any node, sealed (novox/hq ADR 0209)
The manager binds the node a login was adopted from to that login's licence,
switching it if it was bound to another; serves public_key; adopt takes a
key sealed to it. claude-code gains claude_code_add_api_key: read a file on
this node, seal, hand to adopt, remove the file, optionally switch here.
2026-10-04 15:11:54 +02:00
mesh-admin 1c799101fa Merge pull request 'Phase 2 desktop: thirteen modules, one per piece of the graphical session (hq ADR 0208, to-be 42)' (#270) from feat/phase-2-desktop into main 2026-10-04 13:09:48 +00:00
jochen 19a79bef86 i3, screen-lock: the session's output to the journal, and the colour locker removed before i3lock is checked
lemurs leaves the session a stdout nobody reads, so a program writing to it dies of EPIPE.
i3lock-color provides i3lock, so with it still installed the host saw i3lock as present,
skipped the install and then removed the only locker; removing it first lets the same
apply install i3lock.
2026-10-04 15:09:39 +02:00
jochen 12ee8f41d9 Merge branch 'feat/phase-2-desktop-core' of /tmp/claude-1000/-home-jochen-projects-novox-hq/a2753bc7-871c-4aac-b6b6-21e3919ee6cd/scratchpad/wg/mesh-catalog into HEAD 2026-10-04 14:56:11 +02:00
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
144 changed files with 19988 additions and 33 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"
]
}
]
}
}
+362
View File
@@ -0,0 +1,362 @@
# asus-zephyrus-g14
The hardware module for the **ASUS ROG Zephyrus G14** laptop: its vendor daemon and platform
profiles, the hybrid GPU's mode and driver options, suspend, the lid and power key, low battery,
the backlights, the vendor keys and the touchpad (novox/hq research 027/03 *Power management on
the laptop*, research 026/05, to-be 42 phase 3).
## Why this name
A module is named after the hardware model, never the node (novox/hq ADR 0112; research 026/03:
no flavors, no machine names). `asus-zephyrus-g14` is the model family exactly as the firmware
reports it (`/sys/class/dmi/id/product_family` = `ROG Zephyrus G14`). The module's code checks that
value and its switcher does nothing on any other model, and `zephyrus_check` reports it.
A wider name such as `asus-rog-laptop` would promise what this module cannot keep. Its contents
belong to this family: the vendor-key scan codes, the eDP panel beside an NVIDIA dGPU, and the NVIDIA
D3 workaround. A second G14 is assigned the same module. Another ROG model gets its own.
Written against the GA403 (2024, Ryzen 8945HS, RTX 4070 Laptop, hybrid). Older G14 years have the same
daemons and probably the same keys. Their GPU options are unverified.
## What it owns
| | what | how |
|---|---|---|
| package | `asusctl` (asusd + client) | the distribution's package (`extra`). The machine was found with a local build of 6.4.0. The host only asserts *present*, so the switch to 6.5.0 from `extra` happens at the next `pacman -Syu` (or `pacman -S asusctl`). `zephyrus_check` flags a local build |
| package | `upower`, `playerctl` | what the low-battery drop-in and the media keys use. `xinput` is the `xorg` module's (one package, one module on a node) |
| service | `asusd` running (static unit: no boot state to declare), `supergfxd` running and enabled | |
| archive | `/usr/local/lib/asus-zephyrus-g14/bin/` | the module's scripts, from `files/bin` (below) |
| file ×2 | `~/.config/i3/config.d/10-asus.conf`, `20-g14.conf` | the laptop's lines in i3: the keys the firmware sends as ordinary presses (Fn+F6, Fn+F9), the keyboard-backlight notifier, the panel as primary, the touchpad key. The paths are adopted, because a second file binding the same keys makes i3's configuration check fail, and the `i3` module's watcher then reloads nothing |
| file ×4 | `asus-zephyrus-g14-touchpad-resume.service`, and a drop-in `asus-zephyrus-g14-touchpad.conf` on each sleep service | the touchpad's settings once more after a resume (below) |
| file | `/etc/modprobe.d/g14-nvidia-power.conf` | `NVreg_DynamicPowerManagement=0x00` (runtime D3 off: the ACPI D-Notifier hang) and `NVreg_PreserveVideoMemoryAllocations=1`. The path is adopted (ADR 0182) |
| file | `/etc/modprobe.d/video-brightness-switch.conf` | `video.brightness_switch_enabled=0`, so the ACPI video driver does not also move a backlight on the keys. The file was on the machine and owned by nothing |
| file ×3 | `systemd-{suspend,hibernate,suspend-then-hibernate}.service.d/asus-zephyrus-g14-nvidia.conf` | `Wants=` the matching `nvidia-*` sleep units and `nvidia-resume` (see *suspend units* below) |
| file | `nvidia-powerd.service.d/asus-zephyrus-g14.conf` | `ConditionKernelCommandLine=zephyrus.nvidia-powerd`: Dynamic Boost runs only when the operator opts in at boot |
| file | `/etc/systemd/logind.conf.d/power.conf` | the power key and the lid suspend, on battery, on mains and docked. `systemd-logind` is reloaded, never restarted |
| file | `/etc/udev/rules.d/90-backlight.rules` | backlights writable by the `video` group. `systemd-udevd` is reloaded |
| file | `triggerhappy.service.d/asus-zephyrus-g14.conf` | `thd … --user ${machine:account}`: the triggers run as the operator's account (below) |
| file | `/etc/triggerhappy/triggers.d/asus-g14.conf` | the vendor keys: media (`KEY_PROG1/3/4`), panel brightness, touchpad (`KEY_F21`). The path is adopted, because two trigger files would fire every key twice |
| file | `/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf` | low battery at 15/10/7 %; at 7 % **suspend**, not power off. A drop-in over the package's own file |
| file | `/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf` | tap to click, natural scrolling, acceleration 0.15, as an X input class |
**What it does not own, on purpose:**
- `/etc/asusd/*.ron` belong to asusd, which rewrites them whenever a setting changes. RON is not a
format the host writes into (ADR 0102 speaks JSON and marked blocks). Owning the file whole would
repeat the predecessor's freeze: the measured file already differs from the one the predecessor
shipped. The settings the module needs are set through asusd, by its code (below).
- `/etc/supergfxd.conf` and `/etc/modprobe.d/supergfxd.conf` belong to supergfxd, which writes both.
- The swap file, its unit and the swap partition are the machine's swap layout (research 027,
question 3). They are not this module's, nor `memory-pressure`'s.
- **Places.** The screen layouts for named places (`$mod+Alt+1…7`, `~/.screenlayout/`,
`~/scripts/.screenlayouts/@*.sh`) name where the operator works, which no module may (ADR 0112).
They are the operator's own lines until autorandr profiles replace them (the `xorg` module).
- **The monitor-hotplug wizard** (`/etc/udev/rules.d/91-monitor-hotplug.rules`,
`~/scripts/.screenlayouts/monitor-wizard.sh`) is any laptop's, not this model's. The predecessor said
so itself (its `laptop` flavor). It is the display server's to replace with autorandr's own hotplug
handling. Until then it stays as found, and it still calls the predecessor's `as-user`.
- **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module
binds it too. This module only binds the key the firmware sends for it.
- **The bar's battery block.** It belongs here (a block that follows this model's hardware), but the
bar has no way in yet (`i3status-rust` README). Once ADR 0210's contributions reach the bar seat,
this module contributes it.
**Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is
assigned where the display server is.
## Software outside the distribution (ADR 0205, research 027 question 1)
`supergfxctl` (5.2.7, from the asus-linux repository, which is no longer configured) and
`triggerhappy` (AUR) are **kept as found, and depended on**. The module declares no package for
either, because the host installs from the official repositories only. It declares their services
(`supergfxd` running, `triggerhappy` running), so on a machine without them the host refuses the
service by name: *does not exist on this machine*. The refusal is loud, never a silent pass.
`zephyrus_check` names both as foreign.
This module does not choose between the options of research 027 question 1. Under the starting
position (P2: the build machine builds AUR packages into a repository the mesh serves), both become
`package` resources here, and a fresh G14 installs them. **Until P2 exists, a fresh G14 is blocked
on installing these two by hand.** ADR 0205's vendored archive (P1) does not fit: supergfxctl is a
daemon with a system-bus policy and udev rules, and triggerhappy is C.
A later option for the keys: the module's own Go code could read the vendor keys from evdev, which
the operator's account may do through the `input` group. That would retire triggerhappy entirely.
It is not done here, because it would put the keys behind the node's runtime, and the runtime
restarts a bundle that dies only on its next call (below).
## The long-running code: the profile switcher (ADR 0198)
The module's Go bundle serves the tools and runs the platform-profile switcher in the same process.
The node's runtime launches the bundle at the runtime's start. It replaces the predecessor's
`auto-profile`, a user unit that woke every five seconds, on battery too.
- **Policy** (constants until settings exist, issue 168): battery → `Quiet`; mains → `Balanced`;
mains with the CPU at or above 50 % for 3 samples of 10 s → `Performance`, back to `Balanced` after
3 samples at or below 20 %. Between the lines nothing moves (hysteresis). iowait counts as idle.
- **Woken by events, not a poll.** The kernel's power-supply uevents (netlink, group 1) wake the
switcher. Any account may listen on that group, and it needs no daemon, bus client or dependency;
upower re-announces the same changes but would need a D-Bus client in the bundle. The CPU is
sampled only on mains, every 10 s, because only there does the answer depend on it. On battery,
a safety re-read every 5 minutes covers an event lost across a suspend. If the uevent socket
cannot be opened, the switcher polls every 10 s and says so in `zephyrus_profile_policy`.
- **The battery decides the source.** A battery that is *discharging* means battery, whatever any
adapter says. The predecessor took any `online` file reading 1 as mains, and on this model the USB-C
ports report `online`. Batteries of `scope=Device` (a mouse, a headset) are ignored.
- **It acts on a change of its decision, never to restore one.** A profile someone chose by hand (the
profile key, asusctl, `zephyrus_profile`) stays until the power source changes or the load crosses
a line. The predecessor re-asserted its choice every five seconds, which made the profile key
useless. **Starting is not a decision**: the runtime restarts the bundle on every push that changes
one, and a push must not reset the operator's profile.
- **A hold.** `zephyrus_profile` holds the profile it sets for 60 min (`hold_minutes`). A change of
power source ends the hold.
- **One assertion at start:** through asusctl, the charge limit (80 %) and asusd's own on-mains and
on-battery profiles (`Balanced`, `Quiet`), each read first and set only if it differs. asusd's own
switching on a change of power source then agrees with the switcher's. A limit set later with
`zephyrus_charge_limit` stands until the bundle next starts. For a one-off full charge, use its
`oneshot`.
- **Events:** `profile.switched` (`profile`, `from`, `reason`, `source`), published through the
runtime.
No root is involved. asusd's and supergfxd's bus policies admit the `users` and `wheel` groups, and the
runtime runs as the operator's account. The one write that may escalate is the panel's backlight,
when the udev rule has not run yet. It uses `sudo -n` and never prompts. Every command is bounded at
20 s.
**Known limit.** The runtime restarts a launched bundle that exits *on its next tool call*, not at
once (mesh-tools `launch.ts`), so a crashed switcher stays down until a tool is called. ADR 0198 §1
says *started again when it exits*. The switcher recovers from a panic and reports it in
`zephyrus_profile_policy` and `zephyrus_check`, but a crash of the process is the runtime's to restart.
## The vendor keys and the scripts
triggerhappy opens the input devices as root, then **drops to the operator's account with its groups**
(`initgroups`: `input`, `video`). The packaged unit already drops to `nobody`, and the module's drop-in
names the account instead. The predecessor replaced the packaged unit with one that ran every trigger
as root, then `su`-ed to a named person with a hard-coded uid and display, and sourced a file of
secrets on the way (research 027 question 2). Now:
- `zephyrus-session CMD…`: runs a command in the account's graphical session. It sets the account's
own bus (`/run/user/<uid>/bus`) and takes the display and its authority from the session's window
manager's own environment, as the desktop modules' session finder does. If i3 is not running, it
asks logind, and then any process of the account that has a display. Nothing is sourced.
triggerhappy's `--user` changes the user and its groups and nothing else: the triggers start with
the service's bare environment, which is why every key that needs the session goes through this.
- `zephyrus-media play-pause | next | previous`: the media keys through MPRIS, with a notification of
what happened and a lock against the key's own repeat.
- `zephyrus-display primary | order`: the internal panel as the primary output, and the display key's
workspace split (odd workspaces on the panel, even ones on the first external output). The panel is
found as the connected `eDP` output. The predecessor named `eDP-1` and used `jq`. This reads i3's
answer without it.
- `zephyrus-kbd-notify`: the keyboard backlight's level, shown when UPower says it changed. One
instance per session, because i3 runs its `exec` lines again on an in-place restart.
- `zephyrus-backlight + | - | N`: the panel in 5 % steps, never below 1 %. **The panel is the
backlight under the eDP connector**, because this model also registers `nvidia_0`, which moves
nothing. The predecessor named `amdgpu_bl1` literally.
- `zephyrus-notify ID TEXT`: one replacing notification, through `busctl` (the service manager's
client, so no libnotify).
- `zephyrus-touchpad reset | toggle`: bound to the touchpad key (`KEY_F21`).
**Media keys** go to MPRIS through `playerctl`. The predecessor's fallback to a media server's local
API needed a token from the secrets file, and is dropped until a module can be handed a secret
(research 027 question 2). A player that does not speak MPRIS is told as "Media: no player".
## The touchpad: an input class instead of a sleep hook
The predecessor re-ran `xinput` from `/etc/systemd/system-sleep/` after every resume, as a named person
on a guessed display, because settings made with `xinput` are lost when the device initialises again.
An X input class is applied by X **every time the device appears**: at login, on hotplug and after a
resume. So the cause is fixed. The class matches any touchpad on the machine, which is the model's, so it
holds across G14 years whose touchpads differ. It takes effect at the next X start.
`zephyrus-touchpad reset` stays as the manual form, on the touchpad key and `$mod+Shift+x`.
**And a backstop after resume.** A resume that does not initialise the device again does not make X
apply the class either, and the predecessor's i3 file says the touchpad "sometimes needs re-init after
sleep". So `asus-zephyrus-g14-touchpad-resume.service` runs `zephyrus-touchpad reset` as the account,
two seconds after the machine is awake. It is never enabled. Each sleep service `Wants=` it through a
drop-in, and it is ordered `After=` them, which is how `nvidia-resume` is started too.
`zephyrus_check` says whether the sleep wants it.
## Suspend units without enabling them
`nvidia-suspend`, `-hibernate`, `-suspend-then-hibernate` and `-resume` are enabled with links in the
sleep services' `.wants` directories. The mesh makes no links (ADR 0012). The host's service shape
cannot declare them either: it may only say *running* or *stopped*, and *running* on a one-shot that
last failed would start `nvidia-sleep.sh suspend` with the machine awake. So the module asks for them
from the other side: a drop-in on each sleep service that `Wants=` them. The units' own
`Before=`/`After=` order them. The found links stay and are harmless.
`suspend-then-hibernate` now also gets `nvidia-suspend-then-hibernate`, which the machine lacked.
The drop-ins take effect at the service manager's next `daemon-reload`. In the same apply, the restart
of `triggerhappy` (whose drop-in changes) performs one.
## Tools
| tool | r/a | what |
|---|---|---|
| `zephyrus_brightness` | r/a | panel (percent or ±step, floor 1 %) and keyboard (off/low/med/high, 0-3, ±) through asusd |
| `zephyrus_battery` | r | charge, energy in Wh, health (full ÷ design), cycles (the firmware reports 0, and this is said), limit, watts, hours left |
| `zephyrus_charge_limit` | r/a | 20-100 through asusd; `oneshot` |
| `zephyrus_gpu_mode` | r/a | mode, supported modes, dGPU power, the pending mode and action; says that asusd switches the mode on every change of power source |
| `zephyrus_profile` | r/a | active, on-mains and on-battery profile, kernel platform profile; set with a hold |
| `zephyrus_thermals` | r | every hwmon temperature and fan, the hottest, the dGPU's temperature **only when it is awake** (nvidia-smi wakes a suspended GPU) |
| `zephyrus_power_draw` | r | battery flow, APU package power (PPT), dGPU draw when awake, power source and why |
| `zephyrus_profile_policy` | r | what the switcher would choose now and why: source, recent load against the thresholds, decision, hold, last switch, what woke it, what it asserted at start, and whether the predecessor's switcher still runs |
| `zephyrus_fan_curves` | r | asusd's curves per profile and fan |
| `zephyrus_keys` | r | every custom key: each triggerhappy trigger, the module's i3 lines and the keys the firmware handles; what runs and the file it is in. Warns when one key is in two trigger files, which fires it twice |
| `zephyrus_check` | r | every expectation: model, packages (local or foreign), daemons, nvidia-powerd, sleep units, the NVIDIA options **in force** (`/proc/driver/nvidia/params`), charge limit, one authority each over the profile and the GPU mode, predecessor leftovers; the touchpad resume unit wanted by the sleep; triggerhappy running as the operator's account; any trigger, udev rule or sleep hook still naming `as-user`. It also lists what it did not check |
`profile` is a candidate verb for a future `node-power-profile` seat (research 027/03). That seat has
no record yet, so this is the module's own tool.
## Found on the laptop, 2026-10-04 (read-only)
- **Two authorities over the GPU mode.** `asusd.ron` has `ac_command: "supergfxctl -m Hybrid"` and
`bat_command: "supergfxctl -m Integrated"`, so asusd switches the GPU mode on every change of power
source. **supergfxd 5.2.7 cannot read logind's sessions** (`manager is an invalid variant`, every
boot), so a switch that needs a logout times out. `zephyrus_check` reports both. The fix is the
operator's, in asusd's file: clear both commands, or update supergfxctl once it can be packaged.
- **`brightness.conf` did nothing.** `HandleBrightnessKey` is not a logind key, and logind logs
*Unknown key … ignoring* at every start. The module does not carry it. The brightness keys were
always triggerhappy's, with the ACPI video switch off.
- **Two profile switchers** would run at once until `auto-profile` is stopped (below).
- **asusctl is a local build** (6.4.0, *Unknown Packager*) beside a foreign `asusctl-debug`.
## When assigned to the laptop: what changes
1. `/usr/local/lib/asus-zephyrus-g14/` appears (seven scripts).
2. Written over found files (each original kept once by the host): `g14-nvidia-power.conf` and
`video-brightness-switch.conf` (same options, so no change until the next boot either),
`logind.conf.d/power.conf` (same keys; logind reloaded), `90-backlight.rules` (same effect;
udevd reloaded), `triggers.d/asus-g14.conf` (now the module's scripts), and i3's `10-asus.conf` and
`20-g14.conf` (the module's lines; the `i3` module's watcher checks and reloads them).
3. New: the three sleep drop-ins (behaviour gained: `nvidia-suspend-then-hibernate`), the
nvidia-powerd drop-in (no effect while it is masked), the triggerhappy drop-in, the UPower drop-in
(same values as today), the touchpad input class (at the next X start), and the resume unit with
its three drop-ins.
4. `daemon-reload` and a `triggerhappy` restart. thd now runs as the account and the keys run the
module's scripts. `upower` restarts.
5. Packages, asusd and supergfxd: already as declared, so nothing changes. asusctl stays the local
6.4.0 until the next upgrade.
6. The node runtime restarts with the new bundle. The switcher asserts the limit (80, already) and
asusd's profiles (Balanced and Quiet, already), so it sets nothing. It takes the current decision
as applied and acts from the first event on.
## Predecessor files this module makes redundant — the operator removes them once (ADR 0182)
**On the laptop:**
1. `systemctl --user disable --now auto-profile.service`, then delete
`~/.config/systemd/user/auto-profile.service` and `~/scripts/auto-profile`. **Do this right after
the push**, or two switchers run at once.
2. **Before the push, keep the place layouts.** The module writes `20-g14.conf` over the found file,
and the found file carries the seven `$mod+Alt+1…7` layout bindings, which name places. Move them,
and the `exec … ~/.screenlayout/@default.sh` line, to a file of your own in the same directory,
e.g. `~/.config/i3/config.d/90-layouts.conf`. i3 reads it the same way, and it stays yours until
autorandr profiles replace it. The host keeps the found `20-g14.conf` once in any case.
3. `~/scripts/asus-bright`, `~/scripts/asusctl-kbd-bright`, `~/scripts/xrandr-bright`,
`~/scripts/media-control`, `~/scripts/xinput-reset-touchpad`,
`~/scripts/.screenlayouts/orden-workspaces.sh` and `~/.config/i3/scripts/kbd-brightness-notify.sh`:
no trigger and no line of the module's uses them any more.
**Keep `~/scripts/as-user`** while `/etc/udev/rules.d/91-monitor-hotplug.rules` exists: that rule
still runs the monitor wizard through it. `zephyrus_check` names every place that still calls it.
4. `/etc/systemd/system/triggerhappy.service`: the predecessor's replacement of the packaged unit. The
module's drop-in works over either, so delete it and `systemctl daemon-reload` to return to the
packaged unit (`Type=notify`, socket).
5. `/etc/systemd/logind.conf.d/brightness.conf`: the unknown key, which does nothing.
6. `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`, if it is still there: replaced by the input
class and the resume unit. (It was already gone on 2026-10-04.)
7. `/etc/UPower/UPower.conf`: the predecessor's replacement of the package's file. Its values are now
the module's drop-in. Restore the package's copy (`rm` it, then `pacman -S upower`).
8. Optional: `systemctl disable nvidia-suspend nvidia-resume nvidia-hibernate` (the drop-ins carry them
now), `/etc/asusd/*.ron-old` and `fan_curves.ron.bak`, the foreign `asusctl-debug` package, and
`pacman -S asusctl` for the distribution's build.
**Stays the machine's:** `/swapfile` and `/etc/systemd/system/swapfile.swap` (the swap layout),
`/etc/udev/rules.d/91-monitor-hotplug.rules` (the display's, phase 2), and the place layouts.
**On the desktop** (the predecessor's G14 flavor reached it; part was removed on 2026-10-04): none of
this module applies there. Still present and to be deleted:
`/etc/systemd/logind.conf.d/brightness.conf`, `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`,
`~/scripts/xinput-reset-touchpad`, `~/scripts/xrandr-bright` and
`~/.config/i3/scripts/kbd-brightness-notify.sh`.
## The predecessor, file by file
The predecessor carried this model in its desktop module's `g14` and `laptop` flavors, and in a
`g14-power` module. Every model-specific file of the desktop module, and where it is now:
| predecessor file | now |
|---|---|
| `i3-asus.conf` → `config.d/10-asus.conf` | **this module**, the same path; the scripts it calls are the module's |
| `i3-g14.conf` → `config.d/20-g14.conf` | **this module**, the same path. The place layouts in it are the operator's own file (migration, step 2) |
| `g14-triggerhappy-asus-g14.conf` | **this module**, the same path; the triggers run the module's scripts |
| `g14-triggerhappy.service` (a replacement of the packaged unit) | **retired**: a drop-in over the packaged unit (`--user`) |
| `as-user` | **retired**: thd runs as the account, and `zephyrus-session` finds the session. Kept on the machine while the monitor wizard's udev rule calls it |
| `asus-bright` | **this module**: `zephyrus-backlight`, the panel found by its connector |
| `media-control` (bound by the `g14` triggers) | **this module**: `zephyrus-media`, without the fallback that needed a secret |
| `xinput-reset-touchpad` | **this module**: the input class, `zephyrus-touchpad`, and the resume unit |
| `g14-system-sleep-xinput-reset-touchpad.sh` | **this module**: the input class, and the resume unit that the sleep services want |
| `kbd-brightness-notify.sh` | **this module**: `zephyrus-kbd-notify`, one instance per session |
| `asusctl-kbd-bright` | **retired**: the keyboard keys are the firmware's, and `zephyrus_brightness` sets it by hand |
| `xrandr-bright` | **retired**: a software dimming; the panel's backlight is `zephyrus-backlight` |
| `screenlayout-orden-workspaces.sh` | **this module**: `zephyrus-display order`, on Fn+F9 |
| `screenlayout-*.sh` (six named places) | **the operator's**, until autorandr profiles (the `xorg` module) replace them |
| `g14-90-backlight.rules` | **this module**, the same path |
| `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing |
| `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace |
| `bottom-bar.g14.toml` (the battery block) | **waits**: this module's, once the bar takes contributions (ADR 0210) |
| `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) |
| `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it |
| package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) |
`g14-power`'s pieces (the NVIDIA options, logind, UPower, the sleep units, `auto-profile`) are in
*What it owns* and the switcher above. Its memory guard is the `memory-pressure` module's.
## What the predecessor paid for, and what this module does about it
- **One file for every machine overwrote what each machine needed** (the predecessor's split of its i3
configuration into a base, an ASUS and a G14 layer, 2026-03). Here the model's lines are this
module's files, and nothing else writes them.
- **The layers silently stopped applying.** The predecessor's chain *laptop → g14* was stated in two
places, and the one its installer read lacked it. For weeks the G14 received only its 21
G14-tagged files, and the 49 base and laptop files were never seeded. Validation and the pipeline
both reported success (2026-08, found at a cutover). Here a module is one manifest. Its tests assert
that every trigger runs a script it ships, and `zephyrus_check` and `zephyrus_keys` read back what is
on the machine.
- **A hook wrote asusd's own files with sudo,** because configuration sync might not run on install
(2026-04), and froze them. asusd rewrites those files whenever a setting changes. Here the module
sets asusd through its client and never writes the RON files.
- **A profile daemon fought the profile key.** The predecessor turned asusd's own switching off and
re-asserted its choice every five seconds (2026-03). Here the switcher acts on a change of its
decision, never to restore one.
- **The overnight freeze** was an ACPI power-source event hanging the NVIDIA GPU in runtime D3
(2026-06). It is fixed by the driver options, which `zephyrus_check` reads from the running driver
rather than from the file.
- **A unit restarted about 73,000 times, unnoticed** (2026-06). Here every expectation is in
`zephyrus_check`, and so is a list of what it did not check.
- **The vendor keys ran as root and `su`-ed to a named person** on a guessed display, sourcing a file
of secrets. Here thd drops to the account, and nothing is sourced.
- **The touchpad lost its settings after a resume,** because `xinput` settings vanish when the device
initialises again. Here an input class re-applies them, with the resume unit as a backstop.
## Tests
`go test ./...` in this directory. Every tool runs against a tree standing in for `/sys`, `/proc` and
`/etc`, and an injected runner answering with what asusctl 6.4 and supergfxctl 5.2 said on the laptop.
The tests cover:
- the power-source rule;
- battery arithmetic from `charge_*`;
- the eDP panel choice;
- brightness bounds;
- the policy's sustain, relax and hysteresis, with iowait counted as idle;
- the switcher: no act at start, one switch per change of source, a published event, boost from
samples, holds, retry after failure, start-up assertions only where they differ, inert on another
model;
- the uevent filter;
- the manifest: tools listed equal tools served, no machine named, triggers exist, every key runs a
shipped executable script, every script passes `bash -n`;
- `zephyrus_keys` over the module's own triggers and i3 lines, and a key in two trigger files;
- the checks for `as-user`, triggerhappy's account and the resume unit.
@@ -0,0 +1,264 @@
package main
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
)
// The vendor daemons are reached through their own command-line clients, which speak to them on the
// system bus. Their bus policy admits the `users` and `wheel` groups, so none of this needs root.
// Profiles are the platform profiles asusd offers on this model, in its spelling.
var Profiles = []string{"Quiet", "Balanced", "Performance"}
// canonicalProfile accepts any case and answers asusd's spelling, or an error naming the choices.
func canonicalProfile(s string) (string, error) {
for _, p := range Profiles {
if strings.EqualFold(strings.TrimSpace(s), p) {
return p, nil
}
}
return "", fmt.Errorf("profile %q is not one of %s", s, strings.Join(Profiles, ", "))
}
// ProfileState is what asusd says about the platform profile.
type ProfileState struct {
Active string `json:"active"`
OnAC string `json:"on_ac,omitempty"`
Battery string `json:"on_battery,omitempty"`
Platform string `json:"platform_profile,omitempty"`
Choices string `json:"platform_profile_choices,omitempty"`
}
var (
activeProfile = regexp.MustCompile(`(?m)^Active profile:\s*(\S+)`)
acProfile = regexp.MustCompile(`(?m)^AC profile\s+(\S+)`)
batteryProfile = regexp.MustCompile(`(?m)^Battery profile\s+(\S+)`)
)
// ParseProfileGet reads `asusctl profile get`.
func ParseProfileGet(out string) (ProfileState, error) {
var p ProfileState
if m := activeProfile.FindStringSubmatch(out); m != nil {
p.Active = m[1]
} else {
return p, fmt.Errorf("asusctl profile get said no active profile: %q", strings.TrimSpace(out))
}
if m := acProfile.FindStringSubmatch(out); m != nil {
p.OnAC = m[1]
}
if m := batteryProfile.FindStringSubmatch(out); m != nil {
p.Battery = m[1]
}
return p, nil
}
// Profile reads the platform profile from asusd and the kernel.
func (m *Machine) Profile(ctx context.Context) (ProfileState, error) {
out, err := m.Run(ctx, "asusctl", "profile", "get")
if err != nil {
return ProfileState{}, vendor("asusctl", err)
}
p, err := ParseProfileGet(out)
if err != nil {
return p, err
}
p.Platform = m.read("/sys/firmware/acpi/platform_profile")
p.Choices = m.read("/sys/firmware/acpi/platform_profile_choices")
return p, nil
}
// SetProfile has asusd switch the active profile.
func (m *Machine) SetProfile(ctx context.Context, profile string) error {
_, err := m.Run(ctx, "asusctl", "profile", "set", profile)
return vendor("asusctl", err)
}
var chargeLimit = regexp.MustCompile(`charge limit:\s*(\d+)\s*%`)
// ChargeLimit is the battery's charge limit as asusd reports it.
func (m *Machine) ChargeLimit(ctx context.Context) (int, error) {
out, err := m.Run(ctx, "asusctl", "battery", "info")
if err != nil {
return 0, vendor("asusctl", err)
}
g := chargeLimit.FindStringSubmatch(out)
if g == nil {
return 0, fmt.Errorf("asusctl battery info said no limit: %q", strings.TrimSpace(out))
}
n, _ := strconv.Atoi(g[1])
return n, nil
}
// Keyboard backlight levels in asusd's spelling, index = the kernel's brightness value.
var KeyboardLevels = []string{"off", "low", "med", "high"}
var ledLevel = regexp.MustCompile(`(?i)brightness:\s*(off|low|med|high)`)
// ParseLeds reads `asusctl leds get`.
func ParseLeds(out string) (string, error) {
g := ledLevel.FindStringSubmatch(out)
if g == nil {
return "", fmt.Errorf("asusctl leds get said no level: %q", strings.TrimSpace(out))
}
return strings.ToLower(g[1]), nil
}
// FanCurve is one fan's curve in one profile: eight points of temperature (°C) and duty (0-255).
type FanCurve struct {
Fan string `json:"fan"`
Enabled bool `json:"enabled"`
Temp []int `json:"temp_c"`
PWM []int `json:"pwm"`
}
var (
fanBlock = regexp.MustCompile(`(?s)fan:\s*(\w+),\s*pwm:\s*\(([^)]*)\),\s*temp:\s*\(([^)]*)\),\s*enabled:\s*(true|false)`)
)
// ParseFanCurves reads `asusctl fan-curve --mod-profile <p>`.
func ParseFanCurves(out string) []FanCurve {
var curves []FanCurve
for _, g := range fanBlock.FindAllStringSubmatch(out, -1) {
curves = append(curves, FanCurve{Fan: g[1], PWM: ints(g[2]), Temp: ints(g[3]), Enabled: g[4] == "true"})
}
return curves
}
func ints(list string) []int {
var out []int
for _, f := range strings.Split(list, ",") {
if n, err := strconv.Atoi(strings.TrimSpace(f)); err == nil {
out = append(out, n)
}
}
return out
}
// GPUState is what supergfxd says about the hybrid GPU.
type GPUState struct {
Mode string `json:"mode"`
Supported []string `json:"supported"`
Power string `json:"dgpu_power,omitempty"`
PendingAction string `json:"pending_action,omitempty"`
PendingMode string `json:"pending_mode,omitempty"`
Vendor string `json:"dgpu_vendor,omitempty"`
}
// ParseSupported reads `supergfxctl -s`: `[Integrated, Hybrid, AsusMuxDgpu]`.
func ParseSupported(out string) []string {
out = strings.Trim(strings.TrimSpace(out), "[]")
var modes []string
for _, f := range strings.Split(out, ",") {
if f = strings.TrimSpace(f); f != "" {
modes = append(modes, f)
}
}
return modes
}
// GPU reads supergfxd.
func (m *Machine) GPU(ctx context.Context) (GPUState, error) {
var g GPUState
mode, err := m.Run(ctx, "supergfxctl", "-g")
if err != nil {
return g, vendor("supergfxctl", err)
}
g.Mode = strings.TrimSpace(mode)
if s, err := m.Run(ctx, "supergfxctl", "-s"); err == nil {
g.Supported = ParseSupported(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-S"); err == nil {
g.Power = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-p"); err == nil {
g.PendingAction = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-P"); err == nil {
g.PendingMode = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-V"); err == nil {
g.Vendor = strings.TrimSpace(s)
}
return g, nil
}
// vendor names a vendor client that is not installed, rather than passing on "executable file not
// found". asusctl is in the distribution's repositories; supergfxctl is not, and the module keeps it as
// it was found until the mesh can build packages from the user repository (research 027, question 1).
func vendor(name string, err error) error {
if err == nil {
return nil
}
if notInstalled(err) {
switch name {
case "supergfxctl":
return fmt.Errorf("supergfxctl is not installed: it is not in the distribution's repositories, " +
"and this module keeps the copy it finds rather than install one (novox/hq research 027, question 1)")
default:
return fmt.Errorf("%s is not installed; the module's package resource installs it", name)
}
}
return err
}
// AsusdConfig is the few settings of asusd's own file that decide what this module's code does. The
// file is asusd's: it rewrites it whenever a setting changes, so the module reads it and never writes
// it.
type AsusdConfig struct {
ChargeLimit *int `json:"charge_control_end_threshold,omitempty"`
ProfileOnAC string `json:"platform_profile_on_ac,omitempty"`
ProfileOnBattery string `json:"platform_profile_on_battery,omitempty"`
ChangesProfileOnAC *bool `json:"change_platform_profile_on_ac,omitempty"`
ChangesProfileOnBatt *bool `json:"change_platform_profile_on_battery,omitempty"`
ACCommand string `json:"ac_command,omitempty"`
BatteryCommand string `json:"bat_command,omitempty"`
DisablesPowerdOnBatt *bool `json:"disable_nvidia_powerd_on_battery,omitempty"`
}
var ronField = regexp.MustCompile(`(?m)^\s{4}([a-z_]+):\s*(.*?),?\s*$`)
// ParseAsusdRon reads the top-level scalar fields of asusd.ron. RON is not a format the mesh
// speaks; these are one line each, and nothing nested is read.
func ParseAsusdRon(text string) AsusdConfig {
var c AsusdConfig
for _, g := range ronField.FindAllStringSubmatch(text, -1) {
key, value := g[1], strings.TrimSuffix(strings.TrimSpace(g[2]), ",")
unquoted := strings.Trim(value, `"`)
boolean := func() *bool { b := value == "true"; return &b }
switch key {
case "charge_control_end_threshold":
if n, err := strconv.Atoi(value); err == nil {
c.ChargeLimit = &n
}
case "platform_profile_on_ac":
c.ProfileOnAC = unquoted
case "platform_profile_on_battery":
c.ProfileOnBattery = unquoted
case "change_platform_profile_on_ac":
c.ChangesProfileOnAC = boolean()
case "change_platform_profile_on_battery":
c.ChangesProfileOnBatt = boolean()
case "ac_command":
c.ACCommand = unquoted
case "bat_command":
c.BatteryCommand = unquoted
case "disable_nvidia_powerd_on_battery":
c.DisablesPowerdOnBatt = boolean()
}
}
return c
}
// Asusd reads asusd's file; nil when it is not there.
func (m *Machine) Asusd() *AsusdConfig {
text := m.read("/etc/asusd/asusd.ron")
if text == "" {
return nil
}
c := ParseAsusdRon(text)
return &c
}
@@ -0,0 +1,146 @@
package main
import (
"context"
"strings"
"testing"
)
// What asusctl 6.4 and supergfxctl 5.2 said on the laptop on 2026-10-04.
const fanCurveQuiet = `
Fan curves for Quiet
[
(
fan: CPU,
pwm: (2, 0, 10, 20, 35, 55, 80, 100),
temp: (35, 45, 50, 55, 60, 65, 70, 80),
enabled: true,
),
(
fan: GPU,
pwm: (0, 0, 10, 20, 35, 65, 90, 115),
temp: (35, 45, 50, 55, 60, 65, 70, 80),
enabled: false,
),
]
`
const asusdRon = `(
charge_control_end_threshold: 80,
base_charge_control_end_threshold: 0,
disable_nvidia_powerd_on_battery: true,
ac_command: "supergfxctl -m Hybrid",
bat_command: "supergfxctl -m Integrated",
platform_profile_linked_epp: true,
platform_profile_on_battery: Quiet,
change_platform_profile_on_battery: true,
platform_profile_on_ac: Balanced,
change_platform_profile_on_ac: true,
ac_profile_tunings: {
Quiet: (
enabled: false,
group: {},
),
},
)`
func TestAsusctlsAnswersAreRead(t *testing.T) {
p, err := ParseProfileGet(profileGetBalanced)
if err != nil || p.Active != "Balanced" || p.OnAC != "Balanced" || p.Battery != "Quiet" {
t.Fatalf("%+v %v", p, err)
}
if _, err := ParseProfileGet("something else"); err == nil {
t.Fatal("an answer with no profile was read as one")
}
if l, err := ParseLeds("Current keyboard led brightness: High\n"); err != nil || l != "high" {
t.Fatalf("%q %v", l, err)
}
curves := ParseFanCurves(fanCurveQuiet)
if len(curves) != 2 || curves[0].Fan != "CPU" || curves[0].PWM[7] != 100 || curves[0].Temp[0] != 35 || curves[1].Enabled {
t.Fatalf("%+v", curves)
}
if got := ParseSupported("[Integrated, Hybrid, AsusMuxDgpu]\n"); strings.Join(got, ",") != "Integrated,Hybrid,AsusMuxDgpu" {
t.Fatalf("%v", got)
}
}
func TestAsusdsFileIsReadForWhatDecidesTheModulesCodeAndNothingNested(t *testing.T) {
c := ParseAsusdRon(asusdRon)
if *c.ChargeLimit != 80 || c.ProfileOnAC != "Balanced" || c.ProfileOnBattery != "Quiet" ||
c.ACCommand != "supergfxctl -m Hybrid" || c.BatteryCommand != "supergfxctl -m Integrated" ||
!*c.ChangesProfileOnAC || !*c.DisablesPowerdOnBatt {
t.Fatalf("%+v", c)
}
}
func TestAMissingVendorClientIsNamedWithWhyItIsMissing(t *testing.T) {
f := newFake(t)
f.fails["supergfxctl"] = notFound
_, err := f.machine().GPU(context.Background())
if err == nil || !strings.Contains(err.Error(), "research 027") {
t.Fatalf("%v", err)
}
f.fails["asusctl"] = notFound
_, err = f.machine().Profile(context.Background())
if err == nil || !strings.Contains(err.Error(), "package resource installs it") {
t.Fatalf("%v", err)
}
}
func TestAGPUModeIsSetOnlyWhenTheMachineSupportsItAndAsusdsSwitchingIsSaid(t *testing.T) {
f := newFake(t)
f.answers["supergfxctl -g"] = "Hybrid\n"
f.answers["supergfxctl -s"] = "[Integrated, Hybrid, AsusMuxDgpu]\n"
f.file("/etc/asusd/asusd.ron", asusdRon)
m := f.machine()
if _, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "Vfio"}); err == nil {
t.Fatal("an unsupported mode was accepted")
}
out, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "integrated"})
if err != nil {
t.Fatal(err)
}
if !f.called("supergfxctl -m Integrated") {
t.Fatalf("calls %v", f.calls)
}
if _, said := out.(map[string]any)["asusd_switches_it"]; !said {
t.Fatalf("asusd's own switching of the mode was not said: %+v", out)
}
}
func TestTheChargeLimitIsBoundedAndSetThroughAsusd(t *testing.T) {
f := newFake(t)
f.answers["asusctl battery info"] = "Current battery charge limit: 60%\n"
m := f.machine()
for _, bad := range []any{float64(10), float64(101), "x", 55.5} {
if _, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": bad}); err == nil {
t.Errorf("limit %v was accepted", bad)
}
}
out, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": float64(60)})
if err != nil || !f.called("asusctl battery limit 60") || out.(map[string]any)["asusd_limit_percent"] != 60 {
t.Fatalf("%+v %v %v", out, err, f.calls)
}
}
func TestAProfileSetByToolIsHeldAndAnUnknownOneRefused(t *testing.T) {
f := newFake(t)
f.onMains()
f.answers["asusctl profile get"] = profileGetBalanced
m := f.machine()
sw := NewSwitcher(m, nil)
if _, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "Turbo"}); err == nil {
t.Fatal("an unknown profile was accepted")
}
out, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "performance", "hold_minutes": float64(30)})
if err != nil || !f.called("asusctl profile set Performance") {
t.Fatalf("%v %v", err, f.calls)
}
if _, held := out.(map[string]any)["held_until"]; !held {
t.Fatalf("not held: %+v", out)
}
if r := sw.Report(); r.Held != "Performance" {
t.Fatalf("%+v", r)
}
}
@@ -0,0 +1,200 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Check is one thing the module expects of the machine, and whether it holds.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// CheckReport is what zephyrus_check answers. NotChecked says what it did not look at, because a
// check that reads as clean while skipping something is the predecessor's verifier again.
type CheckReport struct {
Model string `json:"model"`
Checks []Check `json:"checks"`
Failing int `json:"failing"`
NotChecked []string `json:"not_checked"`
}
var (
pacmanVersion = regexp.MustCompile(`(?m)^Version\s*:\s*(\S+)`)
pacmanPackager = regexp.MustCompile(`(?m)^Packager\s*:\s*(.+)$`)
nvidiaParam = regexp.MustCompile(`(?m)^(\w+):\s*(\S+)`)
)
// ParseNvidiaParams reads /proc/driver/nvidia/params.
func ParseNvidiaParams(text string) map[string]string {
out := map[string]string{}
for _, g := range nvidiaParam.FindAllStringSubmatch(text, -1) {
out[g[1]] = g[2]
}
return out
}
// predecessorProcess finds a running process whose command line names the predecessor's script.
func (m *Machine) predecessorProcess(name string) (int, bool) {
for _, dir := range m.glob("/proc/[0-9]*") {
cmd := strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " ")
if strings.Contains(cmd, "/"+name) && !strings.Contains(cmd, "zephyrus") {
var pid int
fmt.Sscanf(filepath.Base(dir), "%d", &pid)
return pid, true
}
}
return 0, false
}
func (m *Machine) unitIs(ctx context.Context, verb, unit string) string {
out, _ := m.Run(ctx, "systemctl", verb, unit)
return strings.TrimSpace(out)
}
// Check reads every expectation and reports each.
func (m *Machine) Check(ctx context.Context, sw *Switcher) CheckReport {
r := CheckReport{Model: m.Model(), NotChecked: []string{
"the fan curves (asusd's own, read them with zephyrus_fan_curves)",
"whether the initramfs carries the NVIDIA options (they are read from the running driver instead)",
"the vendor keys themselves (press them)",
}}
add := func(name string, ok bool, format string, a ...any) {
r.Checks = append(r.Checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, a...)})
if !ok {
r.Failing++
}
}
add("model", m.ThisModel(), "the firmware reports %q; this module is for %q", r.Model, ModelFamily)
// asusctl: present, and from the distribution rather than a local build.
if info, err := m.Run(ctx, "pacman", "-Qi", "asusctl"); err != nil {
add("asusctl package", false, "not installed: %v", err)
} else {
v, p := "", ""
if g := pacmanVersion.FindStringSubmatch(info); g != nil {
v = g[1]
}
if g := pacmanPackager.FindStringSubmatch(info); g != nil {
p = strings.TrimSpace(g[1])
}
local := p == "Unknown Packager"
add("asusctl package", !local, "version %s, packager %s%s", v, p,
map[bool]string{true: "; a local build — the distribution's package replaces it at the next upgrade (pacman -S asusctl)", false: ""}[local])
}
for _, foreign := range []string{"supergfxctl", "triggerhappy"} {
_, err := m.Run(ctx, "pacman", "-Q", foreign)
add(foreign+" package", err == nil, "%s; not in the distribution's repositories, kept as found (novox/hq research 027, question 1)",
map[bool]string{true: "installed", false: "NOT installed"}[err == nil])
}
for _, unit := range []string{"asusd.service", "supergfxd.service", "triggerhappy.service"} {
state := m.unitIs(ctx, "is-active", unit)
add(unit, state == "active", "%s", state)
}
powerd := m.unitIs(ctx, "is-enabled", "nvidia-powerd.service")
add("nvidia-powerd.service", powerd == "masked" || powerd == "disabled" || powerd == "" || strings.Contains(powerd, "not-found"),
"%s; the module's drop-in keeps it from starting unless the kernel command line says zephyrus.nvidia-powerd", orNone(powerd))
wants, _ := m.Run(ctx, "systemctl", "show", "-p", "Wants", "systemd-suspend.service")
add("nvidia suspend and resume", strings.Contains(wants, "nvidia-suspend.service") && strings.Contains(wants, "nvidia-resume.service"),
"systemd-suspend.service %s", strings.TrimSpace(wants))
add("touchpad after resume", strings.Contains(wants, "asus-zephyrus-g14-touchpad-resume.service"),
"systemd-suspend.service wants asus-zephyrus-g14-touchpad-resume.service: %v (a daemon-reload makes a new drop-in count)",
strings.Contains(wants, "asus-zephyrus-g14-touchpad-resume.service"))
if uid := m.triggerhappyUID(); uid < 0 {
add("triggerhappy as the account", false, "no thd process runs: the vendor keys do nothing")
} else {
add("triggerhappy as the account", uid == operatorUID(), "thd runs as uid %d; the operator's account is %d (the module's drop-in passes --user)", uid, operatorUID())
}
refs := m.asUserReferences()
add("no as-user", len(refs) == 0, "%s", orNone(map[bool]string{true: "", false: "the predecessor's as-user is still named in " + strings.Join(refs, ", ") +
": it su-s to a named person on a guessed display and sources a file of secrets"}[len(refs) == 0]))
params := ParseNvidiaParams(m.read("/proc/driver/nvidia/params"))
if len(params) == 0 {
add("nvidia options", false, "the NVIDIA driver is not loaded (no /proc/driver/nvidia/params)")
} else {
add("nvidia options", params["PreserveVideoMemoryAllocations"] == "1" && params["DynamicPowerManagement"] == "0",
"PreserveVideoMemoryAllocations=%s DynamicPowerManagement=%s (want 1 and 0; a change applies when the driver loads again)",
params["PreserveVideoMemoryAllocations"], params["DynamicPowerManagement"])
}
for _, b := range m.Batteries() {
ok := b.LimitPercent != nil && *b.LimitPercent == ChargeLimitPercent
have := "unknown"
if b.LimitPercent != nil {
have = fmt.Sprintf("%d%%", *b.LimitPercent)
}
add("charge limit", ok, "%s is %s, the module's is %d%%", b.Name, have, ChargeLimitPercent)
}
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
add("one authority over the GPU mode", false,
"asusd runs %q on mains and %q on battery: it switches the GPU mode on every change of power source, "+
"so a mode set with zephyrus_gpu_mode lasts until the next one. Clear ac_command and bat_command in /etc/asusd/asusd.ron (asusd's file) to make it the operator's alone",
c.ACCommand, c.BatteryCommand)
}
if out, err := m.Run(ctx, "journalctl", "-b", "-u", "supergfxd.service", "-g", "invalid variant", "-n", "1", "-q", "-o", "cat"); err == nil && strings.TrimSpace(out) != "" {
add("supergfxd and logind", false, "supergfxd cannot read logind's sessions this boot (%s): a mode change that needs a logout times out", strings.TrimSpace(out))
}
if pid, ok := m.predecessorProcess("auto-profile"); ok {
add("one profile switcher", false, "the predecessor's auto-profile still runs (pid %d) and switches the profile every five seconds; "+
"stop it: systemctl --user disable --now auto-profile.service", pid)
} else {
add("one profile switcher", true, "no predecessor auto-profile is running")
}
if sw != nil {
rep := sw.Report()
add("profile switcher", rep.Running, "%s", orNone(firstNonEmpty(rep.Disabled, rep.LastError, "woken by "+rep.Watching)))
}
if home := os.Getenv("MESH_OPERATOR_HOME"); home != "" {
var left []string
for _, p := range PredecessorHomeFiles {
if _, err := os.Stat(filepath.Join(m.Root, home, p)); err == nil {
left = append(left, "~/"+p)
}
}
add("predecessor files in the home", len(left) == 0, "%s", orNone(strings.Join(left, ", ")))
} else {
r.NotChecked = append(r.NotChecked, "the predecessor's files in the operator's home (MESH_OPERATOR_HOME is not set)")
}
return r
}
// PredecessorHomeFiles are what the predecessor placed in the operator's home for this model and this
// module replaces. The mesh removes nothing it did not make (novox/hq ADR 0182): the operator does,
// once, and this list is how the check knows.
var PredecessorHomeFiles = []string{
"scripts/auto-profile",
".config/systemd/user/auto-profile.service",
"scripts/asus-bright",
"scripts/asusctl-kbd-bright",
"scripts/xrandr-bright",
"scripts/media-control",
"scripts/xinput-reset-touchpad",
".config/i3/scripts/kbd-brightness-notify.sh",
"scripts/.screenlayouts/orden-workspaces.sh",
}
func orNone(s string) string {
if strings.TrimSpace(s) == "" {
return "none"
}
return s
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
@@ -0,0 +1,202 @@
package main
import (
"context"
"fmt"
"math"
"os"
"path"
"sort"
"strconv"
"strings"
)
// MinPanelPercent is the floor a brightness change never goes below: a panel at zero is a black
// screen that looks like a dead machine, and the keys cannot be seen to bring it back.
const MinPanelPercent = 1
// Panel is the internal display's backlight.
type Panel struct {
Device string `json:"device"`
Percent float64 `json:"percent"`
Raw int64 `json:"raw"`
Max int64 `json:"max"`
Others []string `json:"other_backlights,omitempty"`
}
// panelDevice chooses the backlight that drives the internal panel.
//
// **This model registers two.** In hybrid mode the integrated GPU drives the panel (amdgpu_bl1,
// beneath the eDP connector) and the discrete GPU's driver registers one of its own (nvidia_0) that
// moves nothing. The predecessor's scripts named amdgpu_bl1 literally, which is right until the GPU
// mode puts the panel on the other GPU. The one that sits under an eDP connector is the panel's; failing
// that, the kernel's own preference: firmware, then platform, then raw.
func (m *Machine) panelDevice() (string, []string, error) {
all := m.glob("/sys/class/backlight/*")
if len(all) == 0 {
return "", nil, fmt.Errorf("this machine has no backlight in /sys/class/backlight")
}
names := make([]string, 0, len(all))
for _, d := range all {
names = append(names, path.Base(d))
}
sort.Strings(names)
rank := func(name string) int {
dir := "/sys/class/backlight/" + name
if target, err := os.Readlink(m.path(dir)); err == nil && strings.Contains(target, "-eDP-") {
return 0
}
switch m.read(dir + "/type") {
case "firmware":
return 1
case "platform":
return 2
}
return 3
}
best := names[0]
for _, n := range names[1:] {
if rank(n) < rank(best) {
best = n
}
}
var others []string
for _, n := range names {
if n != best {
others = append(others, n)
}
}
return best, others, nil
}
// PanelBrightness reads the panel.
func (m *Machine) PanelBrightness() (Panel, error) {
dev, others, err := m.panelDevice()
if err != nil {
return Panel{}, err
}
dir := "/sys/class/backlight/" + dev
raw, ok1 := m.readInt(dir + "/brightness")
max, ok2 := m.readInt(dir + "/max_brightness")
if !ok1 || !ok2 || max <= 0 {
return Panel{}, fmt.Errorf("%s does not say its brightness", dir)
}
return Panel{Device: dev, Raw: raw, Max: max, Percent: round1(float64(raw) / float64(max) * 100), Others: others}, nil
}
// PanelTarget turns a request — "40", "40%", "+5", "-10" — into the percentage to set, clamped to
// [MinPanelPercent, 100].
func PanelTarget(current float64, request string) (float64, error) {
r := strings.TrimSuffix(strings.TrimSpace(request), "%")
if r == "" {
return 0, fmt.Errorf("panel needs a percentage (40) or a step (+5, -5)")
}
n, err := strconv.ParseFloat(r, 64)
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, fmt.Errorf("panel %q is not a percentage or a step", request)
}
target := n
if strings.HasPrefix(r, "+") || strings.HasPrefix(r, "-") {
target = current + n
}
return math.Max(MinPanelPercent, math.Min(100, target)), nil
}
// SetPanel sets the panel to a percentage.
func (m *Machine) SetPanel(ctx context.Context, request string) (Panel, error) {
p, err := m.PanelBrightness()
if err != nil {
return p, err
}
target, err := PanelTarget(p.Percent, request)
if err != nil {
return p, err
}
raw := int64(math.Round(target / 100 * float64(p.Max)))
if raw < 1 {
raw = 1
}
if err := m.write(ctx, "/sys/class/backlight/"+p.Device+"/brightness", strconv.FormatInt(raw, 10)); err != nil {
return p, err
}
return m.PanelBrightness()
}
// Keyboard is the keyboard's backlight.
type Keyboard struct {
Device string `json:"device"`
Level string `json:"level"`
Value int64 `json:"value"`
Max int64 `json:"max"`
}
// KeyboardBrightness reads the keyboard backlight from the kernel.
func (m *Machine) KeyboardBrightness() (Keyboard, error) {
found := m.glob("/sys/class/leds/*kbd_backlight*")
if len(found) == 0 {
return Keyboard{}, fmt.Errorf("this machine has no keyboard backlight in /sys/class/leds")
}
dir := found[0]
v, ok1 := m.readInt(dir + "/brightness")
max, ok2 := m.readInt(dir + "/max_brightness")
if !ok1 || !ok2 {
return Keyboard{}, fmt.Errorf("%s does not say its brightness", dir)
}
k := Keyboard{Device: path.Base(dir), Value: v, Max: max}
if max == int64(len(KeyboardLevels)-1) && v >= 0 && v <= max {
k.Level = KeyboardLevels[v]
}
return k, nil
}
// KeyboardTarget turns a request — off/low/med/high, 0-3, "+", "-" — into asusd's level name.
func KeyboardTarget(current int64, request string) (string, error) {
r := strings.ToLower(strings.TrimSpace(request))
switch r {
case "medium":
r = "med"
case "+", "up":
r = strconv.FormatInt(min64(current+1, int64(len(KeyboardLevels)-1)), 10)
case "-", "down":
r = strconv.FormatInt(max64(current-1, 0), 10)
}
for _, l := range KeyboardLevels {
if r == l {
return l, nil
}
}
if n, err := strconv.Atoi(r); err == nil && n >= 0 && n < len(KeyboardLevels) {
return KeyboardLevels[n], nil
}
return "", fmt.Errorf("keyboard %q is not one of off, low, med, high, 0-3, + or -", request)
}
// SetKeyboard has asusd set the keyboard backlight, so its own record of the level stays true.
func (m *Machine) SetKeyboard(ctx context.Context, request string) (Keyboard, error) {
k, err := m.KeyboardBrightness()
if err != nil {
return k, err
}
level, err := KeyboardTarget(k.Value, request)
if err != nil {
return k, err
}
if _, err := m.Run(ctx, "asusctl", "leds", "set", level); err != nil {
return k, vendor("asusctl", err)
}
return m.KeyboardBrightness()
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
@@ -0,0 +1,91 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// backlight makes a backlight the way sysfs does: a link from /sys/class/backlight into the device
// tree, which is where the eDP connector shows.
func (f *fake) backlight(name, device string, raw, max string) {
dev := "/sys/devices/" + device + "/" + name
f.file(dev+"/brightness", raw)
f.file(dev+"/max_brightness", max)
f.file(dev+"/type", "raw")
link := filepath.Join(f.root, "/sys/class/backlight", name)
os.MkdirAll(filepath.Dir(link), 0o755)
if err := os.Symlink(filepath.Join(f.root, dev), link); err != nil {
f.t.Fatal(err)
}
}
func TestThePanelIsTheBacklightUnderTheEDPConnectorNotTheDiscreteGPUs(t *testing.T) {
f := newFake(t)
f.backlight("amdgpu_bl1", "pci0000:00/0000:65:00.0/drm/card1/card1-eDP-1", "199500", "399000")
f.backlight("nvidia_0", "pci0000:00/0000:01:00.0/backlight", "100", "100")
p, err := f.machine().PanelBrightness()
if err != nil {
t.Fatal(err)
}
if p.Device != "amdgpu_bl1" || p.Percent != 50 || len(p.Others) != 1 || p.Others[0] != "nvidia_0" {
t.Fatalf("%+v", p)
}
}
func TestAPanelRequestIsAPercentageOrAStepAndNeverGoesDark(t *testing.T) {
for _, c := range []struct {
cur float64
req string
want float64
}{{50, "40", 40}, {50, "40%", 40}, {50, "+5", 55}, {50, "-10", 40}, {3, "-10", 1}, {98, "+5", 100}, {50, "0", 1}} {
got, err := PanelTarget(c.cur, c.req)
if err != nil || got != c.want {
t.Errorf("%v %q: %v %v, want %v", c.cur, c.req, got, err, c.want)
}
}
for _, bad := range []string{"", "bright", "NaN"} {
if _, err := PanelTarget(50, bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
func TestSettingThePanelWritesTheRawValue(t *testing.T) {
f := newFake(t)
f.backlight("amdgpu_bl1", "card1-eDP-1", "399000", "399000")
p, err := f.machine().SetPanel(context.Background(), "25")
if err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(f.root, "/sys/devices/card1-eDP-1/amdgpu_bl1/brightness"))
if strings.TrimSpace(string(raw)) != "99750" || p.Percent != 25 {
t.Fatalf("wrote %q, read back %+v", raw, p)
}
}
func TestTheKeyboardIsSetThroughAsusdByLevel(t *testing.T) {
f := newFake(t)
f.file("/sys/class/leds/asus::kbd_backlight/brightness", "1")
f.file("/sys/class/leds/asus::kbd_backlight/max_brightness", "3")
k, err := f.machine().KeyboardBrightness()
if err != nil || k.Level != "low" {
t.Fatalf("%+v %v", k, err)
}
if _, err := f.machine().SetKeyboard(context.Background(), "+"); err != nil {
t.Fatal(err)
}
if !f.called("asusctl leds set med") {
t.Fatalf("calls: %v", f.calls)
}
for req, want := range map[string]string{"high": "high", "0": "off", "medium": "med", "-": "off"} {
if got, err := KeyboardTarget(1, req); err != nil || got != want {
t.Errorf("%q: %q %v", req, got, err)
}
}
if _, err := KeyboardTarget(1, "7"); err == nil {
t.Error("level 7 was accepted")
}
}
@@ -0,0 +1,103 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and
// recording every command it was asked to run.
type fake struct {
t *testing.T
root string
mu sync.Mutex
answers map[string]string
fails map[string]error
calls []string
}
func newFake(t *testing.T) *fake {
t.Helper()
return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}}
}
func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} }
func (f *fake) run(_ context.Context, name string, args ...string) (string, error) {
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, line)
if err, ok := f.fails[line]; ok {
return "", err
}
if out, ok := f.answers[line]; ok {
return out, nil
}
if err, ok := f.fails[name]; ok {
return "", err
}
return "", nil
}
func (f *fake) called(line string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c == line {
return true
}
}
return false
}
func (f *fake) callsLike(prefix string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
out = append(out, c)
}
}
return out
}
// file writes a file under the fake root.
func (f *fake) file(path, content string) {
f.t.Helper()
full := filepath.Join(f.root, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// supply writes one power supply's attributes.
func (f *fake) supply(name string, attrs map[string]string) {
for k, v := range attrs {
f.file("/sys/class/power_supply/"+name+"/"+k, v+"\n")
}
}
// onMains and onBattery are this model's two states as measured on 2026-10-04.
func (f *fake) onMains() {
f.supply("ACAD", map[string]string{"type": "Mains", "online": "1"})
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Not charging", "capacity": "80"})
}
func (f *fake) onBattery() {
f.supply("ACAD", map[string]string{"type": "Mains", "online": "0"})
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Discharging", "capacity": "79"})
}
var notFound = &exec.Error{Name: "x", Err: exec.ErrNotFound}
const profileGetBalanced = "Active profile: Balanced\n\nAC profile Balanced\nBattery profile Quiet\n"
@@ -0,0 +1,156 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Key is one custom key on the laptop: what it is, what runs, and where that is defined.
type Key struct {
Key string `json:"key"`
Physical string `json:"physical,omitempty"`
When string `json:"when,omitempty"`
Runs string `json:"runs"`
From string `json:"from"`
}
// KeysReport is what zephyrus_keys answers.
type KeysReport struct {
Keys []Key `json:"keys"`
Warnings []string `json:"warnings"`
NotRead []string `json:"not_read"`
}
// TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it.
const TriggerDir = "/etc/triggerhappy/triggers.d"
// I3Fragments are the module's own files in i3's include directory, relative to the account's home.
var I3Fragments = []string{".config/i3/config.d/10-asus.conf", ".config/i3/config.d/20-g14.conf"}
// physicalKeys names the key behind an evdev code, as far as it is known on this model. The media
// codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded.
var physicalKeys = map[string]string{
"KEY_PROG1": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_PROG3": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_PROG4": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_BRIGHTNESSDOWN": "Fn+F7",
"KEY_BRIGHTNESSUP": "Fn+F8",
"KEY_F21": "Fn+F10 (touchpad)",
"$mod+Shift+s": "Fn+F6 (screenshot; the firmware sends Super+Shift+S)",
"$mod+p": "Fn+F9 (display; the firmware sends Super+P)",
}
// firmwareKeys are handled below any configuration file.
var firmwareKeys = []Key{
{Key: "Fn+F2 / Fn+F3", Physical: "keyboard backlight", Runs: "the firmware and asusd; zephyrus-kbd-notify shows the level", From: "firmware"},
}
var (
triggerLine = regexp.MustCompile(`^(\S+)\s+([0-9]+)\s+(.+)$`)
i3Bind = regexp.MustCompile(`^bindsym\s+((?:--\S+\s+)*)(\S+)\s+(.+)$`)
i3Exec = regexp.MustCompile(`^exec(?:_always)?\s+(?:--no-startup-id\s+)?(.+)$`)
)
var triggerWhen = map[string]string{"0": "released", "1": "pressed", "2": "held (repeat)"}
// Keys lists the laptop's custom keys: every triggerhappy trigger, the module's i3 lines and the keys
// the firmware handles itself.
func (m *Machine) Keys() KeysReport {
r := KeysReport{Keys: []Key{}, Warnings: []string{}, NotRead: []string{}}
seen := map[string][]string{}
files := m.glob(TriggerDir + "/*.conf")
sort.Strings(files)
if len(files) == 0 {
r.Warnings = append(r.Warnings, "no trigger file in "+TriggerDir+": the vendor keys do nothing")
}
for _, f := range files {
for _, line := range strings.Split(m.read(f), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
g := triggerLine.FindStringSubmatch(line)
if g == nil {
continue
}
r.Keys = append(r.Keys, Key{Key: g[1], Physical: physicalKeys[g[1]], When: triggerWhen[g[2]], Runs: g[3], From: f})
id := g[1] + " " + g[2]
seen[id] = append(seen[id], f)
}
}
for id, fs := range seen {
if len(fs) > 1 {
r.Warnings = append(r.Warnings, fmt.Sprintf("%s is bound in %d places (%s): it fires every one", id, len(fs), strings.Join(fs, ", ")))
}
}
home := os.Getenv("MESH_OPERATOR_HOME")
if home == "" {
r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)")
}
for _, rel := range I3Fragments {
if home == "" {
break
}
p := filepath.Join(home, rel)
text := m.read(p)
if text == "" {
r.Warnings = append(r.Warnings, "~/"+rel+" is missing or empty")
continue
}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if g := i3Bind.FindStringSubmatch(line); g != nil {
when := "pressed"
if strings.Contains(g[1], "--release") {
when = "released"
}
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: "~/" + rel})
} else if g := i3Exec.FindStringSubmatch(line); g != nil {
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: "~/" + rel})
}
}
}
r.Keys = append(r.Keys, firmwareKeys...)
sort.Strings(r.Warnings)
return r
}
// asUserReferences finds the predecessor's as-user wrapper still named in a trigger, a udev rule or
// the module's i3 lines.
func (m *Machine) asUserReferences() []string {
var at []string
for _, pattern := range []string{TriggerDir + "/*.conf", "/etc/udev/rules.d/*.rules", "/etc/systemd/system-sleep/*"} {
for _, f := range m.glob(pattern) {
if strings.Contains(m.read(f), "as-user") {
at = append(at, f)
}
}
}
sort.Strings(at)
return at
}
// triggerhappyUID is the real user id the running thd has, or -1 when none runs.
func (m *Machine) triggerhappyUID() int {
for _, dir := range m.glob("/proc/[0-9]*") {
if strings.TrimSpace(m.read(dir+"/comm")) != "thd" {
continue
}
for _, line := range strings.Split(m.read(dir+"/status"), "\n") {
if f := strings.Fields(line); len(f) >= 2 && f[0] == "Uid:" {
var uid int
if _, err := fmt.Sscanf(f[1], "%d", &uid); err == nil {
return uid
}
}
}
}
return -1
}
// operatorUID is the account the module's code runs as: the runtime runs it as the operator's.
var operatorUID = os.Getuid
@@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func put(t *testing.T, root, p, content string) {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// The module's shipped triggers and i3 lines, read back as keys: what each runs and where.
func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
f := newFake(t)
m := readManifest(t)
content := map[string]string{}
for _, r := range m.Resources {
if c, ok := r["content"].(string); ok {
content[r["id"].(string)] = c
}
}
put(t, f.root, TriggerDir+"/asus-g14.conf", content["vendor-keys"])
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
put(t, f.root, home+"/"+I3Fragments[0], content["i3-vendor-keys"])
put(t, f.root, home+"/"+I3Fragments[1], content["i3-model"])
r := f.machine().Keys()
got := map[string]Key{}
for _, k := range r.Keys {
got[k.Key+"|"+k.When] = k
}
if k := got["KEY_F21|pressed"]; !strings.Contains(k.Runs, "zephyrus-touchpad reset") || k.Physical == "" {
t.Errorf("touchpad key: %+v", k)
}
if k := got["KEY_PROG1|pressed"]; !strings.Contains(k.Runs, "zephyrus-media play-pause") {
t.Errorf("media key: %+v", k)
}
if k := got["$mod+Shift+s|released"]; !strings.Contains(k.Runs, "screenshot") || !strings.Contains(k.Physical, "Fn+F6") {
t.Errorf("screenshot key: %+v", k)
}
if k := got["$mod+p|pressed"]; !strings.Contains(k.Runs, "zephyrus-display order") {
t.Errorf("display key: %+v", k)
}
if k := got["(session start)|at login"]; k.Runs == "" {
t.Errorf("no session-start line read")
}
if k := got["Fn+F2 / Fn+F3|"]; k.From != "firmware" {
t.Errorf("firmware keys missing: %+v", r.Keys)
}
if len(r.Warnings) != 0 || len(r.NotRead) != 0 {
t.Errorf("warnings %v, not read %v", r.Warnings, r.NotRead)
}
if _, err := json.Marshal(r); err != nil {
t.Fatal(err)
}
}
// A key bound in two trigger files fires twice, which is said.
func TestKeysWarnsAboutAKeyInTwoTriggerFiles(t *testing.T) {
f := newFake(t)
put(t, f.root, TriggerDir+"/asus-g14.conf", "KEY_F21\t1\t/x reset\n")
put(t, f.root, TriggerDir+"/old.conf", "KEY_F21\t1\t/y reset\n")
t.Setenv("MESH_OPERATOR_HOME", "")
r := f.machine().Keys()
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "KEY_F21") || len(r.NotRead) != 1 {
t.Errorf("warnings %v, not read %v", r.Warnings, r.NotRead)
}
}
// The check names what still calls as-user, and whether triggerhappy runs as the account.
func TestCheckFindsAsUserAndTriggerhappysAccount(t *testing.T) {
f := newFake(t)
put(t, f.root, "/etc/udev/rules.d/91-monitor-hotplug.rules", `RUN+="/x/scripts/as-user setsid wizard"`)
put(t, f.root, "/proc/4242/comm", "thd\n")
put(t, f.root, "/proc/4242/status", "Name:\tthd\nUid:\t0\t0\t0\t0\n")
was := operatorUID
operatorUID = func() int { return 1000 }
defer func() { operatorUID = was }()
t.Setenv("MESH_OPERATOR_HOME", "")
r := f.machine().Check(context.Background(), nil)
by := map[string]Check{}
for _, c := range r.Checks {
by[c.Name] = c
}
if c := by["no as-user"]; c.OK || !strings.Contains(c.Detail, "91-monitor-hotplug.rules") {
t.Errorf("as-user: %+v", c)
}
if c := by["triggerhappy as the account"]; c.OK || !strings.Contains(c.Detail, "uid 0") {
t.Errorf("triggerhappy: %+v", c)
}
if c := by["touchpad after resume"]; c.OK {
t.Errorf("resume: %+v", c)
}
}
@@ -0,0 +1,124 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// CommandTimeout bounds every command a tool or the switcher runs: a vendor daemon that hangs on its
// bus must cost a tool call twenty seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// Runner runs one command and answers its standard output. It is injected so that every tool is
// tested against recorded answers rather than this machine's daemons.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the
// command said on stderr, because "exit status 1" names nothing.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
if said != "" {
return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said)
}
return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return stdout.String(), nil
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of
// /sys and /proc and /etc) and a way to run commands.
type Machine struct {
Root string
Run Runner
}
// Here is the machine this process runs on.
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// read is a file's content, trimmed; "" when it cannot be read.
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// readInt is a file holding one integer; ok false when it is absent or not a number.
func (m *Machine) readInt(p string) (int64, bool) {
s := m.read(p)
if s == "" {
return 0, false
}
n, err := strconv.ParseInt(s, 10, 64)
return n, err == nil
}
func (m *Machine) glob(pattern string) []string {
found, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(found))
for _, f := range found {
rel, err := filepath.Rel(m.Root, f)
if err != nil {
continue
}
out = append(out, "/"+filepath.ToSlash(rel))
}
return out
}
// write puts a value into a file of the kernel's (a backlight). Where the account may not write it
// — the udev rule that gives the video group the panel has not run yet — it escalates with `sudo -n`,
// which never prompts: the operator's account may escalate without one, and when it may not, the
// tool says so in sudo's words.
func (m *Machine) write(ctx context.Context, p, value string) error {
err := os.WriteFile(m.path(p), []byte(value), 0)
if err == nil {
return nil
}
if !errors.Is(err, os.ErrPermission) {
return err
}
if _, serr := m.Run(ctx, "sudo", "-n", "sh", "-c", `printf '%s' "$1" > "$2"`, "sh", value, m.path(p)); serr != nil {
return fmt.Errorf("%s is not writable by this account and sudo -n refused: %v", p, serr)
}
return nil
}
// notInstalled says a command failed because it is not on this machine at all.
func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) }
// round to one decimal, for watts and percentages a person reads.
func round1(f float64) float64 {
return float64(int64(f*10+sign(f)*0.5)) / 10
}
func sign(f float64) float64 {
if f < 0 {
return -1
}
return 1
}
@@ -0,0 +1,25 @@
// The asus-zephyrus-g14 module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the
// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running
// code — the platform-profile switcher — beside them.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
sw := NewSwitcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sw.Run(ctx)
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE).
if err := stdio.Serve("", Tools(m, sw)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,110 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile("../../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 TestTheManifestNamesExactlyTheToolsTheBundleServes(t *testing.T) {
m := readManifest(t)
var served []string
for _, tool := range Tools(&Machine{Root: t.TempDir(), Run: newFake(t).run}, nil) {
served = append(served, tool.Name)
}
sort.Strings(served)
listed := append([]string(nil), m.Tools...)
sort.Strings(listed)
if strings.Join(served, ",") != strings.Join(listed, ",") {
t.Fatalf("served %v, listed %v", served, listed)
}
if len(m.Emits) != 1 || m.Emits[0] != "profile.switched" {
t.Fatalf("emits %v", m.Emits)
}
}
// The module names the model, never a node, a person or a user id (novox/hq ADR 0112), and every
// trigger it names a service restart or reload on is one of its own resources.
func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
m := readManifest(t)
ids := map[string]bool{}
for _, r := range m.Resources {
ids[r["id"].(string)] = true
}
raw, _ := os.ReadFile("../../module.json")
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
if strings.Contains(string(raw), banned) {
t.Errorf("the manifest says %q", banned)
}
}
for _, r := range m.Resources {
for _, key := range []string{"restart-on", "reload-on"} {
list, _ := r[key].([]any)
for _, id := range list {
if !ids[id.(string)] {
t.Errorf("%s %s names %v, which is not a resource", r["id"], key, id)
}
}
}
}
}
// Every trigger runs a script the module ships, and every script parses.
func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) {
m := readManifest(t)
var triggers string
for _, r := range m.Resources {
if r["id"] == "vendor-keys" {
triggers = r["content"].(string)
}
}
if triggers == "" {
t.Fatal("no vendor-keys resource")
}
for _, line := range strings.Split(triggers, "\n") {
f := strings.Split(line, "\t")
if strings.HasPrefix(line, "#") || len(f) < 3 {
continue
}
script := strings.Fields(f[2])[0]
local := filepath.Join("../../files/bin", filepath.Base(script))
if !strings.HasPrefix(script, "/usr/local/lib/asus-zephyrus-g14/bin/") {
t.Errorf("%s runs %s, which the module does not ship", f[0], script)
} else if st, err := os.Stat(local); err != nil || st.Mode()&0o111 == 0 {
t.Errorf("%s: %s is missing or not executable", f[0], local)
}
}
scripts, _ := filepath.Glob("../../files/bin/*")
if len(scripts) == 0 {
t.Fatal("no scripts")
}
for _, s := range scripts {
if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil {
t.Errorf("%s: %v %s", s, err, out)
}
}
}
@@ -0,0 +1,137 @@
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
// The policy, as constants until the mesh has settings a module can read (novox/hq issue 168). The
// values are the predecessor's, made explicit, and two of its behaviours are changed on purpose:
//
// - **Sustained, not momentary.** The predecessor boosted on one five-second sample above 50 %: a
// compile's first second, a browser's tab restore. Here the load must stay above the line for
// SustainSamples samples in a row, and below the lower line as long, before the profile moves.
// - **Waiting on a disk is not load.** iowait is counted as idle: a machine stalled on its SSD does
// not get faster with a higher power limit, only hotter.
const (
ProfileOnBattery = "Quiet"
ProfileOnAC = "Balanced"
ProfileUnderLoad = "Performance"
CPUHighPercent = 50.0 // on mains, sustained at or above this boosts to ProfileUnderLoad
CPULowPercent = 20.0 // and sustained at or below this goes back to ProfileOnAC
SampleEvery = 10 * time.Second // CPU is sampled only on mains; on battery nothing is sampled
SustainSamples = 3 // 30 s above CPUHighPercent to boost
RelaxSamples = 3 // 30 s below CPULowPercent to relax
// SafetyRecheck is how often the power source is read when no event has said it changed: the
// kernel's event is the trigger, and this only covers one lost across a suspend.
SafetyRecheck = 5 * time.Minute
// DefaultHold is how long a profile chosen through the profile tool is kept before the switcher
// may move it again. A change of power source ends a hold at once.
DefaultHold = 60 * time.Minute
// ChargeLimitPercent is the battery charge limit the module asserts through asusd at start.
ChargeLimitPercent = 80
)
// Policy is the switcher's memory of recent load: how many samples in a row were above the upper line
// or below the lower one, and whether it is boosted.
type Policy struct {
Boosted bool `json:"boosted"`
Above int `json:"samples_above"`
Below int `json:"samples_below"`
Recent []float64 `json:"recent_cpu_percent"`
BoostedSince time.Time `json:"boosted_since,omitempty"`
}
// Observe takes one CPU sample (busy percent since the previous one) taken on mains.
func (p *Policy) Observe(cpu float64, at time.Time) {
p.Recent = append(p.Recent, round1(cpu))
if len(p.Recent) > 6 {
p.Recent = p.Recent[len(p.Recent)-6:]
}
switch {
case cpu >= CPUHighPercent:
p.Above++
p.Below = 0
if !p.Boosted && p.Above >= SustainSamples {
p.Boosted = true
p.BoostedSince = at
}
case cpu <= CPULowPercent:
p.Below++
p.Above = 0
if p.Boosted && p.Below >= RelaxSamples {
p.Boosted = false
p.BoostedSince = time.Time{}
}
default:
// Between the lines: no direction is sustained, and the profile stays where it is.
p.Above, p.Below = 0, 0
}
}
// Reset forgets the load, for a change of power source.
func (p *Policy) Reset() { *p = Policy{} }
// Decision is what the switcher would choose, and why.
type Decision struct {
Profile string `json:"profile"`
Reason string `json:"reason"`
}
// Decide is the policy: battery → ProfileOnBattery; mains → ProfileOnAC, or ProfileUnderLoad while
// boosted.
func (p *Policy) Decide(src Source) Decision {
if !src.OnAC {
return Decision{ProfileOnBattery, "on battery (" + src.Reason + ")"}
}
if p.Boosted {
return Decision{ProfileUnderLoad, fmt.Sprintf("on mains (%s) and CPU load sustained at or above %s%% for %d samples of %s",
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64), SustainSamples, SampleEvery)}
}
return Decision{ProfileOnAC, fmt.Sprintf("on mains (%s), and CPU load not sustained at or above %s%%",
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64))}
}
// CPUTimes is the first line of /proc/stat: total and idle jiffies (iowait counted as idle).
type CPUTimes struct{ Total, Idle uint64 }
// ParseProcStat reads the aggregate cpu line of /proc/stat.
func ParseProcStat(text string) (CPUTimes, error) {
line := strings.SplitN(text, "\n", 2)[0]
f := strings.Fields(line)
if len(f) < 6 || f[0] != "cpu" {
return CPUTimes{}, fmt.Errorf("/proc/stat does not start with the cpu line")
}
var t CPUTimes
for i, s := range f[1:] {
if i >= 8 { // user nice system idle iowait irq softirq steal; guest is already in user
break
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return CPUTimes{}, fmt.Errorf("/proc/stat: %v", err)
}
t.Total += n
if i == 3 || i == 4 {
t.Idle += n
}
}
return t, nil
}
// Busy is the percentage of time not idle between two readings.
func Busy(before, after CPUTimes) (float64, bool) {
if after.Total <= before.Total {
return 0, false
}
total := float64(after.Total - before.Total)
idle := float64(after.Idle - before.Idle)
return (total - idle) / total * 100, true
}
@@ -0,0 +1,180 @@
package main
import (
"path"
"sort"
"strings"
)
// Supply is one entry of /sys/class/power_supply as the kernel reports it.
type Supply struct {
Name string `json:"name"`
Type string `json:"type"`
Scope string `json:"scope,omitempty"`
Status string `json:"status,omitempty"`
Online *bool `json:"online,omitempty"`
}
// Supplies is every power supply the kernel knows, sorted by name.
func (m *Machine) Supplies() []Supply {
var out []Supply
for _, dir := range m.glob("/sys/class/power_supply/*") {
s := Supply{
Name: path.Base(dir),
Type: m.read(dir + "/type"),
Scope: m.read(dir + "/scope"),
Status: m.read(dir + "/status"),
}
if v, ok := m.readInt(dir + "/online"); ok {
on := v == 1
s.Online = &on
}
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// system is a supply that powers this machine. A mouse's or a headset's battery reports scope
// Device, and it says nothing about whether the laptop is on mains.
func (s Supply) system() bool { return !strings.EqualFold(s.Scope, "Device") }
// Source is where the machine draws its power from, and why that was concluded.
type Source struct {
OnAC bool `json:"on_ac"`
Source string `json:"source"`
Reason string `json:"reason"`
}
// PowerSource decides mains or battery.
//
// **A battery that says it is discharging wins over any adapter that says it is online.** The
// predecessor's script took any `online` file reading 1 as mains, and a USB-C port reports `online`
// for things that do not power the machine. The battery's own status is the one fact that cannot be
// misread: it discharges exactly when nothing outside is carrying the load. Only when no battery says
// so are the adapters asked, and a machine with no system battery at all is on mains.
func PowerSource(supplies []Supply) Source {
batteries := 0
for _, s := range supplies {
if s.Type == "Battery" && s.system() {
batteries++
if strings.EqualFold(s.Status, "Discharging") {
return Source{OnAC: false, Source: "battery", Reason: s.Name + " is discharging"}
}
}
}
for _, s := range supplies {
if (s.Type == "Mains" || strings.HasPrefix(s.Type, "USB")) && s.system() && s.Online != nil && *s.Online {
return Source{OnAC: true, Source: "ac", Reason: s.Name + " (" + s.Type + ") is online"}
}
}
if batteries == 0 {
return Source{OnAC: true, Source: "ac", Reason: "this machine has no system battery"}
}
return Source{OnAC: false, Source: "battery", Reason: "no mains or USB supply is online"}
}
// Battery is what the battery tool answers.
type Battery struct {
Name string `json:"name"`
Status string `json:"status"`
ChargePercent *int64 `json:"charge_percent,omitempty"`
EnergyWh *float64 `json:"energy_wh,omitempty"`
FullWh *float64 `json:"full_wh,omitempty"`
DesignWh *float64 `json:"design_wh,omitempty"`
HealthPercent *float64 `json:"health_percent,omitempty"`
Cycles *int64 `json:"cycles"`
CyclesNote string `json:"cycles_note,omitempty"`
LimitPercent *int64 `json:"charge_limit_percent,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
HoursRemaining *float64 `json:"hours_remaining,omitempty"`
Technology string `json:"technology,omitempty"`
Model string `json:"model,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
}
// Batteries reads every system battery.
func (m *Machine) Batteries() []Battery {
var out []Battery
for _, s := range m.Supplies() {
if s.Type != "Battery" || !s.system() {
continue
}
out = append(out, m.battery(s))
}
return out
}
func (m *Machine) battery(s Supply) Battery {
dir := "/sys/class/power_supply/" + s.Name
b := Battery{
Name: s.Name, Status: s.Status,
Technology: m.read(dir + "/technology"),
Model: m.read(dir + "/model_name"),
Manufacturer: strings.TrimSpace(m.read(dir + "/manufacturer")),
}
if v, ok := m.readInt(dir + "/capacity"); ok {
b.ChargePercent = &v
}
// Energy in Wh: energy_* (µWh) where the firmware reports it, else charge_* (µAh) times the
// design minimum voltage, which is how upower converts it too.
wh := func(energy, charge string) *float64 {
if v, ok := m.readInt(dir + "/" + energy); ok {
f := round1(float64(v) / 1e6)
return &f
}
c, okc := m.readInt(dir + "/" + charge)
volts, okv := m.readInt(dir + "/voltage_min_design")
if okc && okv {
f := round1(float64(c) * float64(volts) / 1e12)
return &f
}
return nil
}
b.EnergyWh = wh("energy_now", "charge_now")
b.FullWh = wh("energy_full", "charge_full")
b.DesignWh = wh("energy_full_design", "charge_full_design")
if b.FullWh != nil && b.DesignWh != nil && *b.DesignWh > 0 {
h := round1(*b.FullWh / *b.DesignWh * 100)
b.HealthPercent = &h
}
if v, ok := m.readInt(dir + "/cycle_count"); ok && v > 0 {
b.Cycles = &v
} else {
b.CyclesNote = "the firmware does not report a cycle count (it reads 0)"
}
if v, ok := m.readInt(dir + "/charge_control_end_threshold"); ok {
b.LimitPercent = &v
}
if w := m.batteryWatts(dir); w != nil {
b.PowerW = w
if strings.EqualFold(s.Status, "Discharging") && b.EnergyWh != nil && *w > 0.5 {
h := round1(*b.EnergyWh / *w)
b.HoursRemaining = &h
}
}
return b
}
// batteryWatts is how much the battery is giving or taking, in watts, unsigned: power_now where the
// firmware reports it, else current times voltage.
func (m *Machine) batteryWatts(dir string) *float64 {
if v, ok := m.readInt(dir + "/power_now"); ok {
f := round1(abs(float64(v)) / 1e6)
return &f
}
i, oki := m.readInt(dir + "/current_now")
u, oku := m.readInt(dir + "/voltage_now")
if oki && oku {
f := round1(abs(float64(i)) * float64(u) / 1e12)
return &f
}
return nil
}
func abs(f float64) float64 {
if f < 0 {
return -f
}
return f
}
@@ -0,0 +1,73 @@
package main
import "testing"
func on(b bool) *bool { return &b }
func TestADischargingBatteryWinsOverAnAdapterThatSaysOnline(t *testing.T) {
got := PowerSource([]Supply{
{Name: "BAT1", Type: "Battery", Status: "Discharging"},
{Name: "ucsi-source-psy-USBC000:001", Type: "USB", Scope: "System", Online: on(true)},
})
if got.OnAC {
t.Fatalf("a USB-C port reporting online while the battery discharges was read as mains: %+v", got)
}
}
func TestMainsOnlineIsAC(t *testing.T) {
got := PowerSource([]Supply{
{Name: "ACAD", Type: "Mains", Online: on(true)},
{Name: "BAT1", Type: "Battery", Status: "Not charging"},
})
if !got.OnAC || got.Reason != "ACAD (Mains) is online" {
t.Fatalf("%+v", got)
}
}
func TestAPeripheralsBatteryDecidesNothing(t *testing.T) {
got := PowerSource([]Supply{
{Name: "hidpp_battery_0", Type: "Battery", Scope: "Device", Status: "Discharging"},
{Name: "ACAD", Type: "Mains", Online: on(true)},
{Name: "BAT1", Type: "Battery", Status: "Charging"},
})
if !got.OnAC {
t.Fatalf("a mouse's discharging battery put the laptop on battery: %+v", got)
}
}
func TestNoSupplyOnlineWithABatteryIsBatteryAndNoBatteryIsMains(t *testing.T) {
if got := PowerSource([]Supply{{Name: "ACAD", Type: "Mains", Online: on(false)}, {Name: "BAT1", Type: "Battery", Status: "Unknown"}}); got.OnAC {
t.Fatalf("%+v", got)
}
if got := PowerSource(nil); !got.OnAC {
t.Fatalf("a machine with no battery is on mains: %+v", got)
}
}
func TestTheBatteryIsReadInWattHoursFromChargeAndHealthAgainstDesign(t *testing.T) {
f := newFake(t)
// The laptop's own battery, as measured: charge_* in µAh, no energy_* and no power_now.
f.supply("BAT1", map[string]string{
"type": "Battery", "status": "Discharging", "capacity": "80",
"charge_now": "3073000", "charge_full": "3865000", "charge_full_design": "4580000",
"voltage_min_design": "15939000", "current_now": "1000000", "voltage_now": "16000000",
"cycle_count": "0", "charge_control_end_threshold": "80", "manufacturer": "ASUS ",
})
bs := f.machine().Batteries()
if len(bs) != 1 {
t.Fatalf("%+v", bs)
}
b := bs[0]
if *b.EnergyWh != 49 || *b.FullWh != 61.6 || *b.DesignWh != 73 || *b.HealthPercent != 84.4 {
t.Fatalf("energy %v full %v design %v health %v", *b.EnergyWh, *b.FullWh, *b.DesignWh, *b.HealthPercent)
}
if b.Cycles != nil || b.CyclesNote == "" {
t.Fatal("a cycle count of 0 is the firmware not reporting one, and said so")
}
if *b.LimitPercent != 80 || *b.PowerW != 16 || b.HoursRemaining == nil || *b.HoursRemaining != 3.1 {
t.Fatalf("limit %v power %v hours %v", *b.LimitPercent, *b.PowerW, b.HoursRemaining)
}
if b.Manufacturer != "ASUS" {
t.Fatalf("manufacturer %q", b.Manufacturer)
}
}
@@ -0,0 +1,303 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
)
// The module's long-running code (novox/hq ADR 0198): the profile switcher, launched with the tools
// by the node's runtime and running beside them in the same process.
//
// It replaces the predecessor's `auto-profile`, a user unit that woke every five seconds for ever —
// read the adapters, read /proc/stat, maybe call asusctl — on battery too, where its only possible
// answer was the one it had already given. Here the kernel's power-supply event is the trigger; the
// CPU is sampled only on mains, where the answer depends on it; and on battery the process sleeps
// until the adapter comes back.
//
// **It acts on a change of its decision, never to restore one.** A profile chosen by hand — the
// vendor's profile key, asusctl in a terminal, the profile tool — stays until the power source
// changes or the load crosses a line. The predecessor re-asserted its choice every five seconds and so
// made the profile key useless on battery.
// Emitter publishes an event as the module; nil when the process is not under the runtime.
type Emitter func(eventType string, body any) error
// Switcher is the switcher's state, shared with the tools that report it.
type Switcher struct {
m *Machine
now func() time.Time
emit Emitter
mu sync.Mutex
policy Policy
source *Source
decision *Decision
applied string
appliedAt time.Time
lastError string
holdUntil time.Time
holdOf string
watching string
cpuPrev *CPUTimes
disabled string
asserted []string
}
func NewSwitcher(m *Machine, emit Emitter) *Switcher {
return &Switcher{m: m, now: time.Now, emit: emit}
}
// Model is the machine's product family as its firmware reports it.
func (m *Machine) Model() string { return m.read("/sys/class/dmi/id/product_family") }
// ModelFamily is the family this module is written for.
const ModelFamily = "ROG Zephyrus G14"
// ThisModel says whether the machine is the model this module is written for.
func (m *Machine) ThisModel() bool { return strings.EqualFold(m.Model(), ModelFamily) }
// sampleCPU reads /proc/stat and answers the busy percentage since the previous reading.
func (s *Switcher) sampleCPU() (float64, bool) {
t, err := ParseProcStat(s.m.read("/proc/stat"))
if err != nil {
return 0, false
}
prev := s.cpuPrev
s.cpuPrev = &t
if prev == nil {
return 0, false
}
return Busy(*prev, t)
}
// Evaluate reads the power source, takes a CPU sample when asked and on mains, decides, and applies
// the decision when it changed. It is the whole of one wake-up and what the tests drive.
func (s *Switcher) Evaluate(ctx context.Context, sample bool) {
if body := s.evaluate(ctx, sample); body != nil && s.emit != nil {
// Outside the lock: publishing waits for the bus, and the tools that report the switcher
// must not wait with it.
if err := s.emit("profile.switched", body); err != nil {
fmt.Fprintf(os.Stderr, "profile.switched not published: %v\n", err)
}
}
}
// evaluate is Evaluate under the lock; it answers the event to publish when it switched.
func (s *Switcher) evaluate(ctx context.Context, sample bool) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
if s.disabled != "" {
return nil
}
src := PowerSource(s.m.Supplies())
now := s.now()
first := s.source == nil
if first || s.source.OnAC != src.OnAC {
// A new power source: what was learnt about load on the other one says nothing here, and a
// hold was for the source it was asked on.
s.policy.Reset()
s.cpuPrev = nil
s.holdUntil = time.Time{}
s.holdOf = ""
s.sampleCPU() // the first reading on this source, so the next sample is a difference
} else if sample && src.OnAC {
if busy, ok := s.sampleCPU(); ok {
s.policy.Observe(busy, now)
}
}
s.source = &src
d := s.policy.Decide(src)
s.decision = &d
// **Starting is not a reason to switch.** The runtime starts this process on every push that
// changes a bundle; at boot and at every change of power source asusd has already applied its own
// profile for the source, which AssertVendorSettings made the policy's. So the first decision is
// taken as applied, and a profile someone chose by hand survives a push.
if first {
s.applied = d.Profile
return nil
}
// Compared with what the switcher itself last applied, never with the profile in force: a profile
// someone chose by hand is not a reason to act, a new decision is.
if d.Profile == s.applied || now.Before(s.holdUntil) {
return nil
}
from := s.applied
if err := s.m.SetProfile(ctx, d.Profile); err != nil {
s.lastError = err.Error() // and tried again at the next wake-up, since applied did not move
return nil
}
s.lastError = ""
s.applied, s.appliedAt = d.Profile, now
body := map[string]any{"profile": d.Profile, "reason": d.Reason, "source": src.Source}
if from != "" {
body["from"] = from
}
return body
}
// Hold keeps a profile chosen through the tool for a while: the switcher does not move it until the
// hold ends or the power source changes.
func (s *Switcher) Hold(profile string, d time.Duration) time.Time {
s.mu.Lock()
defer s.mu.Unlock()
if d <= 0 {
s.holdUntil, s.holdOf = time.Time{}, ""
return time.Time{}
}
s.holdUntil, s.holdOf = s.now().Add(d), profile
return s.holdUntil
}
// Run is the switcher's life: assert asusd's settings once, then wake on each power-supply event, on
// each CPU sample while on mains, and at SafetyRecheck otherwise.
func (s *Switcher) Run(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
s.mu.Lock()
s.disabled = fmt.Sprintf("the switcher stopped on a fault: %v", r)
s.mu.Unlock()
fmt.Fprintln(os.Stderr, s.disabled)
}
}()
if !s.m.ThisModel() {
s.mu.Lock()
s.disabled = fmt.Sprintf("this machine reports %q, not %q: the switcher does not act on another model",
s.m.Model(), ModelFamily)
s.mu.Unlock()
fmt.Fprintln(os.Stderr, s.disabled)
return
}
s.AssertVendorSettings(ctx)
events, err := listenPowerSupply(ctx)
s.mu.Lock()
if err != nil {
s.watching = "polling every " + SampleEvery.String() + ": " + err.Error()
} else {
s.watching = "the kernel's power-supply events"
}
s.mu.Unlock()
s.Evaluate(ctx, false)
timer := time.NewTimer(s.interval(err != nil))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case _, open := <-events:
if !open {
events = nil
s.mu.Lock()
s.watching = "polling every " + SampleEvery.String() + ": the uevent socket closed"
s.mu.Unlock()
err = fmt.Errorf("closed")
continue
}
// Settle: an adapter change arrives as several events within a moment.
time.Sleep(time.Second)
s.Evaluate(ctx, false)
case <-timer.C:
s.Evaluate(ctx, true)
timer.Reset(s.interval(err != nil))
}
}
}
// interval is how long to sleep: a CPU sample's period on mains (or with no events to wake on), the
// safety recheck on battery.
func (s *Switcher) interval(polling bool) time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if polling || s.source == nil || s.source.OnAC {
return SampleEvery
}
return SafetyRecheck
}
// AssertVendorSettings puts asusd's own settings where the module wants them, once at start: the
// battery charge limit, and the profiles asusd itself switches to on mains and on battery, so that the
// vendor daemon's own switching and this module's never disagree. Each is read first and set only if
// it differs. A value changed later with a tool stands until the next start.
func (s *Switcher) AssertVendorSettings(ctx context.Context) []string {
var said []string
if limit, err := s.m.ChargeLimit(ctx); err != nil {
said = append(said, "charge limit not read: "+err.Error())
} else if limit != ChargeLimitPercent {
if _, err := s.m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(ChargeLimitPercent)); err != nil {
said = append(said, "charge limit not set: "+vendor("asusctl", err).Error())
} else {
said = append(said, fmt.Sprintf("charge limit %d%% → %d%%", limit, ChargeLimitPercent))
}
} else {
said = append(said, fmt.Sprintf("charge limit already %d%%", ChargeLimitPercent))
}
p, err := s.m.Profile(ctx)
if err != nil {
said = append(said, "asusd's profiles not read: "+err.Error())
} else {
for _, want := range []struct{ flag, have, want, what string }{
{"-a", p.OnAC, ProfileOnAC, "on mains"},
{"-b", p.Battery, ProfileOnBattery, "on battery"},
} {
if want.have == "" || strings.EqualFold(want.have, want.want) {
continue
}
if _, err := s.m.Run(ctx, "asusctl", "profile", "set", want.flag, want.want); err != nil {
said = append(said, "asusd's profile "+want.what+" not set: "+err.Error())
} else {
said = append(said, fmt.Sprintf("asusd's profile %s %s → %s", want.what, want.have, want.want))
}
}
}
s.mu.Lock()
s.asserted = said
s.mu.Unlock()
for _, line := range said {
fmt.Fprintln(os.Stderr, line)
}
return said
}
// SwitcherReport is the switcher's state as the profile-policy tool shows it.
type SwitcherReport struct {
Running bool `json:"running"`
Disabled string `json:"disabled,omitempty"`
Watching string `json:"woken_by,omitempty"`
Source *Source `json:"source,omitempty"`
Decision *Decision `json:"decision,omitempty"`
Load Policy `json:"load"`
LastApplied string `json:"last_applied,omitempty"`
LastAppliedAt *time.Time `json:"last_applied_at,omitempty"`
LastError string `json:"last_error,omitempty"`
HeldUntil *time.Time `json:"held_until,omitempty"`
Held string `json:"held_profile,omitempty"`
AssertedAtStart []string `json:"asserted_at_start,omitempty"`
}
func (s *Switcher) Report() SwitcherReport {
s.mu.Lock()
defer s.mu.Unlock()
r := SwitcherReport{
Running: s.watching != "" && s.disabled == "", Disabled: s.disabled, Watching: s.watching,
Source: s.source, Decision: s.decision, Load: s.policy, LastApplied: s.applied,
LastAppliedAt: when(s.appliedAt), LastError: s.lastError, AssertedAtStart: s.asserted,
}
if s.now().Before(s.holdUntil) {
r.HeldUntil, r.Held = when(s.holdUntil), s.holdOf
}
return r
}
// when is a time for a report: absent rather than the zero time.
func when(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
@@ -0,0 +1,190 @@
package main
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
func TestTheLoadMustBeSustainedToBoostAndToRelaxAndBetweenTheLinesNothingMoves(t *testing.T) {
var p Policy
at := time.Now()
mains := Source{OnAC: true, Reason: "ACAD (Mains) is online"}
for i := 0; i < SustainSamples-1; i++ {
p.Observe(90, at)
}
if p.Decide(mains).Profile != ProfileOnAC {
t.Fatal("boosted before the load was sustained")
}
p.Observe(35, at) // between the lines breaks the run
p.Observe(90, at)
if p.Boosted {
t.Fatal("a broken run still counted")
}
for i := 0; i < SustainSamples; i++ {
p.Observe(CPUHighPercent, at)
}
if d := p.Decide(mains); d.Profile != ProfileUnderLoad {
t.Fatalf("%+v", d)
}
p.Observe(35, at)
if !p.Boosted {
t.Fatal("load between the lines relaxed the boost")
}
for i := 0; i < RelaxSamples; i++ {
p.Observe(5, at)
}
if p.Decide(mains).Profile != ProfileOnAC {
t.Fatal("did not relax after a sustained low")
}
p.Boosted = true
if d := p.Decide(Source{OnAC: false, Reason: "BAT1 is discharging"}); d.Profile != ProfileOnBattery {
t.Fatalf("battery: %+v", d)
}
}
func TestIOWaitIsIdle(t *testing.T) {
a, err := ParseProcStat("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3\n")
if err != nil {
t.Fatal(err)
}
b, _ := ParseProcStat("cpu 150 0 150 700 200 0 0 0 0 0\n")
busy, ok := Busy(a, b)
if !ok || busy != 50 {
t.Fatalf("%v %v", busy, ok)
}
if _, ok := Busy(b, b); ok {
t.Fatal("no time passed and a load was answered")
}
if _, err := ParseProcStat("intr 1 2"); err == nil {
t.Fatal("a file without the cpu line was read")
}
}
func switcherOn(t *testing.T) (*fake, *Switcher, *[]map[string]any) {
f := newFake(t)
f.onMains()
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0\n")
var emitted []map[string]any
sw := NewSwitcher(f.machine(), func(_ string, body any) error {
emitted = append(emitted, body.(map[string]any))
return nil
})
return f, sw, &emitted
}
func TestStartingIsNotAReasonToSwitch(t *testing.T) {
f, sw, emitted := switcherOn(t)
sw.Evaluate(context.Background(), false)
if calls := f.callsLike("asusctl profile set"); len(calls) != 0 || len(*emitted) != 0 {
t.Fatalf("the first decision acted: %v %v", calls, *emitted)
}
}
func TestAChangeOfPowerSourceSwitchesOnceAndPublishesIt(t *testing.T) {
f, sw, emitted := switcherOn(t)
ctx := context.Background()
sw.Evaluate(ctx, false)
f.onBattery()
sw.Evaluate(ctx, false)
sw.Evaluate(ctx, true) // nothing changed: nothing done, and on battery nothing sampled
if calls := f.callsLike("asusctl profile set"); len(calls) != 1 || calls[0] != "asusctl profile set Quiet" {
t.Fatalf("%v", calls)
}
if len(*emitted) != 1 || (*emitted)[0]["profile"] != "Quiet" || (*emitted)[0]["from"] != "Balanced" {
t.Fatalf("%v", *emitted)
}
f.onMains()
sw.Evaluate(ctx, false)
if calls := f.callsLike("asusctl profile set"); len(calls) != 2 || calls[1] != "asusctl profile set Balanced" {
t.Fatalf("%v", calls)
}
}
func TestSustainedLoadOnMainsBoostsFromSamples(t *testing.T) {
f, sw, _ := switcherOn(t)
ctx := context.Background()
sw.Evaluate(ctx, false)
var user int
for i := 1; i <= SustainSamples; i++ {
user += 90
f.file("/proc/stat", "cpu "+itoa(user)+" 0 0 "+itoa(i*10)+" 0 0 0 0\n")
sw.Evaluate(ctx, true)
}
if !f.called("asusctl profile set Performance") {
t.Fatalf("%v", f.calls)
}
}
func TestAHoldKeepsTheProfileUntilItEndsAndAFailureIsTriedAgain(t *testing.T) {
f, sw, _ := switcherOn(t)
ctx := context.Background()
now := time.Now()
sw.now = func() time.Time { return now }
sw.Evaluate(ctx, false)
f.onBattery()
sw.Evaluate(ctx, false) // source change ends any hold; switches to Quiet
sw.Hold("Performance", time.Hour)
f.fails["asusctl profile set Balanced"] = errors.New("asusd is restarting")
f.onMains()
sw.Evaluate(ctx, false) // a change of source: the hold ends, the switch is attempted and fails
if r := sw.Report(); r.LastError == "" || r.Held != "" {
t.Fatalf("%+v", r)
}
delete(f.fails, "asusctl profile set Balanced")
sw.Evaluate(ctx, false)
if r := sw.Report(); r.LastApplied != "Balanced" || r.LastError != "" {
t.Fatalf("not tried again: %+v", r)
}
// A hold on the same source keeps a new decision from acting until it ends.
sw.Hold("Quiet", time.Hour)
sw.policy.Boosted = true
sw.Evaluate(ctx, false)
if f.called("asusctl profile set Performance") {
t.Fatal("switched during a hold")
}
now = now.Add(2 * time.Hour)
sw.Evaluate(ctx, false)
if !f.called("asusctl profile set Performance") {
t.Fatal("did not act once the hold ended")
}
}
func TestAsusdsSettingsAreSetOnlyWhereTheyDiffer(t *testing.T) {
f := newFake(t)
f.answers["asusctl battery info"] = "Current battery charge limit: 100%\n"
f.answers["asusctl profile get"] = "Active profile: Balanced\nAC profile Performance\nBattery profile Quiet\n"
sw := NewSwitcher(f.machine(), nil)
sw.AssertVendorSettings(context.Background())
if !f.called("asusctl battery limit 80") || !f.called("asusctl profile set -a Balanced") || f.called("asusctl profile set -b Quiet") {
t.Fatalf("%v", f.calls)
}
}
func TestTheSwitcherDoesNotActOnAnotherModel(t *testing.T) {
f := newFake(t)
f.file("/sys/class/dmi/id/product_family", "ROG Strix\n")
sw := NewSwitcher(f.machine(), nil)
done := make(chan struct{})
go func() { sw.Run(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the switcher ran on another model")
}
if r := sw.Report(); r.Running || r.Disabled == "" || len(f.calls) != 0 {
t.Fatalf("%+v %v", r, f.calls)
}
}
func TestOnlyPowerSupplyUeventsWake(t *testing.T) {
yes := []byte("change@/devices/LNXSYSTM:00/ACPI0003:00/power_supply/ACAD\x00ACTION=change\x00SUBSYSTEM=power_supply\x00POWER_SUPPLY_ONLINE=0\x00")
no := []byte("change@/devices/virtual/net/wlan0\x00ACTION=change\x00SUBSYSTEM=net\x00")
if !powerSupplyEvent(yes) || powerSupplyEvent(no) {
t.Fatal("the uevent filter")
}
}
func itoa(n int) string { return strconv.Itoa(n) }
@@ -0,0 +1,168 @@
package main
import (
"context"
"path"
"sort"
"strconv"
"strings"
)
// Sensor is one temperature, fan or power reading from hwmon.
type Sensor struct {
Chip string `json:"chip"`
Label string `json:"label"`
Value float64 `json:"value"`
}
// DGPU is the discrete GPU as the PCI bus and its driver see it.
type DGPU struct {
Address string `json:"pci_address"`
Runtime string `json:"runtime_status"`
Name string `json:"name,omitempty"`
TempC *float64 `json:"temp_c,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
PState string `json:"pstate,omitempty"`
Note string `json:"note,omitempty"`
}
// hwmon reads every hwmon reading of one kind: "temp" (°C), "fan" (RPM) or "power" (W).
func (m *Machine) hwmon(kind string) []Sensor {
var out []Sensor
for _, dir := range m.glob("/sys/class/hwmon/hwmon*") {
chip := m.read(dir + "/name")
inputs := m.glob(dir + "/" + kind + "*_input")
if kind == "power" {
inputs = append(inputs, m.glob(dir+"/power*_average")...)
}
for _, in := range inputs {
v, ok := m.readInt(in)
if !ok {
continue
}
base := path.Base(in)
stem := base[:strings.LastIndex(base, "_")]
label := m.read(dir + "/" + stem + "_label")
if label == "" {
label = base
} else if strings.HasSuffix(base, "_average") {
label += " (average)"
}
value := float64(v)
switch kind {
case "temp":
value = round1(value / 1000)
case "power":
value = round1(value / 1e6)
}
out = append(out, Sensor{Chip: chip, Label: label, Value: value})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Chip != out[j].Chip {
return out[i].Chip < out[j].Chip
}
return out[i].Label < out[j].Label
})
return out
}
// dgpu finds the NVIDIA display controller and, only when it is already awake, asks its driver for
// its temperature and draw. **Asking wakes it**: nvidia-smi brings a suspended GPU out of D3, which
// is the power a reading of power draw should not cost.
func (m *Machine) dgpu(ctx context.Context) *DGPU {
for _, dir := range m.glob("/sys/bus/pci/devices/*") {
if m.read(dir+"/vendor") != "0x10de" || !strings.HasPrefix(m.read(dir+"/class"), "0x03") {
continue
}
g := &DGPU{Address: path.Base(dir), Runtime: m.read(dir + "/power/runtime_status")}
if g.Runtime != "active" {
g.Note = "the discrete GPU is " + g.Runtime + "; not woken to be read"
return g
}
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=name,temperature.gpu,power.draw,pstate", "--format=csv,noheader,nounits")
if err != nil {
g.Note = "nvidia-smi: " + err.Error()
return g
}
f := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ",")
if len(f) >= 4 {
g.Name = strings.TrimSpace(f[0])
if t, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64); err == nil {
g.TempC = &t
}
if w, err := strconv.ParseFloat(strings.TrimSpace(f[2]), 64); err == nil {
w = round1(w)
g.PowerW = &w
}
g.PState = strings.TrimSpace(f[3])
}
return g
}
return nil
}
// Thermals is what the thermals tool answers.
type Thermals struct {
Temperatures []Sensor `json:"temperatures_c"`
Fans []Sensor `json:"fans_rpm"`
DGPU *DGPU `json:"dgpu,omitempty"`
Profile string `json:"platform_profile,omitempty"`
Hottest *Sensor `json:"hottest,omitempty"`
}
func (m *Machine) Thermals(ctx context.Context) Thermals {
t := Thermals{Temperatures: m.hwmon("temp"), Fans: m.hwmon("fan"), DGPU: m.dgpu(ctx),
Profile: m.read("/sys/firmware/acpi/platform_profile")}
if t.Temperatures == nil {
t.Temperatures = []Sensor{}
}
if t.Fans == nil {
t.Fans = []Sensor{}
}
for i := range t.Temperatures {
if t.Hottest == nil || t.Temperatures[i].Value > t.Hottest.Value {
h := t.Temperatures[i]
t.Hottest = &h
}
}
return t
}
// PowerDraw is what the power-draw tool answers.
type PowerDraw struct {
Source Source `json:"source"`
BatteryW *float64 `json:"battery_w,omitempty"`
BatteryFlow string `json:"battery_flow,omitempty"`
CPUPackageW *float64 `json:"apu_package_w,omitempty"`
DGPU *DGPU `json:"dgpu,omitempty"`
Note string `json:"note"`
}
func (m *Machine) PowerDraw(ctx context.Context) PowerDraw {
p := PowerDraw{Source: PowerSource(m.Supplies()), DGPU: m.dgpu(ctx),
Note: "on battery, battery_w is what the whole machine draws; on mains it is only what the battery takes or gives"}
for _, b := range m.Batteries() {
if b.PowerW != nil {
w := *b.PowerW
p.BatteryW = &w
switch strings.ToLower(b.Status) {
case "discharging":
p.BatteryFlow = "discharging"
case "charging":
p.BatteryFlow = "charging"
default:
p.BatteryFlow = strings.ToLower(b.Status)
}
break
}
}
// The integrated GPU's hwmon reports the whole APU's package power (PPT) on this model.
for _, s := range m.hwmon("power") {
if s.Chip == "amdgpu" && s.Label == "PPT" {
w := s.Value
p.CPUPackageW = &w
}
}
return p
}
@@ -0,0 +1,315 @@
package main
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools is the module's tools, over one machine and its switcher.
func Tools(m *Machine, sw *Switcher) []stdio.Tool {
ctx := context.Background
return []stdio.Tool{
{
Name: "zephyrus_brightness",
Description: "Read or set the internal panel's and the keyboard's backlight. With no argument, reads both. " +
"panel is a percentage (40) or a step (+5, -10), never below 1 %; keyboard is off, low, med, high, 0-3, + or -.",
Input: map[string]any{
"panel": map[string]any{"type": "string", "description": "percentage or step, e.g. 40, +5, -10"},
"keyboard": map[string]any{"type": "string", "description": "off, low, med, high, 0-3, + or -"},
},
Run: func(args map[string]any) (any, error) {
out := map[string]any{}
if p := str(args, "panel"); p != "" {
got, err := m.SetPanel(ctx(), p)
if err != nil {
return nil, err
}
out["panel"] = got
} else if got, err := m.PanelBrightness(); err == nil {
out["panel"] = got
} else {
out["panel_error"] = err.Error()
}
if k := str(args, "keyboard"); k != "" {
got, err := m.SetKeyboard(ctx(), k)
if err != nil {
return nil, err
}
out["keyboard"] = got
} else if got, err := m.KeyboardBrightness(); err == nil {
out["keyboard"] = got
} else {
out["keyboard_error"] = err.Error()
}
return out, nil
},
},
{
Name: "zephyrus_battery",
Description: "The battery: charge, energy, health (full against design), cycles, the charge limit, the power it gives or takes, and time left when discharging.",
Run: func(map[string]any) (any, error) {
return map[string]any{"source": PowerSource(m.Supplies()), "batteries": orEmpty(m.Batteries())}, nil
},
},
{
Name: "zephyrus_charge_limit",
Description: fmt.Sprintf("Read or set the battery charge limit through asusd. limit is 20-100; oneshot charges to full once "+
"and goes back to the limit. The module asserts %d %% again when its process next starts.", ChargeLimitPercent),
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "20-100"},
"oneshot": map[string]any{"type": "boolean", "description": "charge to full once, keeping the limit"},
},
Run: func(args map[string]any) (any, error) { return ChargeLimitTool(ctx(), m, args) },
},
{
Name: "zephyrus_gpu_mode",
Description: "Read or set the hybrid GPU's mode through supergfxd: Integrated, Hybrid or AsusMuxDgpu as the machine supports. " +
"Answers the mode, the discrete GPU's power state, any pending mode and the action it waits for (a logout, a reboot), " +
"and whether asusd will switch it again on the next change of power source.",
Input: map[string]any{
"mode": map[string]any{"type": "string", "description": "a supported mode, e.g. Integrated or Hybrid"},
},
Run: func(args map[string]any) (any, error) { return GPUModeTool(ctx(), m, args) },
},
{
Name: "zephyrus_profile",
Description: "Read or set the platform profile (Quiet, Balanced, Performance) through asusd. A profile set here is held " +
fmt.Sprintf("for hold_minutes (default %d, 0 for none) before the module's switcher may move it; a change of power source ends the hold.", int(DefaultHold.Minutes())),
Input: map[string]any{
"profile": map[string]any{"type": "string", "enum": Profiles},
"hold_minutes": map[string]any{"type": "integer", "description": "how long the switcher leaves it (default 60, at most 1440)"},
},
Run: func(args map[string]any) (any, error) { return ProfileTool(ctx(), m, sw, args) },
},
{
Name: "zephyrus_thermals",
Description: "Every temperature and fan the hardware reports (°C, RPM), the hottest, the platform profile, and the discrete GPU's temperature when it is awake (it is not woken to be read).",
Run: func(map[string]any) (any, error) { return m.Thermals(ctx()), nil },
},
{
Name: "zephyrus_power_draw",
Description: "What the machine draws: the battery's flow in watts, the APU's package power, the discrete GPU's draw when awake, and the power source with the reason it was decided.",
Run: func(map[string]any) (any, error) { return m.PowerDraw(ctx()), nil },
},
{
Name: "zephyrus_profile_policy",
Description: "What the module's profile switcher would choose now and why: the power source, recent CPU load against the thresholds, " +
"the decision, the profile in force, any hold, what woke it, and what it asserted in asusd at start.",
Run: func(map[string]any) (any, error) { return PolicyTool(ctx(), m, sw), nil },
},
{
Name: "zephyrus_fan_curves",
Description: "The fan curves asusd holds for each profile (or one profile): per fan, eight points of temperature and duty.",
Input: map[string]any{
"profile": map[string]any{"type": "string", "enum": Profiles},
},
Run: func(args map[string]any) (any, error) { return FanCurvesTool(ctx(), m, args) },
},
{
Name: "zephyrus_keys",
Description: "Every custom key on the laptop: each triggerhappy trigger (the vendor keys that reach no X client), the module's own i3 lines " +
"(keys the firmware sends as ordinary presses, and what starts with the session), and the keys the firmware handles itself — " +
"what each runs and the file it is defined in. Warns when one key is bound in two trigger files, which fires it twice.",
Run: func(map[string]any) (any, error) { return m.Keys(), nil },
},
{
Name: "zephyrus_check",
Description: "Check what this module expects of the machine: the model, the vendor packages and daemons, the NVIDIA options in force, " +
"suspend and resume, the charge limit, one authority each over the profile and the GPU mode, and the predecessor's leftovers. Says what it did not check.",
Run: func(map[string]any) (any, error) { return m.Check(ctx(), sw), nil },
},
}
}
// ChargeLimitTool reads or sets the limit.
func ChargeLimitTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
out := map[string]any{"module_limit_percent": ChargeLimitPercent}
if v, given := args["limit"]; given && v != nil {
n, err := whole(v, "limit")
if err != nil {
return nil, err
}
if n < 20 || n > 100 {
return nil, fmt.Errorf("limit %d is outside 20-100", n)
}
if _, err := m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(n)); err != nil {
return nil, vendor("asusctl", err)
}
out["set"] = n
}
if b, _ := args["oneshot"].(bool); b {
if _, err := m.Run(ctx, "asusctl", "battery", "oneshot"); err != nil {
return nil, vendor("asusctl", err)
}
out["oneshot"] = "charging to full once; the limit returns after"
}
if n, err := m.ChargeLimit(ctx); err == nil {
out["asusd_limit_percent"] = n
} else {
out["asusd_error"] = err.Error()
}
for _, b := range m.Batteries() {
if b.LimitPercent != nil {
out["kernel_limit_percent"] = *b.LimitPercent
}
}
return out, nil
}
// GPUModeTool reads or sets the GPU mode.
func GPUModeTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
g, err := m.GPU(ctx)
if err != nil {
return nil, err
}
out := map[string]any{}
if want := str(args, "mode"); want != "" {
mode := ""
for _, s := range g.Supported {
if strings.EqualFold(s, want) {
mode = s
}
}
if mode == "" {
return nil, fmt.Errorf("mode %q is not one this machine supports (%s)", want, strings.Join(g.Supported, ", "))
}
said, err := m.Run(ctx, "supergfxctl", "-m", mode)
if err != nil {
return nil, vendor("supergfxctl", err)
}
out["requested"] = mode
if s := strings.TrimSpace(said); s != "" {
out["supergfxctl_said"] = s
}
if g, err = m.GPU(ctx); err != nil {
return nil, err
}
}
out["gpu"] = g
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
out["asusd_switches_it"] = map[string]string{"on_ac": c.ACCommand, "on_battery": c.BatteryCommand,
"note": "asusd runs these on every change of power source, so a mode set here lasts until the next one"}
}
return out, nil
}
// ProfileTool reads or sets the profile.
func ProfileTool(ctx context.Context, m *Machine, sw *Switcher, args map[string]any) (any, error) {
out := map[string]any{}
if want := str(args, "profile"); want != "" {
p, err := canonicalProfile(want)
if err != nil {
return nil, err
}
hold := DefaultHold
if v, given := args["hold_minutes"]; given && v != nil {
n, err := whole(v, "hold_minutes")
if err != nil {
return nil, err
}
if n < 0 {
return nil, fmt.Errorf("hold_minutes must not be negative")
}
hold = time.Duration(min(n, 1440)) * time.Minute
}
if err := m.SetProfile(ctx, p); err != nil {
return nil, err
}
out["set"] = p
if sw != nil {
if until := sw.Hold(p, hold); !until.IsZero() {
out["held_until"] = until
}
}
}
state, err := m.Profile(ctx)
if err != nil {
return nil, err
}
out["profile"] = state
return out, nil
}
// PolicyTool reports the switcher and, independently of it, what the policy says now.
func PolicyTool(ctx context.Context, m *Machine, sw *Switcher) any {
out := map[string]any{
"thresholds": map[string]any{
"on_battery": ProfileOnBattery, "on_ac": ProfileOnAC, "under_load": ProfileUnderLoad,
"cpu_high_percent": CPUHighPercent, "cpu_low_percent": CPULowPercent,
"sample_every": SampleEvery.String(), "sustain_samples": SustainSamples, "relax_samples": RelaxSamples,
"set_by": "constants until settings exist (novox/hq issue 168)",
},
}
if sw != nil {
out["switcher"] = sw.Report()
} else {
var p Policy
out["decision_now"] = p.Decide(PowerSource(m.Supplies()))
}
if state, err := m.Profile(ctx); err == nil {
out["in_force"] = state
} else {
out["in_force_error"] = err.Error()
}
if pid, ok := m.predecessorProcess("auto-profile"); ok {
out["second_switcher"] = fmt.Sprintf("the predecessor's auto-profile still runs (pid %d) and overrides this every five seconds", pid)
}
return out
}
// FanCurvesTool reads asusd's fan curves.
func FanCurvesTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
profiles := Profiles
if want := str(args, "profile"); want != "" {
p, err := canonicalProfile(want)
if err != nil {
return nil, err
}
profiles = []string{p}
}
out := map[string]any{}
for _, p := range profiles {
said, err := m.Run(ctx, "asusctl", "fan-curve", "--mod-profile", strings.ToLower(p))
if err != nil {
return nil, vendor("asusctl", err)
}
out[p] = orEmpty(ParseFanCurves(said))
}
return out, nil
}
func str(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
// whole is an integer argument given as a JSON number or a numeric string.
func whole(v any, key string) (int, error) {
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, fmt.Errorf("%s must be a whole number", key)
}
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"context"
"fmt"
"syscall"
)
// The kernel announces every change of a power supply — an adapter plugged or pulled, a battery
// starting or stopping to discharge — as a uevent on a netlink socket that any account may listen
// on. That is the event the switcher reacts to: no daemon, no bus client, no polling.
//
// upower re-announces the same changes on the system bus, and listening there would need a D-Bus
// client in the bundle; udev's re-broadcast (netlink group 2) carries a libudev header. The kernel's
// own group (1) is the source both of them read.
// powerSupplyEvent says whether a uevent is about a power supply.
func powerSupplyEvent(msg []byte) bool {
for _, field := range bytes.Split(msg, []byte{0}) {
if bytes.Equal(field, []byte("SUBSYSTEM=power_supply")) {
return true
}
}
return false
}
// listenPowerSupply opens the kernel's uevent socket and sends on the channel for each power-supply
// event, never blocking: a burst of events is one wake-up. It stops when ctx ends.
func listenPowerSupply(ctx context.Context) (<-chan struct{}, error) {
fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, syscall.NETLINK_KOBJECT_UEVENT)
if err != nil {
return nil, fmt.Errorf("opening the kernel's uevent socket: %w", err)
}
if err := syscall.Bind(fd, &syscall.SockaddrNetlink{Family: syscall.AF_NETLINK, Groups: 1}); err != nil {
syscall.Close(fd)
return nil, fmt.Errorf("joining the kernel's uevent group: %w", err)
}
events := make(chan struct{}, 1)
go func() {
<-ctx.Done()
syscall.Close(fd)
}()
go func() {
defer close(events)
buf := make([]byte, 64*1024)
for {
n, _, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
if err == syscall.EINTR || err == syscall.ENOBUFS {
// ENOBUFS: events were dropped. Treat it as one, since a dropped one may have
// been the adapter.
if err == syscall.ENOBUFS {
select {
case events <- struct{}{}:
default:
}
}
continue
}
return
}
if powerSupplyEvent(buf[:n]) {
select {
case events <- struct{}{}:
default:
}
}
}
}()
return events, nil
}
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also
# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group
# through the module's udev rule, so the vendor-key trigger needs no root.
set -u
STEP=5
dev=""
for d in /sys/class/backlight/*; do
[ -e "$d" ] || continue
case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac
done
if [ -z "$dev" ]; then
for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done
fi
[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; }
cur=$(cat "$dev/brightness")
max=$(cat "$dev/max_brightness")
pct=$(( cur * 100 / max ))
case "${1:-}" in
+|up|Up) pct=$(( pct + STEP )) ;;
-|down|Down) pct=$(( pct - STEP )) ;;
''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;;
*) pct=$1 ;;
esac
(( pct < 1 )) && pct=1
(( pct > 100 )) && pct=100
new=$(( max * pct / 100 ))
(( new < 1 )) && new=1
printf '%s' "$new" >"$dev/brightness" || exit 1
exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# zephyrus-display primary | order — the laptop's internal panel among the outputs.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# primary makes the internal panel X's primary output, where the bars' tray goes.
# order the display key (Fn+F9, which the firmware sends as Super+P): odd workspaces to the
# internal panel, even ones to the first external output, or all to the panel when it
# is alone. The predecessor's orden-workspaces.
#
# The panel is found, not named: the connected output whose name starts with eDP. The predecessor
# wrote eDP-1, which is what this model calls it today and not a promise.
set -u
here=$(dirname "$0")
panel=$(xrandr --query 2>/dev/null | awk '$2 == "connected" && $1 ~ /^eDP/ { print $1; exit }')
[ -n "$panel" ] || { echo "zephyrus-display: no internal panel connected" >&2; exit 1; }
case "${1:-}" in
primary)
exec xrandr --output "$panel" --primary
;;
order)
external=$(xrandr --listmonitors 2>/dev/null | awk -v p="$panel" 'NR > 1 && $NF != p { print $NF; exit }')
[ -n "$external" ] || external=$panel
# i3's workspace list, one object per workspace; num and output read from each. No jq: the
# fields are flat strings and numbers, and rect, the only nested value, holds neither name.
i3-msg -t get_workspaces 2>/dev/null | sed 's/},{"id"/}\n{"id"/g' |
while read -r ws; do
num=$(printf '%s' "$ws" | grep -o '"num":-\?[0-9]*' | cut -d: -f2)
out=$(printf '%s' "$ws" | grep -o '"output":"[^"]*"' | cut -d'"' -f4)
[ -n "$num" ] && [ "$num" -ge 0 ] || continue
if [ $((num % 2)) -eq 0 ]; then dest=$external; else dest=$panel; fi
[ "$out" = "$dest" ] && continue
i3-msg "workspace number $num; move workspace to output $dest" >/dev/null
done
if [ "$external" = "$panel" ]; then
"$here/zephyrus-notify" 7780 "Workspaces: all on the panel"
else
"$here/zephyrus-notify" 7780 "Workspaces: odd on the panel, even on $external"
fi
;;
*) echo "usage: zephyrus-display primary | order" >&2; exit 2 ;;
esac
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# zephyrus-kbd-notify — shows the keyboard backlight's level when it changes. The level itself is set
# by the firmware and asusd (Fn+F2/F3), which tell UPower; this only listens and notifies. Started once
# per session from the module's i3 fragment. Shipped by the mesh's asus-zephyrus-g14 module.
#
# One instance per session: i3 runs its `exec` lines again on an in-place restart, and the
# predecessor's listener ran twice after one, showing every change twice.
set -u
here=$(dirname "$0")
lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-kbd-notify.lock"
exec 9>"$lock"
flock -n 9 || exit 0
levels=(Off Low Med High)
dbus-monitor --system "type='signal',interface='org.freedesktop.UPower.KbdBacklight',member='BrightnessChanged'" 2>/dev/null |
while read -r line; do
if [[ $line =~ int32\ ([0-9]+) ]]; then
v=${BASH_REMATCH[1]}
"$here/zephyrus-notify" 5556 "Keyboard: ${levels[$v]:-$v}"
fi
done
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# zephyrus-media play-pause | next | previous — the media keys, through MPRIS (playerctl), with a short
# notification of what happened. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# From the predecessor's media-control, kept: the lock against a double fire (the vendor keys can
# repeat), and the notification. Dropped: its fallback to a media server's local API, which needed a
# token from a file of secrets (novox/hq research 027 question 2). A player that speaks MPRIS is
# reached; one that does not says so.
set -u
here=$(dirname "$0")
action=${1:-play-pause}
case "$action" in play-pause | next | previous) ;; *) echo "usage: zephyrus-media play-pause | next | previous" >&2; exit 2 ;; esac
lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-media.lock"
exec 9>"$lock"
flock -n 9 || exit 0
if ! playerctl status >/dev/null 2>&1; then
"$here/zephyrus-notify" 7777 "Media: no player"
exit 0
fi
playerctl "$action" 2>/dev/null
if [ "$action" = play-pause ]; then
sleep 0.2
[ "$(playerctl status 2>/dev/null)" = Playing ] && label=Playing || label=Paused
else
label=${action^}
fi
"$here/zephyrus-notify" 7777 "Media: $label"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the
# same ID, through the session's notification service on its bus. busctl is the service manager's
# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module.
set -u
id=${1:-0}
summary=${2:-}
uid=$(id -u)
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \
org.freedesktop.Notifications Notify susssasa{sv}i \
asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside
# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of
# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to
# a named person with a hard-coded user id and display, and sourced a file of secrets on the way.
# Here the account is whoever runs it, the bus is that account's, and the display is the one the
# account's own session uses. Nothing is sourced.
set -u
uid=$(id -u)
export XDG_RUNTIME_DIR="/run/user/${uid}"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
home=$(getent passwd "$uid" | cut -d: -f6)
[ -n "$home" ] && export HOME="$home"
# The session's own window manager says best which display and authority the session uses: read
# them from its environment, as the desktop modules' session finder does. Then logind, then any
# process of this account that has a display.
from_environ() {
env=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null) || return 1
d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1)
[ -n "$d" ] || return 1
export DISPLAY="$d"
a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1)
[ -n "$a" ] && export XAUTHORITY="$a"
return 0
}
if [ -z "${DISPLAY:-}" ]; then
for pid in $(pgrep -xu "$uid" i3 2>/dev/null); do
from_environ "$pid" && break
done
fi
if [ -z "${DISPLAY:-}" ]; then
for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do
d=$(loginctl show-session "$s" -p Display --value 2>/dev/null)
if [ -n "$d" ]; then export DISPLAY="$d"; break; fi
done
fi
if [ -z "${DISPLAY:-}" ]; then
for pid in $(pgrep -u "$uid" 2>/dev/null); do
from_environ "$pid" && break
done
fi
: "${XAUTHORITY:=${HOME}/.Xauthority}"
export XAUTHORITY
exec "$@"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf),
# which X applies every time the device appears — after a resume too, which is what the predecessor's
# sleep hook existed for. This is the manual form, bound to the touchpad key.
set -u
here=$(dirname "$0")
name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad')
[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; }
x() { "$here/zephyrus-session" xinput "$@"; }
case "${1:-reset}" in
reset)
x set-prop "$name" "libinput Tapping Enabled" 1
x set-prop "$name" "libinput Natural Scrolling Enabled" 1
x set-prop "$name" "libinput Accel Speed" 0.15
x enable "$name"
"$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset"
;;
toggle)
if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then
x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off"
else
x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on"
fi
;;
*) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;;
esac
+5
View File
@@ -0,0 +1,5 @@
module asuszephyrusg14
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+301
View File
@@ -0,0 +1,301 @@
{
"module": "asus-zephyrus-g14",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"requires": [
"x11-display"
],
"emits": [
"profile.switched"
],
"tools": [
"zephyrus_brightness",
"zephyrus_battery",
"zephyrus_charge_limit",
"zephyrus_gpu_mode",
"zephyrus_profile",
"zephyrus_thermals",
"zephyrus_power_draw",
"zephyrus_profile_policy",
"zephyrus_fan_curves",
"zephyrus_keys",
"zephyrus_check"
],
"resources": [
{
"id": "asusctl",
"type": "package",
"package": "asusctl"
},
{
"id": "playerctl",
"type": "package",
"package": "playerctl"
},
{
"id": "asusd",
"type": "service",
"unit": "asusd.service",
"state": "running"
},
{
"id": "supergfxd",
"type": "service",
"unit": "supergfxd.service",
"state": "running",
"boot": "enabled"
},
{
"id": "scripts",
"type": "archive",
"path": "/usr/local/lib/asus-zephyrus-g14",
"artifact": "scripts"
},
{
"id": "nvidia-options",
"type": "file",
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
},
{
"id": "video-options",
"type": "file",
"path": "/etc/modprobe.d/video-brightness-switch.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
},
{
"id": "suspend-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend.service.d",
"mode": "0755"
},
{
"id": "nvidia-on-suspend",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
},
{
"id": "hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-hibernate.service.d",
"mode": "0755"
},
{
"id": "nvidia-on-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
},
{
"id": "suspend-then-hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
"mode": "0755"
},
{
"id": "nvidia-on-suspend-then-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
},
{
"id": "powerd-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/nvidia-powerd.service.d",
"mode": "0755"
},
{
"id": "powerd-opt-in",
"type": "file",
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
},
{
"id": "logind-drop-ins",
"type": "directory",
"path": "/etc/systemd/logind.conf.d",
"mode": "0755"
},
{
"id": "logind-power",
"type": "file",
"path": "/etc/systemd/logind.conf.d/power.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The power key and the lid suspend, on battery, on mains and docked alike.\n[Login]\nHandlePowerKey=suspend\nHandleLidSwitch=suspend\nHandleLidSwitchExternalPower=suspend\nHandleLidSwitchDocked=suspend\n"
},
{
"id": "logind",
"type": "service",
"unit": "systemd-logind.service",
"reload-on": [
"logind-power"
]
},
{
"id": "backlight-rule",
"type": "file",
"path": "/etc/udev/rules.d/90-backlight.rules",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
},
{
"id": "udev",
"type": "service",
"unit": "systemd-udevd.service",
"reload-on": [
"backlight-rule"
]
},
{
"id": "triggerhappy-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/triggerhappy.service.d",
"mode": "0755"
},
{
"id": "triggerhappy-as-account",
"type": "file",
"path": "/etc/systemd/system/triggerhappy.service.d/asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# triggerhappy runs the vendor-key triggers as the operator's account, not as root: it opens the input\n# devices first and then drops to the account with its groups (input, video), so the triggers reach the\n# account's own session bus and the panel through the video group, with no su and no hard-coded user.\n[Service]\nExecStart=\nExecStart=/usr/bin/thd --triggers /etc/triggerhappy/triggers.d/ --socket /run/thd.socket --user ${machine:account} --deviceglob /dev/input/event*\n"
},
{
"id": "vendor-keys",
"type": "file",
"path": "/etc/triggerhappy/triggers.d/asus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ROG Zephyrus G14's vendor keys, which reach no X client. Run as the operator's account (see the\n# module's drop-in on triggerhappy.service).\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"id": "triggerhappy",
"type": "service",
"unit": "triggerhappy.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"triggerhappy-as-account",
"vendor-keys",
"scripts",
"touchpad-on-resume",
"touchpad-after-suspend",
"touchpad-after-hibernate",
"touchpad-after-suspend-then-hibernate"
]
},
{
"id": "upower-package",
"type": "package",
"package": "upower"
},
{
"id": "upower-drop-ins",
"type": "directory",
"path": "/etc/UPower/UPower.conf.d",
"mode": "0755"
},
{
"id": "low-battery",
"type": "file",
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
},
{
"id": "upower",
"type": "service",
"unit": "upower.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"low-battery"
]
},
{
"id": "xorg-drop-ins",
"type": "directory",
"path": "/etc/X11/xorg.conf.d",
"mode": "0755"
},
{
"id": "touchpad",
"type": "file",
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
},
{
"id": "touchpad-on-resume",
"type": "file",
"path": "/etc/systemd/system/asus-zephyrus-g14-touchpad-resume.service",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# After a resume, the touchpad's settings once more in the operator's session. X applies the module's\n# input class whenever the device appears, which covers a resume that initialises the device again;\n# this covers one that does not, as the predecessor's sleep hook did. Started by the sleep services\n# themselves (their asus-zephyrus-g14-touchpad.conf drop-ins), never enabled, and ordered after them,\n# so it runs once the machine is awake.\n[Unit]\nDescription=Touchpad settings after resume (asus-zephyrus-g14)\nAfter=systemd-suspend.service systemd-hibernate.service systemd-suspend-then-hibernate.service\n\n[Service]\nType=oneshot\nUser=${machine:account}\nExecStartPre=/bin/sleep 2\nExecStart=/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"id": "touchpad-after-suspend",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings again once the machine is awake (asus-zephyrus-g14-touchpad-resume.service).\n[Unit]\nWants=asus-zephyrus-g14-touchpad-resume.service\n"
},
{
"id": "touchpad-after-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings again once the machine is awake (asus-zephyrus-g14-touchpad-resume.service).\n[Unit]\nWants=asus-zephyrus-g14-touchpad-resume.service\n"
},
{
"id": "touchpad-after-suspend-then-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings again once the machine is awake (asus-zephyrus-g14-touchpad-resume.service).\n[Unit]\nWants=asus-zephyrus-g14-touchpad-resume.service\n"
},
{
"id": "i3-vendor-keys",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/10-asus.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"id": "i3-model",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/20-g14.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/zephyrus",
"binary": "zephyrus",
"loads": [
"zephyrus"
]
},
{
"name": "scripts",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -362,3 +362,40 @@ func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
t.Fatal("another node's key changed this one")
}
}
// An API key goes to the manager sealed to its key, never in the clear, and its file is gone afterwards.
func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
p, _ := node(t, "laptop")
manager, _ := GenerateKeyPair()
file := filepath.Join(p.Home, "api-key")
writeFile(t, file, "sk-ant-api03-secret\n")
var sent []string
ask := func(address string, args any) (json.RawMessage, error) {
raw, _ := json.Marshal(args)
sent = append(sent, address+" "+string(raw))
switch address {
case "seat:anthropic-licence-manager.public-key":
return json.Marshal(map[string]any{"public_key": manager.PublicKey})
case "seat:anthropic-licence-manager.adopt":
box := args.(map[string]any)["sealed"].(SealedBox)
if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret" {
t.Fatalf("the manager opened %q, %v", key, err)
}
return json.Marshal(map[string]any{"adopted": true})
case "seat:anthropic-licence-manager.switch":
return json.Marshal(map[string]any{"licence": "api"})
}
t.Fatalf("asked %s", address)
return nil, nil
}
out, err := AddAPIKey(p, "api", file, true, ask)
if err != nil || out["file"] != "removed" || out["switched"] == nil {
t.Fatalf("%v %v", out, err)
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Fatal("the key file is still there")
}
if strings.Contains(strings.Join(sent, "\n"), "sk-ant") {
t.Fatalf("the key crossed in the clear: %v", sent)
}
}
@@ -177,6 +177,25 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
}
return GrantFor(p, key)
}},
{Name: "claude_code_add_api_key",
Description: "Add an Anthropic API key as a licence (ADR 0209): read from a file on this machine — never typed as an argument — sealed to the licence manager's key, handed over, and the file removed once taken. With use_here, this machine switches to it at once; other machines move with the manager's `switch`.",
Input: map[string]any{
"name": str("the licence's name, e.g. api"),
"file": str("a file on this machine holding the key, e.g. ~/api-key (removed once the manager has it)"),
"use_here": map[string]any{"type": "boolean", "description": "switch this machine to the new licence"},
},
Run: func(a map[string]any) (any, error) {
name, _ := a["name"].(string)
file, _ := a["file"].(string)
if strings.TrimSpace(name) == "" || strings.TrimSpace(file) == "" {
return nil, errors.New("name and file are required")
}
if strings.HasPrefix(file, "~/") {
file = filepath.Join(p.Home, file[2:])
}
useHere, _ := a["use_here"].(bool)
return AddAPIKey(p, strings.TrimSpace(name), file, useHere, ask)
}},
{Name: "claude_code_mcp_list",
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
Run: func(map[string]any) (any, error) {
@@ -343,6 +343,54 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
return out, nil
}
// AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the
// manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With
// useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is
// never an argument.
func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("the key is read from a file on this node: %w", err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
return nil, fmt.Errorf("%s is empty", file)
}
answer, err := ask(SeatVerb("public-key"), map[string]any{})
if err != nil {
return nil, err
}
var pk struct {
PublicKey string `json:"public_key"`
}
if err := json.Unmarshal(answer, &pk); err != nil || !strings.Contains(pk.PublicKey, "PUBLIC KEY") {
return nil, errors.New("the licence manager did not say what key to seal to")
}
box, err := Seal(key, pk.PublicKey)
if err != nil {
return nil, err
}
adopted, err := ask(SeatVerb("adopt"), map[string]any{"name": name, "sealed": box, "from": p.Node})
if err != nil {
return nil, err
}
out := map[string]any{"adopted": json.RawMessage(adopted)}
// Taken: the key now lives encrypted in the manager's store alone.
if err := os.Remove(file); err != nil {
out["file"] = "could not be removed: " + err.Error()
} else {
out["file"] = "removed"
}
if useHere {
switched, err := ask(SeatVerb("switch"), map[string]any{"consumer": p.Node, "licence": name})
if err != nil {
return out, fmt.Errorf("adopted, and switching this node to it failed: %w", err)
}
out["switched"] = json.RawMessage(switched)
}
return out, nil
}
// ---- MCP servers ----------------------------------------------------------------------------------
// Registration is a server registered (or, with no entry, unregistered) through this module.
+1
View File
@@ -23,6 +23,7 @@
"claude_code_render",
"claude_code_pull",
"claude_code_grant",
"claude_code_add_api_key",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister"
@@ -290,7 +290,7 @@ func tools() []stdio.Tool {
}
return m.Bind(ctx, c, l, "bind")
}),
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.",
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, l, err := two(a, "consumer", "licence")
@@ -326,15 +326,34 @@ func tools() []stdio.Tool {
}
return m.Store.Usage(ctx, l, limit)
}),
verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")},
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
"sealed": map[string]any{"type": "object", "description": "the key sealed to the manager's public key"},
"from": str("which node it came from, for the audit")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
n, f, err := two(a, "name", "file")
n, err := text(a, "name")
if err != nil {
return nil, err
}
if raw, ok := a["sealed"]; ok && raw != nil {
b, _ := json.Marshal(raw)
var box SealedBox
if err := json.Unmarshal(b, &box); err != nil {
return nil, err
}
from, _ := a["from"].(string)
return m.AdoptSealedKey(ctx, n, box, "a node: "+from)
}
f, err := text(a, "file")
if err != nil {
return nil, errors.New("adopt takes a `file` on the manager's node, or a `sealed` key")
}
return m.AdoptKey(ctx, n, f)
}),
verb("public-key", "The manager's public key, PEM: what a node seals an API key or a login to before handing it over.",
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
return map[string]any{"public_key": m.Keys.PublicKey}, nil
}),
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
@@ -336,7 +336,24 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""})
// A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing.
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
return "", err
}
from := ""
if b != nil {
from = b.Licence
}
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
if b != nil {
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
}
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
continue
@@ -612,16 +629,29 @@ var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6).
func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) {
if !licenceName.MatchString(name) {
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
key := strings.TrimSpace(string(raw))
return m.adoptKey(ctx, name, strings.TrimSpace(string(raw)), "a file on "+m.Holder)
}
// AdoptSealedKey adopts an API key sealed to this module's public key by a node's agent module (ADR 0209):
// the key crosses the bus only sealed, on request/reply.
func (m *Manager) AdoptSealedKey(ctx context.Context, name string, box SealedBox, from string) (map[string]any, error) {
key, err := Open(box, m.Keys.PrivateKey)
if err != nil {
return nil, err
}
return m.adoptKey(ctx, name, strings.TrimSpace(key), from)
}
func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[string]any, error) {
if !licenceName.MatchString(name) {
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
}
if key == "" {
return nil, fmt.Errorf("%s is empty", file)
return nil, errors.New("the key is empty")
}
held, err := m.Store.Licence(ctx, name)
if err != nil {
@@ -634,11 +664,11 @@ func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]a
if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"})
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
if err := m.publishAdvance(ctx, l); err != nil {
return nil, err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil})
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
}
@@ -357,3 +357,69 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
t.Fatal("a node already bound was bound again")
}
}
// A login to another account on one node adopts it and moves that node, and only that node (ADR 0209).
func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("%v", mm.state)
}
mm.now = t0.Add(time.Hour)
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
mm.vendor.live["rt-b"] = true
second := mm.report("laptop", "rt-b", t0.Add(time.Hour), "acct-2")
adopted, _ := mm.m.Consider(ctx, []Holdings{second, mm.report("server", "", t0, "acct-1")})
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
t.Fatalf("adopted %v", adopted)
}
if mm.state["laptop"].Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
}
if mm.state["server"].Licence != licence1 {
t.Fatalf("another node moved: %v", mm.state["server"])
}
ls, _ := mm.store.Licences(ctx)
if len(ls) != 2 {
t.Fatalf("%d licences", len(ls))
}
}
// A login that does not refresh moves nothing.
func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g := mm.state["laptop"]
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
if mm.state["laptop"] != g {
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
}
}
// An API key sealed to the manager by a node is adopted, and opens only for the manager.
func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
box, _ := Seal("sk-ant-api03-test\n", mm.keys.PublicKey)
r, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop")
if err != nil || r["adopted"] != true {
t.Fatalf("%v %v", r, err)
}
l, _ := mm.store.Licence(ctx, "api")
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test" {
t.Fatalf("stored %q", plain)
}
other, _ := GenerateKeyPair()
wrong, _ := Seal("sk", other.PublicKey)
if _, err := mm.m.AdoptSealedKey(ctx, "api2", wrong, "laptop"); err == nil {
t.Fatal("a key sealed to another key was adopted")
}
for _, e := range mm.events {
if strings.Contains(e, "sk-ant") {
t.Fatalf("the key was published: %s", e)
}
}
}
+4 -2
View File
@@ -33,7 +33,8 @@
"refresh",
"usage",
"adopt",
"current"
"current",
"public-key"
]
}
],
@@ -50,7 +51,8 @@
"refresh",
"usage",
"adopt",
"current"
"current",
"public-key"
]
}
],
+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 systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`, the session's output in the journal (`journalctl -t x-session`) because the login manager's pipe has no reader |
**Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/<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 systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`) {
t.Fatal("the entry runs the session's start, never i3 bare")
}
pkgs := map[string]bool{}
for _, x := range m.Resources {
if x["type"] == "package" {
pkgs[x["package"].(string)] = true
}
}
if !pkgs["i3-wm"] || !pkgs["dex"] || len(pkgs) != 2 {
t.Fatalf("%v", pkgs)
}
a := m.Build.Artifacts[0]
if a["from"] != "cmd/i3-tools" || a["binary"] != "i3-tools" || a["language"] != "go" {
t.Fatalf("a binary not named i3, so nothing that looks for i3 by name finds the tools: %v", a)
}
}
// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as
// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses.
func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
dir := t.TempDir()
drop := filepath.Join(dir, "config.d")
os.MkdirAll(drop, 0o755)
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
var found []string
for _, p := range manifests {
raw, _ := os.ReadFile(p)
var m struct {
Resources []map[string]any `json:"resources"`
}
json.Unmarshal(raw, &m)
for _, r := range m.Resources {
path, _ := r["path"].(string)
if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") {
os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644)
found = append(found, filepath.Base(path))
}
}
}
main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1)
os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644)
out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out)
}
}
+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
+3 -3
View File
@@ -2,7 +2,7 @@
The bars as a module (novox/hq ADR 0208, research 026/05).
- Installs `i3status-rust`, and `pacman-contrib` for `checkupdates`, which the update block uses.
- Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition.
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own
machine: its bars are i3bar's.
- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set
@@ -82,5 +82,5 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod
- The battery block waits for one of the two ways above.
- The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it
runs per session and ends with the session, as a session-start line already does.
- `pacman-contrib` is declared here. A future `pacman` module that wants `checkupdates` or `paccache`
takes it over, since a package is declared once per node.
- `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared
once per node.
@@ -22,7 +22,7 @@ func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"i3status-rust", "pacman-contrib"}) || absent != nil {
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"i3status-rust"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
-5
View File
@@ -32,11 +32,6 @@
"type": "package",
"package": "i3status-rust"
},
{
"id": "update-check",
"type": "package",
"package": "pacman-contrib"
},
{
"id": "configuration-dir",
"type": "directory",
+102
View File
@@ -0,0 +1,102 @@
# 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 and running.** It is its own `display-manager.service`
alias. It declares no restart or reload trigger, so a push starts it only where it is not running
and never restarts it, because restarting it ends the session it started. (A state is required:
the host refuses `boot` on a service that leaves its state to the machine.) 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,118 @@
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")
// Running and enabled: the host refuses boot without a state (a stateless service declares only
// its triggers). Running starts it only where it is not; with no trigger, nothing restarts it.
if s["unit"] != "lemurs.service" || s["boot"] != "enabled" || s["state"] != "running" {
t.Fatalf("%v", s)
}
for _, k := range []string{"restart-on", "reload-on"} {
if _, ok := s[k]; ok {
t.Fatalf("the login manager is never 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
+120
View File
@@ -0,0 +1,120 @@
# memory-pressure
Compressed swap in RAM, systemd-oomd, and a guard that warns before the machine kills for memory. It
can be assigned on any machine (novox/hq research 027/03, 026/05, to-be 42 phase 3). Memory pressure
is not the laptop's alone.
## What it owns
| | what | notes |
|---|---|---|
| package | `zram-generator` | |
| file | `/etc/systemd/zram-generator.conf` | `zram0`: `min(ram / 2, 16384)` MiB, zstd, swap priority 100. These are the laptop's values, adopted (the path too, ADR 0182). They scale with the machine: 15.3 GiB on 30 GiB of RAM, 16 GiB on the 125 GiB desktop. Read at boot, so a change applies at the next boot. Resizing a live device would mean swapping it off, which pushes what it holds back into RAM |
| file | `/etc/sysctl.d/90-memory-pressure.conf` | `vm.page-cluster = 0` (no read-ahead on swap in RAM). Swappiness is left alone on purpose: the comment says why. `systemd-sysctl` is re-run |
| file | `/etc/systemd/oomd.conf.d/memory-pressure.conf` | `SwapUsedLimit=90%`, `DefaultMemoryPressureLimit=60%`, `DefaultMemoryPressureDurationSec=20s` |
| file | `/etc/systemd/system/-.slice.d/10-oomd.conf` | `ManagedOOMSwap=kill` |
| file | `/etc/systemd/system/user@.service.d/10-oomd.conf` | `ManagedOOMMemoryPressure=kill`, limit 80 % |
| service | `systemd-oomd` running, enabled | restarted on any of its drop-ins, after a `daemon-reload` |
The swap on disk is not this module's. Whether there is a swap file or a partition, and how large, is
the machine's swap layout (research 027 question 3, the `kernel` module). The two workstations differ:
the laptop has a 32 GiB swap file (priority 10) and a 16 GiB partition, and the desktop a 128 GiB
partition and no zram. This module adds the zram tier above whatever is there.
## The long-running code: the guard (ADR 0198)
The Go bundle serves the tools and runs the guard in the same process, launched by the node's runtime.
It replaces the predecessor's `mem-guard` user unit, which checked used RAM every 30 s and called
`notify-send`.
- **Three signals:** RAM used ≥ 95 % (the predecessor's line, which it had raised from 90),
swap used ≥ 80 % (oomd kills at 90 %), or memory pressure (PSI `some` avg10) ≥ 40 % (oomd acts at
60 %, or 80 % for the user manager, over 20 s). Whichever comes first warns, and the warning says
which.
- **Every 10 s**, not 30: oomd's window is 20 s, so a check every 30 s could warn after oomd had
already acted. Each check reads two files. PSI triggers would wake on pressure alone, but not on RAM
or swap filling, so one timer reads all three rather than run two mechanisms.
- **Once per episode.** After a warning the guard re-arms only when every value is below its clearing
line: 88 % RAM, 70 % swap, 10 % pressure.
- **The warning names what to close:** the three largest units by resident plus swapped memory, and
in each its largest process.
- **The desktop, from outside the session.** The runtime runs as the operator's account but outside
the graphical session, with no session bus address and no `XDG_RUNTIME_DIR` (measured on the
laptop's runtime unit). The guard names the account's own bus, `/run/user/<uid>/bus`, and calls
`org.freedesktop.Notifications.Notify` with `busctl --user`. That client is the service manager's
own, so nothing is installed, and a server pulls in no libnotify. Measured on 2026-10-04: from a
clean environment as the account's uid, the notification service on that bus answered (dunst 1.13).
The account is the runtime's `MESH_OPERATOR_ACCOUNT`. A runtime running as another uid could not
authenticate on that bus, and the guard says so rather than try.
- **And always an event:** `pressure.high` (reasons, percentages, the largest units) and
`pressure.cleared` (how long it lasted) are published through the runtime, whether or not anyone is
at a desktop. A machine without one has no session bus, and `memory_guard` says
*no session bus*.
Thresholds are constants until settings exist (issue 168).
**Known limit.** The runtime restarts a launched bundle that exits on its next tool call, not at once.
The guard recovers from a panic and reports it, but a crashed process waits for a call.
## Tools
| tool | what |
|---|---|
| `memory_status` | RAM, swap and each device with its priority, zram and its ratio, PSI some/full, and the guard's verdict on them |
| `memory_top` | the largest processes by resident plus swap, with their unit; `by=unit` sums per unit, which is what systemd-oomd chooses among |
| `memory_oom_history` | what was killed, newest first, from the journal: the kernel's OOM killer, systemd-oomd, and the service manager's *killed by the OOM killer*. `since` is checked against a short grammar before it reaches `journalctl` |
| `memory_zram` | each zram device (algorithm, size, stored, compressed, RAM used, ratio, same-filled and incompressible pages), the generator's configuration, swappiness and page-cluster |
| `memory_oomd` | whether oomd runs, and what it watches (`oomctl`) |
| `memory_guard` | the guard's thresholds, last verdict, whether a warning stands, and whether the desktop can be reached |
## Found on 2026-10-04 (read-only)
- **On the laptop, systemd-oomd guards almost nothing a person runs.** Every desktop application runs
in the login session's scope (`session-1.scope`), because the login manager and i3 start them there
and not in per-application scopes under `user@.service`. The pressure kill on `user@.service`
therefore watches 0.5 GiB. The swap kill on `-.slice`, when it fires, would choose the largest
cgroup, which is the whole session scope: X, i3 and every application at once. `memory_top by=unit`
shows it. **The fix is the graphical session's** (phase 2): launch applications in their own
scopes, for example `systemd-run --user --scope` from the launcher, and oomd then kills one
application. This module does not widen oomd's reach onto `user-.slice`, because there it would kill
the session.
- The desktop has no zram and oomd disabled. Assigning the module there is a change of behaviour:
16 GiB of zram at the next boot, and oomd enabled with the same caveat about the session scope.
## When assigned to the laptop: what changes
1. Written over found files (originals kept once): `zram-generator.conf` (same values, so nothing
until the next boot either), `-.slice.d/10-oomd.conf` and `user@.service.d/10-oomd.conf` (same
keys).
2. New: `sysctl.d/90-memory-pressure.conf` (the same `vm.page-cluster = 0` already in force) and
`oomd.conf.d/memory-pressure.conf` (the same values as the predecessor's file beside it).
3. `systemd-sysctl` is re-run (the values are unchanged), and `daemon-reload` and `systemd-oomd` are
restarted.
4. The node runtime restarts with the bundle, and the guard starts. **Until `mem-guard` is stopped,
two notifiers run.**
## Predecessor files this module makes redundant — the operator removes them once (ADR 0182)
On the laptop:
1. `systemctl --user disable --now mem-guard.service`, then delete
`~/.config/systemd/user/mem-guard.service` and `~/scripts/mem-guard.sh`.
2. `/etc/systemd/oomd.conf.d/g14-oomd.conf`: the same values as the module's drop-in.
3. `/etc/sysctl.d/90-g14-zram.conf`: the same key as the module's file.
The desktop had none of these.
## Tests
`go test ./...`. They read a tree standing in for `/proc` and `/sys`, using the laptop's own swaps,
zram statistics and PSI lines. They cover:
- the snapshot;
- the guard's three lines, its once-per-episode latch and its clearing, with the largest named;
- an unreachable desktop still publishing the event;
- the notification being one `busctl` call on the account's bus;
- grouping by unit;
- the journal's three kinds of kill, with non-UTF-8 messages skipped;
- `since` refused when it is an option;
- *no match* read as no kills;
- the manifest: tools listed equal tools served, events, no machine named, triggers exist.
@@ -0,0 +1,286 @@
package main
import (
"context"
"fmt"
"os"
"os/user"
"strconv"
"strings"
"sync"
"time"
)
// The module's long-running code (novox/hq ADR 0198): the guard that warns before the machine kills
// something for memory. It replaces the predecessor's `mem-guard`, a user unit that checked used RAM
// every thirty seconds and called notify-send.
//
// What changed, and why:
//
// - **Three signals, not one.** Used RAM alone misses the two things that decide whether a kill is
// coming: swap filling (systemd-oomd kills at SwapUsedLimit) and stall pressure (it kills a unit
// whose pressure stays over its limit for twenty seconds). The guard warns on whichever comes
// first, and says which.
// - **Ten seconds, not thirty.** oomd's own window is twenty seconds; a check every thirty can
// notice only after oomd has acted. Reading two files every ten seconds costs nothing measurable.
// The kernel's PSI triggers would wake on pressure alone, but not on RAM or swap filling, so the
// guard reads all three on one timer rather than run two mechanisms.
// - **The notification names what to close.** It lists the largest units, not only a percentage.
// - **An event as well as a notification.** `pressure.high` and `pressure.cleared` reach the mesh
// whether or not anyone is at the desktop — a server has no desktop at all.
// - **No session needed.** The runtime runs as the operator's account but outside the graphical
// session, so it has no session bus address. The guard names the account's own bus,
// /run/user/<uid>/bus, and speaks to the notification service with busctl, the service manager's
// client: nothing is installed for it, and a machine without a desktop simply has no such bus.
// Thresholds, as constants until settings exist (novox/hq issue 168). Used RAM is the predecessor's,
// raised by it from 90 to 95 after the lower line fired during ordinary work.
const (
WarnUsedPercent = 95.0
ClearUsedPercent = 88.0
WarnSwapPercent = 80.0 // systemd-oomd's SwapUsedLimit is 90 %
ClearSwapPercent = 70.0
WarnPressureAvg10 = 40.0 // "some" avg10; oomd acts at 60 % (80 % for the user manager) over 20 s
ClearPressureAvg10 = 10.0
CheckEvery = 10 * time.Second
NotificationID = 9010 // replaces the previous warning rather than stacking
)
// Verdict is what one check of the machine concluded.
type Verdict struct {
High bool `json:"high"`
Reasons []string `json:"reasons"`
Clear bool `json:"clear"`
}
// Judge applies the thresholds to a snapshot. High is any warning line crossed; Clear is every value
// below its clearing line, which is what re-arms the guard.
func Judge(s Snapshot) Verdict {
v := Verdict{Reasons: []string{}}
clear := true
if s.UsedPercent >= WarnUsedPercent {
v.Reasons = append(v.Reasons, fmt.Sprintf("RAM %.0f%% used (warns at %.0f%%)", s.UsedPercent, WarnUsedPercent))
}
if s.UsedPercent > ClearUsedPercent {
clear = false
}
if s.SwapTotalGiB > 0 {
if s.SwapPercent >= WarnSwapPercent {
v.Reasons = append(v.Reasons, fmt.Sprintf("swap %.0f%% used (warns at %.0f%%; systemd-oomd kills at 90%%)", s.SwapPercent, WarnSwapPercent))
}
if s.SwapPercent > ClearSwapPercent {
clear = false
}
}
if s.PressureSome != nil {
if s.PressureSome.Avg10 >= WarnPressureAvg10 {
v.Reasons = append(v.Reasons, fmt.Sprintf("tasks stalled on memory %.0f%% of the last 10 s (warns at %.0f%%)", s.PressureSome.Avg10, WarnPressureAvg10))
}
if s.PressureSome.Avg10 > ClearPressureAvg10 {
clear = false
}
}
v.High = len(v.Reasons) > 0
v.Clear = clear
return v
}
// Guard is the notifier's state, shared with the tools that report it.
type Guard struct {
m *Machine
now func() time.Time
emit func(string, any) error
notify func(ctx context.Context, summary, body string) error
mu sync.Mutex
latched bool
since time.Time
last *Verdict
lastAt time.Time
notified string
err string
desktop string
stopped string
}
func NewGuard(m *Machine, emit func(string, any) error) *Guard {
g := &Guard{m: m, now: time.Now, emit: emit}
g.notify = g.desktopNotify
return g
}
// Check is one wake-up: read, judge, and on crossing a line warn once; on clearing every line, re-arm.
func (g *Guard) Check(ctx context.Context) {
s, err := g.m.Snapshot()
g.mu.Lock()
if err != nil {
g.err = err.Error()
g.mu.Unlock()
return
}
v := Judge(s)
now := g.now()
g.last, g.lastAt, g.err = &v, now, ""
warn := v.High && !g.latched
cleared := g.latched && v.Clear
if warn {
g.latched, g.since = true, now
}
if cleared {
g.latched = false
}
since := g.since
g.mu.Unlock()
if warn {
largest := ByUnit(g.m.Processes(), 3)
var names []string
for _, u := range largest {
names = append(names, fmt.Sprintf("%s %.1f GiB", firstNonEmpty(u.Largest, u.Unit), (u.RSSMiB+u.SwapMiB)/1024))
}
body := strings.Join(v.Reasons, "; ")
if len(names) > 0 {
body += ". Largest: " + strings.Join(names, ", ")
}
body += ". systemd-oomd kills the worst unit if it climbs further."
g.publish("pressure.high", map[string]any{"reasons": v.Reasons, "used_percent": s.UsedPercent,
"swap_used_percent": s.SwapPercent, "pressure_some": s.PressureSome, "largest": largest})
err := g.notify(ctx, "Memory is running low", body)
g.mu.Lock()
if err != nil {
g.desktop = "not reached: " + err.Error()
} else {
g.desktop, g.notified = "reached", now.Format(time.RFC3339)
}
g.mu.Unlock()
}
if cleared {
g.publish("pressure.cleared", map[string]any{"used_percent": s.UsedPercent, "swap_used_percent": s.SwapPercent,
"lasted_seconds": int(now.Sub(since).Seconds())})
}
}
func (g *Guard) publish(eventType string, body map[string]any) {
if g.emit == nil {
return
}
if err := g.emit(eventType, body); err != nil {
fmt.Fprintf(os.Stderr, "%s not published: %v\n", eventType, err)
}
}
// Bus is the operator's session bus socket: the account the runtime names, else the one this process
// runs as.
func Bus() (string, error) {
uid := os.Getuid()
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("the operator account %s: %w", name, err)
}
if n, err := strconv.Atoi(u.Uid); err == nil {
if n != uid && uid != 0 {
return "", fmt.Errorf("this process runs as uid %d and the operator's bus belongs to uid %d", uid, n)
}
uid = n
}
}
return fmt.Sprintf("/run/user/%d/bus", uid), nil
}
// desktopNotify sends one critical notification to the operator's session, replacing the previous one.
func (g *Guard) desktopNotify(ctx context.Context, summary, body string) error {
bus, err := Bus()
if err != nil {
return err
}
if _, err := os.Stat(bus); err != nil {
return fmt.Errorf("no session bus at %s (nobody is logged in to a desktop)", bus)
}
_, err = g.m.Run(ctx, "env", "DBUS_SESSION_BUS_ADDRESS=unix:path="+bus,
"busctl", "--user", "call", "org.freedesktop.Notifications", "/org/freedesktop/Notifications",
"org.freedesktop.Notifications", "Notify", "susssasa{sv}i",
"memory-pressure", strconv.Itoa(NotificationID), "dialog-warning", summary, body, "0", "1", "urgency", "y", "2", "0")
return err
}
// Run is the guard's life: a check every CheckEvery.
func (g *Guard) Run(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
g.mu.Lock()
g.stopped = fmt.Sprintf("the guard stopped on a fault: %v", r)
g.mu.Unlock()
fmt.Fprintln(os.Stderr, g.stopped)
}
}()
t := time.NewTicker(CheckEvery)
defer t.Stop()
for {
g.Check(ctx)
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// GuardReport is the guard as the guard tool shows it.
type GuardReport struct {
Running bool `json:"running"`
Stopped string `json:"stopped,omitempty"`
Warned bool `json:"warning_given"`
WarnedSince *time.Time `json:"warning_since,omitempty"`
LastCheck *time.Time `json:"last_check,omitempty"`
LastVerdict *Verdict `json:"last_verdict,omitempty"`
LastError string `json:"last_error,omitempty"`
Desktop string `json:"desktop"`
LastNotified string `json:"last_notified,omitempty"`
Thresholds map[string]any `json:"thresholds"`
}
func (g *Guard) Report() GuardReport {
g.mu.Lock()
defer g.mu.Unlock()
r := GuardReport{
Running: g.stopped == "" && !g.lastAt.IsZero(), Stopped: g.stopped, Warned: g.latched, LastCheck: when(g.lastAt),
LastVerdict: g.last, LastError: g.err, Desktop: g.desktop, LastNotified: g.notified,
Thresholds: map[string]any{
"warn_used_percent": WarnUsedPercent, "clear_used_percent": ClearUsedPercent,
"warn_swap_percent": WarnSwapPercent, "clear_swap_percent": ClearSwapPercent,
"warn_pressure_some_avg10": WarnPressureAvg10, "clear_pressure_some_avg10": ClearPressureAvg10,
"check_every": CheckEvery.String(), "set_by": "constants until settings exist (novox/hq issue 168)",
},
}
if g.latched {
r.WarnedSince = when(g.since)
}
if r.Desktop == "" {
if bus, err := Bus(); err != nil {
r.Desktop = "not reachable: " + err.Error()
} else if _, err := os.Stat(bus); err != nil {
r.Desktop = "no session bus at " + bus
} else {
r.Desktop = "reachable at " + bus + " (not yet used)"
}
}
return r
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
// when is a time for a report: absent rather than the zero time.
func when(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
@@ -0,0 +1,80 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and
// recording every command it was asked to run.
type fake struct {
t *testing.T
root string
mu sync.Mutex
answers map[string]string
fails map[string]error
calls []string
}
func newFake(t *testing.T) *fake {
t.Helper()
return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}}
}
func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} }
func (f *fake) run(_ context.Context, name string, args ...string) (string, error) {
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, line)
if err, ok := f.fails[line]; ok {
return "", err
}
if out, ok := f.answers[line]; ok {
return out, nil
}
if err, ok := f.fails[name]; ok {
return "", err
}
return "", nil
}
func (f *fake) called(line string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c == line {
return true
}
}
return false
}
func (f *fake) callsLike(prefix string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
out = append(out, c)
}
}
return out
}
// file writes a file under the fake root.
func (f *fake) file(path, content string) {
f.t.Helper()
full := filepath.Join(f.root, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
@@ -0,0 +1,106 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// CommandTimeout bounds every command a tool or the guard runs: a journal that takes long to search
// must cost a tool call twenty seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// Runner runs one command and answers its standard output. It is injected so that every tool is
// tested against recorded answers rather than this machine's daemons.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the
// command said on stderr, because "exit status 1" names nothing.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
if said != "" {
return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said)
}
return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return stdout.String(), nil
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of
// /proc, /sys and /etc) and a way to run commands.
type Machine struct {
Root string
Run Runner
}
// Here is the machine this process runs on.
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// read is a file's content, trimmed; "" when it cannot be read.
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// readInt is a file holding one integer; ok false when it is absent or not a number.
func (m *Machine) readInt(p string) (int64, bool) {
s := m.read(p)
if s == "" {
return 0, false
}
n, err := strconv.ParseInt(s, 10, 64)
return n, err == nil
}
func (m *Machine) glob(pattern string) []string {
found, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(found))
for _, f := range found {
rel, err := filepath.Rel(m.Root, f)
if err != nil {
continue
}
out = append(out, "/"+filepath.ToSlash(rel))
}
return out
}
// notInstalled says a command failed because it is not on this machine at all.
func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) }
// round to one decimal, for watts and percentages a person reads.
func round1(f float64) float64 {
return float64(int64(f*10+sign(f)*0.5)) / 10
}
func sign(f float64) float64 {
if f < 0 {
return -1
}
return 1
}
@@ -0,0 +1,24 @@
// The memory-pressure module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the
// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running
// code — the guard that warns before the machine kills for memory — beside them.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
g := NewGuard(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go g.Run(ctx)
if err := stdio.Serve("", Tools(m, g)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,54 @@
package main
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
)
func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
var served []string
for _, tool := range Tools(newFake(t).machine(), nil) {
served = append(served, tool.Name)
}
sort.Strings(served)
sort.Strings(m.Tools)
if strings.Join(served, ",") != strings.Join(m.Tools, ",") {
t.Fatalf("served %v, listed %v", served, m.Tools)
}
sort.Strings(m.Emits)
if strings.Join(m.Emits, ",") != "pressure.cleared,pressure.high" {
t.Fatalf("emits %v", m.Emits)
}
for _, banned := range []string{"g14", "jochen", "/home/", "shanks", "/run/user/1000"} {
if strings.Contains(string(raw), banned) {
t.Errorf("the manifest says %q", banned)
}
}
ids := map[string]bool{}
for _, r := range m.Resources {
ids[r["id"].(string)] = true
}
for _, r := range m.Resources {
list, _ := r["restart-on"].([]any)
for _, id := range list {
if !ids[id.(string)] {
t.Errorf("%s restarts on %v, which is not a resource", r["id"], id)
}
}
}
}
@@ -0,0 +1,300 @@
package main
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
)
const kib = 1024
// GiB turns bytes into gibibytes with one decimal, for a person reading.
func GiB(bytes int64) float64 { return round1(float64(bytes) / (1 << 30)) }
// ParseMeminfo reads /proc/meminfo into bytes by field.
func ParseMeminfo(text string) map[string]int64 {
out := map[string]int64{}
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
n, err := strconv.ParseInt(f[0], 10, 64)
if err != nil {
continue
}
if len(f) > 1 && f[1] == "kB" {
n *= kib
}
out[key] = n
}
return out
}
// Pressure is one line of a PSI file: the share of time some (or all) tasks stalled on memory.
type Pressure struct {
Avg10 float64 `json:"avg10"`
Avg60 float64 `json:"avg60"`
Avg300 float64 `json:"avg300"`
}
// ParsePSI reads /proc/pressure/memory: its `some` and `full` lines.
func ParsePSI(text string) (some, full *Pressure) {
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 4 {
continue
}
p := &Pressure{}
for _, kv := range f[1:] {
k, v, _ := strings.Cut(kv, "=")
n, _ := strconv.ParseFloat(v, 64)
switch k {
case "avg10":
p.Avg10 = n
case "avg60":
p.Avg60 = n
case "avg300":
p.Avg300 = n
}
}
switch f[0] {
case "some":
some = p
case "full":
full = p
}
}
return some, full
}
// SwapDevice is one line of /proc/swaps.
type SwapDevice struct {
Name string `json:"name"`
Type string `json:"type"`
SizeGiB float64 `json:"size_gib"`
UsedGiB float64 `json:"used_gib"`
Priority int `json:"priority"`
}
// ParseSwaps reads /proc/swaps (sizes in KiB).
func ParseSwaps(text string) []SwapDevice {
var out []SwapDevice
for i, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if i == 0 || len(f) < 5 {
continue
}
size, _ := strconv.ParseInt(f[2], 10, 64)
used, _ := strconv.ParseInt(f[3], 10, 64)
prio, _ := strconv.Atoi(f[4])
out = append(out, SwapDevice{Name: f[0], Type: f[1], SizeGiB: GiB(size * kib), UsedGiB: GiB(used * kib), Priority: prio})
}
return out
}
// Snapshot is the machine's memory at one moment: what the status tool answers and what the guard
// judges.
type Snapshot struct {
TotalGiB float64 `json:"total_gib"`
AvailableGiB float64 `json:"available_gib"`
UsedPercent float64 `json:"used_percent"`
SwapTotalGiB float64 `json:"swap_total_gib"`
SwapUsedGiB float64 `json:"swap_used_gib"`
SwapPercent float64 `json:"swap_used_percent"`
Swaps []SwapDevice `json:"swap_devices"`
Zram []Zram `json:"zram"`
PressureSome *Pressure `json:"pressure_some,omitempty"`
PressureFull *Pressure `json:"pressure_full,omitempty"`
}
// Snapshot reads /proc and /sys.
func (m *Machine) Snapshot() (Snapshot, error) {
mem := ParseMeminfo(m.read("/proc/meminfo"))
total, avail := mem["MemTotal"], mem["MemAvailable"]
if total <= 0 {
return Snapshot{}, fmt.Errorf("/proc/meminfo says no MemTotal")
}
s := Snapshot{
TotalGiB: GiB(total), AvailableGiB: GiB(avail),
UsedPercent: round1(float64(total-avail) / float64(total) * 100),
SwapTotalGiB: GiB(mem["SwapTotal"]),
SwapUsedGiB: GiB(mem["SwapTotal"] - mem["SwapFree"]),
Swaps: ParseSwaps(m.read("/proc/swaps")),
Zram: m.Zram(),
}
if mem["SwapTotal"] > 0 {
s.SwapPercent = round1(float64(mem["SwapTotal"]-mem["SwapFree"]) / float64(mem["SwapTotal"]) * 100)
}
if s.Swaps == nil {
s.Swaps = []SwapDevice{}
}
s.PressureSome, s.PressureFull = ParsePSI(m.read("/proc/pressure/memory"))
return s, nil
}
// Zram is one compressed swap device in RAM.
type Zram struct {
Device string `json:"device"`
Algorithm string `json:"algorithm"`
DiskSizeGiB float64 `json:"disk_size_gib"`
StoredGiB float64 `json:"stored_gib"`
CompressedGiB float64 `json:"compressed_gib"`
RAMUsedGiB float64 `json:"ram_used_gib"`
Ratio *float64 `json:"compression_ratio,omitempty"`
SamePages int64 `json:"same_filled_pages"`
HugePages int64 `json:"incompressible_pages"`
}
// Zram reads every zram device's statistics from /sys/block.
func (m *Machine) Zram() []Zram {
out := []Zram{}
for _, dir := range m.glob("/sys/block/zram*") {
z := Zram{Device: path.Base(dir), Algorithm: activeAlgorithm(m.read(dir + "/comp_algorithm"))}
if v, ok := m.readInt(dir + "/disksize"); ok {
z.DiskSizeGiB = GiB(v)
}
// mm_stat: orig_data_size compr_data_size mem_used_total mem_limit mem_used_max same_pages
// pages_compacted huge_pages …
f := strings.Fields(m.read(dir + "/mm_stat"))
n := func(i int) int64 {
if i >= len(f) {
return 0
}
v, _ := strconv.ParseInt(f[i], 10, 64)
return v
}
if len(f) >= 3 {
z.StoredGiB, z.CompressedGiB, z.RAMUsedGiB = GiB(n(0)), GiB(n(1)), GiB(n(2))
z.SamePages, z.HugePages = n(5), n(7)
if n(1) > 0 {
r := round1(float64(n(0)) / float64(n(1)))
z.Ratio = &r
}
}
out = append(out, z)
}
return out
}
// activeAlgorithm is the bracketed one of `lzo lz4 [zstd]`.
func activeAlgorithm(s string) string {
for _, f := range strings.Fields(s) {
if strings.HasPrefix(f, "[") {
return strings.Trim(f, "[]")
}
}
return s
}
// Process is one process by the memory it holds.
type Process struct {
PID int `json:"pid"`
Name string `json:"name"`
RSSMiB float64 `json:"rss_mib"`
SwapMiB float64 `json:"swap_mib"`
Unit string `json:"unit,omitempty"`
Command string `json:"command,omitempty"`
}
// Group is the memory of every process in one systemd unit or scope.
type Group struct {
Unit string `json:"unit"`
Processes int `json:"processes"`
RSSMiB float64 `json:"rss_mib"`
SwapMiB float64 `json:"swap_mib"`
Largest string `json:"largest"`
}
// unitOf is the last named unit in a process's cgroup path: the scope or service it lives in, which
// is what systemd-oomd chooses among.
func unitOf(cgroup string) string {
line := strings.TrimSpace(strings.SplitN(cgroup, "\n", 2)[0])
_, p, _ := strings.Cut(line, "::")
parts := strings.Split(p, "/")
for i := len(parts) - 1; i >= 0; i-- {
if strings.HasSuffix(parts[i], ".scope") || strings.HasSuffix(parts[i], ".service") || strings.HasSuffix(parts[i], ".slice") {
return parts[i]
}
}
return p
}
// Processes reads every process's resident and swapped memory. A process that ends while it is read
// is skipped.
func (m *Machine) Processes() []Process {
var out []Process
for _, dir := range m.glob("/proc/[0-9]*") {
status := m.read(dir + "/status")
if status == "" {
continue
}
fields := ParseMeminfo(status)
name := ""
for _, line := range strings.Split(status, "\n") {
if v, ok := strings.CutPrefix(line, "Name:"); ok {
name = strings.TrimSpace(v)
break
}
}
rss := fields["VmRSS"]
swap := fields["VmSwap"]
if rss == 0 && swap == 0 {
continue // a kernel thread
}
pid, _ := strconv.Atoi(path.Base(dir))
cmd := strings.TrimSpace(strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " "))
if len(cmd) > 160 {
cmd = cmd[:160] + "…"
}
out = append(out, Process{PID: pid, Name: name, RSSMiB: mib(rss), SwapMiB: mib(swap),
Unit: unitOf(m.read(dir + "/cgroup")), Command: cmd})
}
return out
}
func mib(b int64) float64 { return round1(float64(b) / (1 << 20)) }
// Top is the largest processes by resident plus swapped memory.
func Top(ps []Process, limit int) []Process {
sort.Slice(ps, func(i, j int) bool { return ps[i].RSSMiB+ps[i].SwapMiB > ps[j].RSSMiB+ps[j].SwapMiB })
if len(ps) > limit {
ps = ps[:limit]
}
return ps
}
// ByUnit sums processes by their unit, largest first.
func ByUnit(ps []Process, limit int) []Group {
groups := map[string]*Group{}
biggest := map[string]float64{}
for _, p := range ps {
g := groups[p.Unit]
if g == nil {
g = &Group{Unit: p.Unit}
groups[p.Unit] = g
}
g.Processes++
g.RSSMiB = round1(g.RSSMiB + p.RSSMiB)
g.SwapMiB = round1(g.SwapMiB + p.SwapMiB)
if p.RSSMiB+p.SwapMiB > biggest[p.Unit] {
biggest[p.Unit] = p.RSSMiB + p.SwapMiB
g.Largest = p.Name
}
}
out := make([]Group, 0, len(groups))
for _, g := range groups {
out = append(out, *g)
}
sort.Slice(out, func(i, j int) bool { return out[i].RSSMiB+out[i].SwapMiB > out[j].RSSMiB+out[j].SwapMiB })
if len(out) > limit {
out = out[:limit]
}
return out
}
@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
)
const meminfo = `MemTotal: 32000000 kB
MemFree: 905460 kB
MemAvailable: 1000000 kB
SwapTotal: 10000000 kB
SwapFree: 5000000 kB
`
// As /proc/swaps and zram read on the laptop on 2026-10-04.
const swaps = `Filename Type Size Used Priority
/swapfile file 33554428 0 10
/dev/zram0 partition 16059900 7235584 100
`
func (f *fake) machineWith(mem, psi string) *Machine {
f.file("/proc/meminfo", mem)
f.file("/proc/swaps", swaps)
f.file("/proc/pressure/memory", psi)
f.file("/sys/block/zram0/comp_algorithm", "lzo-rle lzo lz4 lz4hc [zstd] deflate 842\n")
f.file("/sys/block/zram0/disksize", "16445341696\n")
f.file("/sys/block/zram0/mm_stat", "14368768 5341499 6680576 0 6680576 2 0 0 0\n")
return f.machine()
}
const calm = "some avg10=0.00 avg60=0.02 avg300=0.00 total=14487028\nfull avg10=0.00 avg60=0.01 avg300=0.00 total=14230772\n"
func TestTheSnapshotReadsRAMSwapZramAndPressure(t *testing.T) {
f := newFake(t)
s, err := f.machineWith(meminfo, calm).Snapshot()
if err != nil {
t.Fatal(err)
}
if s.UsedPercent != 96.9 || s.SwapPercent != 50 || len(s.Swaps) != 2 || s.Swaps[1].Priority != 100 {
t.Fatalf("%+v", s)
}
z := s.Zram[0]
if z.Algorithm != "zstd" || *z.Ratio != 2.7 || z.DiskSizeGiB != 15.3 || z.SamePages != 2 {
t.Fatalf("%+v", z)
}
if s.PressureSome == nil || s.PressureSome.Avg60 != 0.02 || s.PressureFull == nil {
t.Fatalf("%+v %+v", s.PressureSome, s.PressureFull)
}
}
func TestTheGuardWarnsOnWhicheverLineIsCrossedFirst(t *testing.T) {
for name, c := range map[string]struct {
s Snapshot
want string
}{
"ram": {Snapshot{UsedPercent: 96}, "RAM"},
"swap": {Snapshot{UsedPercent: 50, SwapTotalGiB: 10, SwapPercent: 85}, "swap"},
"pressure": {Snapshot{UsedPercent: 50, PressureSome: &Pressure{Avg10: 45}}, "stalled"},
} {
v := Judge(c.s)
if !v.High || len(v.Reasons) != 1 || !strings.Contains(v.Reasons[0], c.want) || v.Clear {
t.Errorf("%s: %+v", name, v)
}
}
if v := Judge(Snapshot{UsedPercent: 90}); v.High || v.Clear {
t.Errorf("between the lines is neither a warning nor clear: %+v", v)
}
if v := Judge(Snapshot{UsedPercent: 50, PressureSome: &Pressure{}}); v.High || !v.Clear {
t.Errorf("%+v", v)
}
}
func TestTheGuardWarnsOnceNamesTheLargestAndClearsOnlyBelowEveryLine(t *testing.T) {
f := newFake(t)
m := f.machineWith(meminfo, calm)
f.file("/proc/4242/status", "Name:\tfirefox\nVmRSS:\t 6291456 kB\nVmSwap:\t 1048576 kB\n")
f.file("/proc/4242/cgroup", "0::/user.slice/user-1000.slice/session-1.scope\n")
f.file("/proc/4242/cmdline", "/usr/lib/firefox/firefox\x00")
var events []string
g := NewGuard(m, func(t string, _ any) error { events = append(events, t); return nil })
var notes []string
g.notify = func(_ context.Context, summary, body string) error {
notes = append(notes, body)
return nil
}
ctx := context.Background()
g.Check(ctx)
g.Check(ctx)
if len(notes) != 1 || !strings.Contains(notes[0], "firefox 7.0 GiB") || !strings.Contains(notes[0], "RAM 97%") {
t.Fatalf("%v", notes)
}
// Between the lines: still latched, nothing new.
f.file("/proc/meminfo", strings.Replace(meminfo, "MemAvailable: 1000000", "MemAvailable: 3200000", 1))
g.Check(ctx)
if len(events) != 1 || !g.Report().Warned {
t.Fatalf("%v %+v", events, g.Report())
}
f.file("/proc/meminfo", "MemTotal: 32000000 kB\nMemAvailable: 20000000 kB\nSwapTotal: 10000000 kB\nSwapFree: 9000000 kB\n")
g.Check(ctx)
if strings.Join(events, ",") != "pressure.high,pressure.cleared" || g.Report().Warned {
t.Fatalf("%v", events)
}
}
func TestADesktopThatCannotBeReachedIsSaidAndTheEventStillGoes(t *testing.T) {
f := newFake(t)
m := f.machineWith(meminfo, calm)
var events []string
g := NewGuard(m, func(t string, _ any) error { events = append(events, t); return nil })
g.notify = func(context.Context, string, string) error { return errors.New("no session bus at /run/user/1000/bus") }
g.Check(context.Background())
if r := g.Report(); !strings.HasPrefix(r.Desktop, "not reached") || len(events) != 1 {
t.Fatalf("%+v %v", r, events)
}
}
func TestTheNotificationIsOneBusctlCallOnTheAccountsBus(t *testing.T) {
f := newFake(t)
g := NewGuard(f.machine(), nil)
bus, err := Bus()
if err != nil {
t.Skip(err)
}
err = g.desktopNotify(context.Background(), "s", "b")
calls := f.callsLike("env DBUS_SESSION_BUS_ADDRESS=unix:path=" + bus + " busctl --user call org.freedesktop.Notifications")
if err == nil && len(calls) != 1 {
t.Fatalf("%v", f.calls)
}
if err != nil && !strings.Contains(err.Error(), "no session bus") {
t.Fatal(err)
}
}
func TestTheLargestAreSummedByTheUnitOomdChoosesAmong(t *testing.T) {
ps := []Process{
{PID: 1, Name: "firefox", RSSMiB: 600, Unit: "session-1.scope"},
{PID: 2, Name: "Isolated Web Co", RSSMiB: 900, Unit: "session-1.scope"},
{PID: 3, Name: "postgres", RSSMiB: 100, Unit: "docker-abc.scope"},
}
g := ByUnit(ps, 10)
if len(g) != 2 || g[0].Unit != "session-1.scope" || g[0].RSSMiB != 1500 || g[0].Largest != "Isolated Web Co" || g[0].Processes != 2 {
t.Fatalf("%+v", g)
}
if top := Top(ps, 1); top[0].PID != 2 {
t.Fatalf("%+v", top)
}
if u := unitOf("0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foot-123.scope\n"); u != "app-foot-123.scope" {
t.Fatal(u)
}
}
func TestTheJournalIsReadForEveryKindOfKillAndSinceIsChecked(t *testing.T) {
journal := strings.Join([]string{
`{"__REALTIME_TIMESTAMP":"1759500000000000","MESSAGE":"Out of memory: Killed process 4242 (firefox) total-vm:1kB","_TRANSPORT":"kernel"}`,
`{"__REALTIME_TIMESTAMP":"1759600000000000","MESSAGE":"Killed /user.slice/user-1000.slice/session-1.scope due to memory pressure for /user.slice being 84.12% > 80.00% for > 20s with reclaim activity","_SYSTEMD_UNIT":"systemd-oomd.service"}`,
`{"__REALTIME_TIMESTAMP":"1759700000000000","MESSAGE":"docker.service: A process of this unit has been killed by the OOM killer.","_SYSTEMD_UNIT":"init.scope"}`,
`{"__REALTIME_TIMESTAMP":"1759800000000000","MESSAGE":"Killed something else entirely","_SYSTEMD_UNIT":"bash.service"}`,
`{"MESSAGE":[1,2,3]}`,
}, "\n")
kills := ParseJournal(journal)
if len(kills) != 3 || kills[0].By != "service manager" || kills[0].Victim != "docker.service" ||
kills[1].By != "systemd-oomd" || kills[2].Victim != "firefox (pid 4242)" || kills[2].Time.Year() != 2025 {
t.Fatalf("%+v", kills)
}
f := newFake(t)
m := f.machine()
if _, err := m.OOMHistory(context.Background(), "--output=x", 10); err == nil {
t.Fatal("an option was passed as since")
}
f.fails["journalctl --no-pager -q -o json --since -7 days -g Out of memory: Killed process|Killed .* due to|has been killed by the OOM killer"] = errors.New("journalctl: exit status 1")
k, err := m.OOMHistory(context.Background(), "7d", 10)
if err != nil || len(k) != 0 {
t.Fatalf("no match is no kills: %v %v", k, err)
}
_ = time.Now
}
@@ -0,0 +1,129 @@
package main
import (
"context"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Kill is one thing the machine killed for memory, from the journal.
type Kill struct {
Time time.Time `json:"time"`
By string `json:"by"`
Victim string `json:"victim,omitempty"`
Message string `json:"message"`
}
// The three voices that report a kill: the kernel's OOM killer, systemd-oomd, and the service manager
// saying one of its units lost a process to the OOM killer.
var (
kernelKill = regexp.MustCompile(`Out of memory: Killed process (\d+) \(([^)]*)\)`)
oomdKill = regexp.MustCompile(`Killed (\S+) due to (.+)`)
unitKilled = regexp.MustCompile(`^(\S+): A process of this unit has been killed by the OOM killer`)
)
// sinceArg is a value journalctl understands after --since; anything else is refused before it
// reaches the command line.
var sinceArg = regexp.MustCompile(`^(-?\d+[smhdw]|today|yesterday|\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?)$`)
// ParseJournal reads `journalctl -o json` lines into kills, newest first.
func ParseJournal(text string) []Kill {
var out []Kill
for _, line := range strings.Split(text, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var e map[string]any
if json.Unmarshal([]byte(line), &e) != nil {
continue
}
msg, ok := e["MESSAGE"].(string) // a message that is not UTF-8 arrives as bytes; not one of these
if !ok {
continue
}
var k Kill
if us, err := strconv.ParseInt(fmt.Sprint(e["__REALTIME_TIMESTAMP"]), 10, 64); err == nil {
k.Time = time.UnixMicro(us).UTC()
}
switch {
case kernelKill.MatchString(msg):
g := kernelKill.FindStringSubmatch(msg)
k.By, k.Victim = "kernel", g[2]+" (pid "+g[1]+")"
case oomdKill.MatchString(msg) && strings.Contains(fmt.Sprint(e["_SYSTEMD_UNIT"], e["SYSLOG_IDENTIFIER"]), "oomd"):
g := oomdKill.FindStringSubmatch(msg)
k.By, k.Victim = "systemd-oomd", g[1]
case unitKilled.MatchString(msg):
k.By, k.Victim = "service manager", unitKilled.FindStringSubmatch(msg)[1]
default:
continue
}
k.Message = msg
out = append(out, k)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) })
return out
}
// OOMHistory searches the journal since a time for every kill.
func (m *Machine) OOMHistory(ctx context.Context, since string, limit int) ([]Kill, error) {
if !sinceArg.MatchString(since) {
return nil, fmt.Errorf("since %q is not -7d, 12h, today, yesterday or a date (2026-10-01)", since)
}
since = expandRelative(since)
out, err := m.Run(ctx, "journalctl", "--no-pager", "-q", "-o", "json", "--since", since,
"-g", "Out of memory: Killed process|Killed .* due to|has been killed by the OOM killer")
if err != nil && strings.TrimSpace(out) == "" {
if strings.HasSuffix(err.Error(), "exit status 1") {
return []Kill{}, nil // journalctl exits 1 when a grep matches nothing
}
return nil, err
}
kills := ParseJournal(out)
if kills == nil {
kills = []Kill{}
}
if len(kills) > limit {
kills = kills[:limit]
}
return kills, nil
}
// expandRelative turns -7d (or 7d) into the "-7 days" form journalctl's --since reads.
func expandRelative(s string) string {
g := regexp.MustCompile(`^-?(\d+)([smhdw])$`).FindStringSubmatch(s)
if g == nil {
return s
}
unit := map[string]string{"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}[g[2]]
return "-" + g[1] + " " + unit
}
// Oomd is what systemd-oomd says it watches.
type Oomd struct {
Active string `json:"active"`
Enabled string `json:"enabled"`
Report []string `json:"oomctl"`
Note string `json:"note,omitempty"`
}
func (m *Machine) Oomd(ctx context.Context) Oomd {
o := Oomd{Report: []string{}}
a, _ := m.Run(ctx, "systemctl", "is-active", "systemd-oomd.service")
e, _ := m.Run(ctx, "systemctl", "is-enabled", "systemd-oomd.service")
o.Active, o.Enabled = strings.TrimSpace(a), strings.TrimSpace(e)
out, err := m.Run(ctx, "oomctl")
if err != nil {
o.Note = "oomctl: " + err.Error()
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) != "" {
o.Report = append(o.Report, strings.TrimRight(strings.ReplaceAll(line, "\t", " "), " "))
}
}
return o
}
@@ -0,0 +1,145 @@
package main
import (
"context"
"fmt"
"math"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools is the module's tools over one machine and its guard.
func Tools(m *Machine, g *Guard) []stdio.Tool {
ctx := context.Background
return []stdio.Tool{
{
Name: "memory_status",
Description: "Memory now: RAM total, available and used, swap and each swap device with its priority, the compressed swap in RAM " +
"(zram) and its ratio, and pressure stall (PSI some/full, avg10/60/300) — with the guard's verdict on it.",
Run: func(map[string]any) (any, error) {
s, err := m.Snapshot()
if err != nil {
return nil, err
}
return map[string]any{"memory": s, "verdict": Judge(s)}, nil
},
},
{
Name: "memory_top",
Description: "The largest processes by resident plus swapped memory, with the systemd unit each runs in; by=unit sums them per unit, which is what systemd-oomd chooses among.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many (default 10, at most 50)"},
"by": map[string]any{"type": "string", "enum": []string{"process", "unit"}},
},
Run: func(args map[string]any) (any, error) {
limit, err := bounded(args, "limit", 10, 50)
if err != nil {
return nil, err
}
ps := m.Processes()
switch str(args, "by") {
case "", "process":
return map[string]any{"processes": orEmpty(Top(ps, limit))}, nil
case "unit":
return map[string]any{"units": orEmpty(ByUnit(ps, limit))}, nil
}
return nil, fmt.Errorf("by is process or unit")
},
},
{
Name: "memory_oom_history",
Description: "What was killed for memory, newest first, from the journal: the kernel's OOM killer, systemd-oomd, and units the service manager says lost a process to it.",
Input: map[string]any{
"since": map[string]any{"type": "string", "description": "-7d (default), 12h, today, yesterday or a date like 2026-10-01"},
"limit": map[string]any{"type": "integer", "description": "how many (default 20, at most 100)"},
},
Run: func(args map[string]any) (any, error) {
limit, err := bounded(args, "limit", 20, 100)
if err != nil {
return nil, err
}
since := str(args, "since")
if since == "" {
since = "-7d"
}
kills, err := m.OOMHistory(ctx(), since, limit)
if err != nil {
return nil, err
}
return map[string]any{"since": since, "kills": kills}, nil
},
},
{
Name: "memory_zram",
Description: "The compressed swap in RAM: each zram device's algorithm, size, what it stores, what that costs in RAM and the ratio, the generator's configuration, and the swap tunables beside it.",
Run: func(map[string]any) (any, error) {
return map[string]any{
"devices": m.Zram(),
"configuration": m.read("/etc/systemd/zram-generator.conf"),
"vm": map[string]string{
"swappiness": m.read("/proc/sys/vm/swappiness"),
"page-cluster": m.read("/proc/sys/vm/page-cluster"),
},
"note": "a change to the configuration applies at the next boot: swapping the device off to resize it would push what it holds back into RAM",
}, nil
},
},
{
Name: "memory_oomd",
Description: "systemd-oomd: whether it runs, and what it watches — the cgroups, their limits and their pressure, as oomctl reports them.",
Run: func(map[string]any) (any, error) { return m.Oomd(ctx()), nil },
},
{
Name: "memory_guard",
Description: "The module's guard that warns before the machine kills for memory: its thresholds, its last verdict, whether a warning stands, and whether the operator's desktop can be reached.",
Run: func(map[string]any) (any, error) {
if g == nil {
return nil, fmt.Errorf("the guard does not run in this process")
}
return g.Report(), nil
},
},
}
}
func str(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
// bounded is an integer argument, defaulted, at least 1 and at most most.
func bounded(args map[string]any, key string, fallback, most int) (int, error) {
v, given := args[key]
if !given || v == nil {
return fallback, nil
}
var n int
switch x := v.(type) {
case float64:
if x != math.Trunc(x) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, x)
}
n = int(x)
case string:
i, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, x)
}
n = i
default:
return 0, fmt.Errorf("%s must be a whole number", key)
}
if n < 1 {
return 0, fmt.Errorf("%s must be at least 1", key)
}
return min(n, most), nil
}
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
+5
View File
@@ -0,0 +1,5 @@
module memorypressure
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=

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