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.
This commit is contained in:
jochen
2026-10-04 13:21:47 +02:00
parent 98eb3fe33c
commit 34829e39fe
17 changed files with 3449 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
# i3
The window manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 4).
- **Claims `node-display-session`** and serves its verbs `reload`, `workspaces` and `windows`.
- **Requires `x11-display`**, which only `xorg` on the same machine provides.
- **Packages:** `i3-wm`, and `dex`, which the configuration has always run for XDG autostart. `dex`
was missing on the desktop, so its autostart entries never started there.
- **Environment** (ADR 0203): `XDG_CURRENT_DESKTOP=i3` and `XDG_SESSION_DESKTOP=i3`. They reach
shells, the session (through `xorg`'s block) and the user manager (`environment.d`). The portal
keys off the first.
- **Session code** (ADR 0208 §4): `exec i3 --shmlog-size=26214400` in the `xinitrc` slot `last`.
That is the end of `xorg`'s block in `~/.xinitrc`.
## What it owns
| path | class (ADR 0182) | what |
|---|---|---|
| `~/.config/i3/` | owned directory | its contents other than `config` are found and kept |
| `~/.config/i3/config.d/` | owned directory | other modules' drop-ins, and yours |
| `~/.config/i3/config` | owned (the found file kept once) | the main configuration, from [`config/config`](config/config) |
| `/etc/lemurs/wms/i3` | owned | the login manager's session entry: `exec /bin/sh "$HOME/.xinitrc"` |
**Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/<NN>-<module>.conf`. The
`include` is the last line of the main file, so every variable set there (`$mod`, `$ws1`…`$ws10`) is
in scope. Files are read in name order. A file of yours there is yours.
## The reload watcher
The bundle runs the watcher for as long as the runtime runs it (ADR 0198). It replaces the
predecessor's `i3-reload-watcher` script and its user unit, so this needs **no user-scoped unit**:
- every 2 s it looks at `config` and `config.d/*.conf` (size and time);
- at its start, if the files differ from what the running i3 loaded, that counts as a change;
- after a change, once the files have been still for one more interval, it checks the result with
`i3 -C`;
- it **reloads only a configuration without errors**. Otherwise it keeps the running one and records
the errors.
What it has done is in the answers of `reload` and `i3_config_check`. It polls rather than using
inotify, so a file replaced by rename and a directory created later are seen without a fresh watch.
It reloads i3 only. Re-running the bar, the compositor and the notifier is each of those modules'
own business.
## Tools
| tool | | what |
|---|---|---|
| `node-display-session.reload` | a | checks, then reloads, keeping windows. Refused with the errors when the check fails; `force` reloads anyway |
| `node-display-session.workspaces` | r | number, name, output, visible, focused, urgent |
| `node-display-session.windows` | r | container id, X id, class, instance, role, title, workspace, output, focused, urgent, floating, fullscreen, scratchpad, marks; narrowed to one workspace |
| `i3_focus` | d | a window by criteria, or a workspace |
| `i3_move` | d | windows to a workspace or output; a workspace to an output |
| `i3_layout_save` | d | a workspace's arrangement as a named layout of placeholders (class, instance, role), in `~/.local/state/mesh/i3/layouts/` |
| `i3_layout_restore` | d | lays a saved layout back; `name: list` lists them |
| `i3_exec` | d | starts a program as i3's child, optionally on a workspace |
| `i3_kill` | d | closes the matching windows, or the focused one when asked; with no arguments it closes nothing |
| `i3_bindings` | r | every binding by mode, with its command and file, from what i3 loaded |
| `i3_config_check` | r | `i3 -C` on the files on disk (errors with file and line), the files i3 loaded, the watcher's record |
| `i3_marks` | r | each mark and its window |
| `i3_scratchpad` | r/d | list, show or move into the scratchpad |
The tools speak i3's IPC on its socket in the account's runtime directory, and need no display. Every
value a caller gives is quoted before it reaches an i3 command. With no i3 running, the answer is
`{"error": "no-graphical-session", …}`. The bundle's binary is `i3-tools`, so nothing that looks for
`i3` by name finds it.
## What it improves over today's configuration
Both workstations ran the same configuration. These changes are against it:
- **dead:** `exec lxpolkit` (installed on neither machine), the unused `$refresh_i3status`, and the
predecessor's `rice_set` comment;
- **duplicates:**
- `exec xdg-desktop-portal` (it is D-Bus activated);
- `exec xrdb -merge ~/.Xresources` (`xorg`'s session start merges it);
- the `picom`, `nm-applet`, `blueman-applet` and `nextcloud` execs. Each also has an XDG autostart
entry that `dex` starts, and both machines carry those entries;
- **font:** `JetBrainsMono Nerd Font 11` for titles, replacing Hack (research 026/04);
- **terminal:** `$mod+Return` runs `i3-sensible-terminal`, which starts whatever `$TERMINAL` names
(the terminal module's contribution), instead of naming xterm;
- **reloads:** the watcher never reloads into a broken configuration.
**Moved to their own modules' drop-ins** (the companion modules of research 026). These leave this
file because each module carries them now:
| lines | now in |
|---|---|
| the launcher bindings (`$mod+d`, `$mod+t`, `$mod+Shift+t`) and the power menu (`$mod+Escape`) | `rofi`'s `50-rofi.conf`, which also adds `$mod+Shift+w` |
| the greenclip daemon and its menu (`$mod+period`) | `clipmenu`'s `50-clipmenu.conf` and its session line |
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDropInLoadTogether`
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
key.
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
leaves when the module that owns it is written.
## What it leaves found
`~/.config/i3/scripts/`, `~/.config/i3/unlock-keyring.sh`, every file in `config.d/`, the
`/usr/share/xsessions` entries (the package's, no longer offered by `lemurs`), and i3's restart
state and logs.
## The one-off migration (ADR 0182)
1. **Before the push that assigns `i3`:** do `xorg`'s migration (its README). From that push on, your
lines below `xorg`'s block in `~/.xinitrc` no longer run.
2. **Disable the predecessor's watcher:** `systemctl --user disable --now i3-reload-watcher.service`.
Then remove `~/.config/systemd/user/i3-reload-watcher.service` and `~/scripts/i3-reload-watcher`.
One thing reloads i3 now. Kept running, it would also `i3-msg restart` on every change, without
checking first. **Keep `i3-bar-watchdog.service` as it is**: the bar is `i3status-rust`'s, and that
module decides.
3. **Delete `/etc/lemurs/wms/i3wm`** (see `lemurs`).
4. **`config.d/` fragments:**
- `10-asus.conf` and `20-g14.conf` (the laptop) belong to that machine model's hardware module.
Keep them until it exists. The desktop no longer carries them.
- `50-slack.conf` (both) is yours, or a future `slack` module's. Keep it.
- `99-tmp-wine-focus.conf` (the desktop), a test of a predecessor change by its own comment:
**delete** it, or keep it as yours.
5. **The main file's predecessor copy** is kept once by the host and written over. Nothing to do.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| packages | none (`i3-wm` and `dex` present) | `dex` installed |
| `~/.config/i3/config` | written: the improved configuration | the same |
| running i3 | **the watcher reloads it once the file changes**. i3 keeps every window, and the title font becomes JetBrains Mono. **Assigned without `rofi`, `clipmenu`, `feh` and `i3status-rust`, the reload takes away the bars and those keys** until they are assigned too, so assign them in the same push. The dropped duplicate execs end nothing that runs | the same |
| `/etc/lemurs/wms/i3` | new: offered as `i3` at the next boot | the same |
| `~/.xinitrc` | `xorg`'s block now ends in `exec i3`: the lines below it stop running at the next login | the same |
| `~/.config/environment.d/50-mesh.conf`, `environment.sh` | gain `XDG_CURRENT_DESKTOP=i3`, `XDG_SESSION_DESKTOP=i3` | the same. Its user manager lacked them, and gets them at the next login |
| next login | XDG autostart as before | **XDG autostart runs for the first time**: Slack, JetBrains Toolbox, Nextcloud, the FortiClient tray, the print applet, the geoclue demo agent and snap's user daemon start from their entries |
**One reload on assignment is the one live change.** To avoid it, assign during a session you are
about to end, or accept it: a reload keeps every window and workspace.
## Blockers
- **`fonts` first** (to-be 42 orders it first). Without `ttf-jetbrains-mono-nerd`, i3's titles and
`i3status-rust`'s bars fall back to pango's default face. On 2026-10-04 the laptop had the package, and the desktop
only a hand-copied file of the face.
- **None for the watcher.** The runtime restarts with every push that changes it, after the push has
written the files. So at its start the watcher compares the files on disk with what the running i3
loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its
configuration, is therefore reloaded although it also restarted the watcher.
+286
View File
@@ -0,0 +1,286 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Binding is one key binding of the configuration in force.
type Binding struct {
Mode string `json:"mode"`
Kind string `json:"kind"` // bindsym or bindcode
Keys string `json:"keys"`
Command string `json:"command"`
Release bool `json:"release,omitempty"`
File string `json:"file"`
}
var modeOpen = regexp.MustCompile(`^mode\s+(?:--pango_markup\s+)?("(?:[^"\\]|\\.)*"|\S+)\s*\{$`)
// Bindings reads the bindings out of configuration text whose variables i3 has already replaced
// (GET_CONFIG's variable_replaced_contents), mode blocks included.
func Bindings(file, text string) []Binding {
var out []Binding
mode, depth := "default", 0
sc := bufio.NewScanner(strings.NewReader(joinContinued(text)))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if m := modeOpen.FindStringSubmatch(line); m != nil {
if s, err := strconv.Unquote(m[1]); err == nil {
mode = s
} else {
mode = m[1]
}
depth = 1
continue
}
if depth > 0 {
depth += strings.Count(line, "{") - strings.Count(line, "}")
if depth <= 0 {
mode, depth = "default", 0
continue
}
}
f := strings.Fields(line)
if len(f) < 3 || (f[0] != "bindsym" && f[0] != "bindcode") {
continue
}
b := Binding{Mode: mode, Kind: f[0], File: file}
i := 1
for ; i < len(f) && strings.HasPrefix(f[i], "--"); i++ {
if f[i] == "--release" {
b.Release = true
}
}
if i >= len(f)-1 {
continue
}
b.Keys = f[i]
b.Command = strings.Join(f[i+1:], " ")
out = append(out, b)
}
return out
}
func joinContinued(text string) string {
return strings.ReplaceAll(text, "\\\n", " ")
}
// ConfigError is one problem `i3 -C` reports.
type ConfigError struct {
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Text string `json:"text"`
}
var (
checkError = regexp.MustCompile(`ERROR: (?:CONFIG: )?(.*)$`)
checkFile = regexp.MustCompile(`^\(in file (.+)\)$`)
checkLine = regexp.MustCompile(`^Line\s+(\d+): (.*)$`)
checkMark = regexp.MustCompile(`^\s*\^+\s*$`)
)
// ParseCheck reads what `i3 -C` printed: each error with the file and line it points at. i3 prints
// the lines around an error as context; the one marked with carets beneath is the error's.
func ParseCheck(text string) []ConfigError {
var out []ConfigError
var cur *ConfigError
file := ""
lastLine, lastText := 0, ""
for _, raw := range strings.Split(text, "\n") {
m := checkError.FindStringSubmatch(raw)
if m == nil {
continue
}
msg := m[1]
switch {
case checkFile.MatchString(msg):
file = checkFile.FindStringSubmatch(msg)[1]
if cur != nil && cur.File == "" {
cur.File = file
}
case checkLine.MatchString(msg):
lm := checkLine.FindStringSubmatch(msg)
lastLine, _ = strconv.Atoi(lm[1])
lastText = lm[2]
case checkMark.MatchString(msg):
if cur != nil {
cur.Line = lastLine
cur.Text = strings.TrimSpace(cur.Text + " — at: " + strings.TrimSpace(lastText))
}
default:
out = append(out, ConfigError{File: file, Text: msg})
cur = &out[len(out)-1]
}
}
return out
}
// Watched is the configuration's files the reload watcher looks at: the main file and every drop-in.
func Watched(dir string) map[string]string {
out := map[string]string{}
paths := []string{filepath.Join(dir, "config")}
drop, _ := filepath.Glob(filepath.Join(dir, "config.d", "*.conf"))
paths = append(paths, drop...)
for _, p := range paths {
if info, err := os.Stat(p); err == nil {
out[p] = strconv.FormatInt(info.Size(), 10) + "@" + strconv.FormatInt(info.ModTime().UnixNano(), 10)
}
}
return out
}
// Watcher reloads i3 when its configuration changes on disk, after checking it (ADR 0198: the
// module's own long-running code, inside its tools bundle). It replaces the predecessor's inotify
// script and user unit: it polls, so a file replaced by rename is seen as surely as one written in
// place, and a directory that appears later (config.d) is picked up without a new watch.
//
// **It never reloads into a broken configuration.** i3 would load what it can and show its error bar;
// the watcher instead keeps the running configuration and records why.
type Watcher struct {
Dir string
Every time.Duration
Check func() ([]ConfigError, error)
Reload func() error
// Loaded is what the running i3 loaded, by file (GET_CONFIG). At the watcher's start, a file on
// disk that differs from it — or a drop-in added or gone — is a change still to apply: the runtime
// restarts with every push, after the push has written the files, so a watcher that only compared
// the files with themselves would never reload what the push that started it wrote.
Loaded func() (map[string]string, error)
mu sync.Mutex
status WatcherStatus
}
// WatcherStatus is what the watcher has done, for the tools to answer.
type WatcherStatus struct {
Since string `json:"since"`
Files int `json:"files_watched"`
LastChange string `json:"last_change,omitempty"`
Changed []string `json:"changed,omitempty"`
LastReload string `json:"last_reload,omitempty"`
Reloads int `json:"reloads"`
Refused []ConfigError `json:"refused,omitempty"`
LastProblem string `json:"last_problem,omitempty"`
}
// Status is a copy of what the watcher has done.
func (w *Watcher) Status() WatcherStatus {
w.mu.Lock()
defer w.mu.Unlock()
return w.status
}
// Run watches until stop closes. A change is acted on once the files have been still for one more
// interval, so a push writing several files is one reload.
func (w *Watcher) Run(stop <-chan struct{}) {
seen := Watched(w.Dir)
w.mu.Lock()
w.status.Since = time.Now().Format(time.RFC3339)
w.status.Files = len(seen)
w.mu.Unlock()
pending := w.stale(seen)
tick := time.NewTicker(w.Every)
defer tick.Stop()
for {
select {
case <-stop:
return
case <-tick.C:
}
now := Watched(w.Dir)
changed := diff(seen, now)
seen = now
if len(changed) > 0 {
pending = true
w.mu.Lock()
w.status.LastChange = time.Now().Format(time.RFC3339)
w.status.Changed = changed
w.status.Files = len(now)
w.mu.Unlock()
continue
}
if !pending {
continue
}
pending = false
w.act()
}
}
func (w *Watcher) act() {
problems, err := w.Check()
w.mu.Lock()
defer w.mu.Unlock()
switch {
case err != nil:
w.status.LastProblem = "the configuration could not be checked: " + err.Error()
return
case len(problems) > 0:
w.status.Refused = problems
w.status.LastProblem = "not reloaded: the configuration has errors"
return
}
w.status.Refused = nil
w.mu.Unlock()
err = w.Reload()
w.mu.Lock()
if err != nil {
w.status.LastProblem = "reload: " + err.Error()
return
}
w.status.LastProblem = ""
w.status.Reloads++
w.status.LastReload = time.Now().Format(time.RFC3339)
}
// stale is whether the files on disk are not what the running i3 loaded.
func (w *Watcher) stale(onDisk map[string]string) bool {
if w.Loaded == nil {
return false
}
loaded, err := w.Loaded()
if err != nil {
return false
}
if len(loaded) != len(onDisk) {
return true
}
for path := range onDisk {
text, ok := loaded[path]
if !ok {
return true
}
disk, err := os.ReadFile(path)
if err != nil || string(disk) != text {
return true
}
}
return false
}
func diff(a, b map[string]string) []string {
var out []string
for k, v := range b {
if a[k] != v {
out = append(out, k)
}
}
for k := range a {
if _, ok := b[k]; !ok {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
+371
View File
@@ -0,0 +1,371 @@
package main
import (
"context"
"encoding/binary"
"encoding/json"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"i3/internal/desktop"
)
// fakeI3 answers i3's IPC on a socket of its own: a fixed tree, workspaces and config, and every
// RUN_COMMAND recorded and answered with success unless it contains "nonsense".
type fakeI3 struct {
path string
mu sync.Mutex
commands []string
}
const tree = `{"id":1,"type":"root","name":"root","nodes":[
{"id":2,"type":"output","name":"__i3","nodes":[{"id":3,"type":"con","name":"content","nodes":[
{"id":4,"type":"workspace","name":"__i3_scratch","nodes":[],"floating_nodes":[
{"id":5,"type":"floating_con","floating":"user_on","nodes":[{"id":6,"type":"con","name":"notes","window":101,"window_properties":{"class":"XTerm","instance":"notes"},"scratchpad_state":"fresh"}]}]}]}]},
{"id":10,"type":"output","name":"eDP-1","nodes":[
{"id":11,"type":"dockarea","name":"topdock","nodes":[{"id":12,"type":"con","name":"i3bar for output eDP-1","window":200,"window_properties":{"class":"i3bar"}}]},
{"id":13,"type":"con","name":"content","nodes":[
{"id":20,"type":"workspace","name":"1","layout":"splith","nodes":[
{"id":21,"type":"con","name":"Mail - Thunderbird","layout":"splith","percent":0.6,"border":"pixel","current_border_width":1,"floating":"auto_off","window":301,"window_properties":{"class":"thunderbird","instance":"Mail","window_role":"3pane"},"marks":["mail"]},
{"id":22,"type":"con","name":null,"layout":"splitv","percent":0.4,"border":"pixel","floating":"auto_off","nodes":[
{"id":23,"type":"con","name":"op: ~ (main)","window":302,"focused":true,"window_properties":{"class":"XTerm","instance":"xterm"}},
{"id":24,"type":"con","name":"a.b (c)","window":303,"window_properties":{"class":"Foo.Bar","instance":"x"}}]}]}]}]}]}`
func startFake(t *testing.T) *fakeI3 {
dir, err := os.MkdirTemp("", "i3ipc")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
f := &fakeI3{path: filepath.Join(dir, "ipc")}
l, err := net.Listen("unix", f.path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go f.serve(c)
}
}()
return f
}
func (f *fakeI3) serve(c net.Conn) {
defer c.Close()
head := make([]byte, 14)
if _, err := io.ReadFull(c, head); err != nil {
return
}
n := binary.LittleEndian.Uint32(head[6:])
kind := binary.LittleEndian.Uint32(head[10:])
body := make([]byte, n)
io.ReadFull(c, body)
var reply string
switch kind {
case RunCommand:
f.mu.Lock()
f.commands = append(f.commands, string(body))
f.mu.Unlock()
if strings.Contains(string(body), "nonsense") {
reply = `[{"success":false,"error":"Expected one of these tokens"}]`
} else {
reply = `[{"success":true}]`
}
case GetWorkspaces:
reply = `[{"num":1,"name":"1","output":"eDP-1","visible":true,"focused":true,"urgent":false}]`
case GetTree:
reply = tree
case GetVersion:
reply = `{"human_readable":"4.25.1","loaded_config_file_name":"/c/config","included_config_file_names":["/c/config.d/50-x.conf"]}`
case GetConfig:
reply = `{"config":"x","included_configs":[{"path":"/c/config","variable_replaced_contents":"bindsym Mod4+Return exec i3-sensible-terminal\nmode \"resize\" {\n bindsym h resize shrink width 10 px\n bindsym Escape mode \"default\"\n}\nbindsym --release Control+Shift+x exec shot\n"},{"path":"/c/config.d/50-x.conf","variable_replaced_contents":"bindcode 133 exec rofi\n"}]}`
}
out := make([]byte, 14+len(reply))
copy(out, "i3-ipc")
binary.LittleEndian.PutUint32(out[6:], uint32(len(reply)))
binary.LittleEndian.PutUint32(out[10:], kind)
copy(out[14:], reply)
c.Write(out)
}
func (f *fakeI3) ran() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.commands...)
}
func withFake(t *testing.T) (*i3, *fakeI3) {
f := startFake(t)
dir := t.TempDir()
return &i3{
d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }},
socket: func() (string, error) { return f.path, nil },
configDir: dir, layouts: filepath.Join(dir, "layouts"),
}, f
}
func run(t *testing.T, x *i3, tool string, args map[string]any) (any, error) {
for _, tl := range tools(x) {
if tl.Name == tool {
if args == nil {
args = map[string]any{}
}
return tl.Run(args)
}
}
t.Fatalf("no tool %s", tool)
return nil, nil
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func TestWindowsAreTheTreesWindowsWithTheirWorkspaceAndNotTheBars(t *testing.T) {
var root Node
if err := json.Unmarshal([]byte(tree), &root); err != nil {
t.Fatal(err)
}
w := Windows(root)
if len(w) != 4 {
t.Fatalf("%d windows: %+v", len(w), w)
}
if w[0].Title != "notes" || !w[0].Scratchpad || !w[0].Floating || w[0].Workspace != "__i3_scratch" {
t.Fatalf("the scratchpad's window: %+v", w[0])
}
if w[1].Class != "thunderbird" || w[1].Workspace != "1" || w[1].Output != "eDP-1" || w[1].Window != "0x12d" || w[1].Marks[0] != "mail" {
t.Fatalf("%+v", w[1])
}
if !w[2].Focused {
t.Fatalf("%+v", w[2])
}
}
func TestTheSeatsVerbsAnswerFromI3(t *testing.T) {
x, _ := withFake(t)
got, err := run(t, x, "node-display-session.windows", map[string]any{"workspace": "1"})
if err != nil || len(got.(map[string]any)["windows"].([]Window)) != 3 {
t.Fatalf("%v %v", asJSON(got), err)
}
got, err = run(t, x, "node-display-session.workspaces", nil)
if err != nil || !strings.Contains(asJSON(got), `"focused":true`) {
t.Fatalf("%v %v", got, err)
}
}
func TestReloadIsRefusedWhenTheConfigurationHasErrors(t *testing.T) {
x, f := withFake(t)
x.d.Run = func(_ context.Context, _ []string, _ []byte, name string, args ...string) desktop.Result {
return desktop.Result{Code: 1, Stderr: "10/04/2026 - ERROR: CONFIG: Expected one of these tokens\n" +
"10/04/2026 - ERROR: CONFIG: (in file /c/config.d/99-x.conf)\n10/04/2026 - ERROR: CONFIG: Line 3: foo bar\n" +
"10/04/2026 - ERROR: CONFIG: ^^^^^^^\n"}
}
got, err := run(t, x, "node-display-session.reload", nil)
if err != nil || got.(map[string]any)["reloaded"] != false || len(f.ran()) != 0 {
t.Fatalf("%v %v %v", asJSON(got), err, f.ran())
}
e := got.(map[string]any)["errors"].([]ConfigError)
if len(e) != 1 || e[0].File != "/c/config.d/99-x.conf" || e[0].Line != 3 || !strings.Contains(e[0].Text, "foo bar") {
t.Fatalf("%+v", e)
}
if _, err := run(t, x, "node-display-session.reload", map[string]any{"force": true}); err != nil || f.ran()[0] != "reload" {
t.Fatalf("forced: %v %v", err, f.ran())
}
}
func TestCommandsQuoteWhatTheCallerGave(t *testing.T) {
x, f := withFake(t)
steps := []struct {
tool string
args map[string]any
want string
}{
{"i3_focus", map[string]any{"class": `Fire"fox`}, `[class="Fire\"fox"] focus`},
{"i3_focus", map[string]any{"workspace": "2: mail"}, `workspace "2: mail"`},
{"i3_move", map[string]any{"con_id": float64(21), "to_workspace": "3"}, `[con_id=21] move container to workspace "3"`},
{"i3_move", map[string]any{"to_output": "right"}, `move container to output right`},
{"i3_move", map[string]any{"workspace_to_output": "1", "to_output": "DP-2"}, `[workspace="^1$"] move workspace to output "DP-2"`},
{"i3_exec", map[string]any{"command": "xterm -e 'a; b'", "workspace": "4"}, `workspace "4"; exec --no-startup-id "xterm -e 'a; b'"`},
{"i3_kill", map[string]any{"window": "0x12d"}, `[id=301] kill`},
{"i3_kill", map[string]any{"focused": true, "force": true}, `kill client`},
{"i3_scratchpad", map[string]any{"action": "show", "mark": "notes"}, `[con_mark="notes"] scratchpad show`},
{"i3_scratchpad", map[string]any{"action": "move"}, `move scratchpad`},
}
for i, s := range steps {
if _, err := run(t, x, s.tool, s.args); err != nil {
t.Fatalf("%s: %v", s.tool, err)
}
if got := f.ran()[i]; got != s.want {
t.Errorf("%s: %q, want %q", s.tool, got, s.want)
}
}
for _, refused := range []struct {
tool string
args map[string]any
}{
{"i3_kill", nil}, {"i3_focus", nil}, {"i3_move", map[string]any{"class": "x"}},
{"i3_focus", map[string]any{"window": "301"}}, {"i3_layout_save", map[string]any{"name": "../x"}},
} {
if _, err := run(t, x, refused.tool, refused.args); err == nil {
t.Errorf("%s %v was accepted", refused.tool, refused.args)
}
}
if _, err := run(t, x, "i3_exec", map[string]any{"command": "nonsense"}); err == nil {
t.Fatal("i3's refusal is the tool's")
}
}
func TestALayoutIsSavedAsPlaceholdersAndLaidBackOnItsWorkspace(t *testing.T) {
x, f := withFake(t)
got, err := run(t, x, "i3_layout_save", map[string]any{"name": "mail"})
if err != nil {
t.Fatal(err)
}
if m := got.(map[string]any); m["workspace"] != "1" || m["windows"] != 3 {
t.Fatalf("the focused workspace, its three windows: %v", m)
}
b, _ := os.ReadFile(filepath.Join(x.layouts, "mail.json"))
text := string(b)
if SavedWorkspace(text) != "1" || !strings.Contains(text, `"class": "^thunderbird$"`) || !strings.Contains(text, `"class": "^Foo\\.Bar$"`) ||
!strings.Contains(text, `"window_role": "^3pane$"`) || !strings.Contains(text, `"layout": "splitv"`) || strings.Contains(text, `"window": `) {
t.Fatalf("%s", text)
}
got, err = run(t, x, "i3_layout_restore", map[string]any{"name": "mail"})
if err != nil {
t.Fatal(err)
}
if last := f.ran()[len(f.ran())-1]; last != `workspace "1"; append_layout "`+filepath.Join(x.layouts, "mail.json")+`"` {
t.Fatalf("%q", last)
}
got, _ = run(t, x, "i3_layout_restore", map[string]any{"name": "list"})
if !strings.Contains(asJSON(got), `"name":"mail"`) {
t.Fatalf("%v", asJSON(got))
}
}
func TestBindingsAreReadByModeWithTheirFiles(t *testing.T) {
x, _ := withFake(t)
got, err := run(t, x, "i3_bindings", nil)
if err != nil {
t.Fatal(err)
}
b := got.(map[string]any)["bindings"].([]Binding)
if len(b) != 5 {
t.Fatalf("%+v", b)
}
if b[1].Mode != "resize" || b[1].Keys != "h" || b[3].Mode != "default" || !b[3].Release || b[3].Keys != "Control+Shift+x" {
t.Fatalf("%+v", b)
}
if b[4].Kind != "bindcode" || b[4].File != "/c/config.d/50-x.conf" {
t.Fatalf("%+v", b[4])
}
got, _ = run(t, x, "i3_bindings", map[string]any{"match": "rofi"})
if len(got.(map[string]any)["bindings"].([]Binding)) != 1 {
t.Fatal("match")
}
}
func TestWithNoI3RunningTheToolsSaySo(t *testing.T) {
x := &i3{d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }},
socket: func() (string, error) { return FindSocket(t.TempDir(), "") }, configDir: t.TempDir()}
for _, tool := range []string{"node-display-session.windows", "node-display-session.workspaces", "i3_marks", "i3_bindings"} {
if _, err := run(t, x, tool, nil); !desktop.IsNoSession(err) {
t.Errorf("%s: %v", tool, err)
}
}
got, err := run(t, x, "i3_config_check", nil)
if err != nil || got.(map[string]any)["valid"] != true || got.(map[string]any)["running"] != nil {
t.Fatalf("the check needs no running i3: %v %v", got, err)
}
}
func TestTheWatcherReloadsOnceAfterAChangeAndNeverIntoErrors(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "config"), []byte("a"), 0o644)
var mu sync.Mutex
reloads, broken := 0, false
w := &Watcher{Dir: dir, Every: 20 * time.Millisecond,
Check: func() ([]ConfigError, error) {
mu.Lock()
defer mu.Unlock()
if broken {
return []ConfigError{{Text: "bad"}}, nil
}
return nil, nil
},
Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil },
}
stop := make(chan struct{})
defer close(stop)
go w.Run(stop)
time.Sleep(60 * time.Millisecond)
os.MkdirAll(filepath.Join(dir, "config.d"), 0o755)
os.WriteFile(filepath.Join(dir, "config.d", "50-x.conf"), []byte("b"), 0o644)
os.WriteFile(filepath.Join(dir, "config"), []byte("aa"), 0o644)
waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return reloads == 1 })
time.Sleep(100 * time.Millisecond)
mu.Lock()
if reloads != 1 {
t.Fatalf("one reload for one change of two files: %d", reloads)
}
broken = true
mu.Unlock()
os.WriteFile(filepath.Join(dir, "config"), []byte("aaa"), 0o644)
waitFor(t, func() bool { return len(w.Status().Refused) == 1 })
if s := w.Status(); s.Reloads != 1 || s.Files != 2 || !strings.Contains(s.LastProblem, "not reloaded") {
t.Fatalf("%+v", s)
}
}
func TestAtItsStartTheWatcherReloadsWhatTheRunningI3HasNotLoaded(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config")
os.WriteFile(cfg, []byte("new"), 0o644)
for _, c := range []struct {
loaded map[string]string
want int
}{
{map[string]string{cfg: "old"}, 1},
{map[string]string{cfg: "new"}, 0},
{map[string]string{cfg: "new", filepath.Join(dir, "config.d", "gone.conf"): "x"}, 1},
} {
var mu sync.Mutex
reloads := 0
w := &Watcher{Dir: dir, Every: 10 * time.Millisecond,
Check: func() ([]ConfigError, error) { return nil, nil },
Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil },
Loaded: func() (map[string]string, error) { return c.loaded, nil },
}
stop := make(chan struct{})
go w.Run(stop)
time.Sleep(80 * time.Millisecond)
close(stop)
mu.Lock()
if reloads != c.want {
t.Errorf("loaded %v: %d reloads, want %d", c.loaded, reloads, c.want)
}
mu.Unlock()
}
}
func waitFor(t *testing.T, ok func() bool) {
for i := 0; i < 200; i++ {
if ok() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("timed out")
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"i3/internal/desktop"
)
// i3's IPC (https://i3wm.org/docs/ipc.html): "i3-ipc", a little-endian length and type, a JSON
// payload; the reply carries the same header. Spoken directly, so the tools need no i3-msg and no
// display — only the socket, which lives under the account's runtime directory.
const (
RunCommand = 0
GetWorkspaces = 1
GetTree = 4
GetMarks = 5
GetVersion = 7
GetConfig = 9
)
const magic = "i3-ipc"
// Ask sends one message to i3 at socket and decodes its reply into out.
func Ask(socket string, kind uint32, payload string, out any) error {
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
if err != nil {
return err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
msg := make([]byte, 14+len(payload))
copy(msg, magic)
binary.LittleEndian.PutUint32(msg[6:], uint32(len(payload)))
binary.LittleEndian.PutUint32(msg[10:], kind)
copy(msg[14:], payload)
if _, err := conn.Write(msg); err != nil {
return err
}
head := make([]byte, 14)
if _, err := io.ReadFull(conn, head); err != nil {
return fmt.Errorf("i3 did not answer: %w", err)
}
if string(head[:6]) != magic {
return errors.New("the socket's answer is not i3's")
}
n := binary.LittleEndian.Uint32(head[6:])
if n > 64<<20 {
return fmt.Errorf("an answer of %d bytes", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(conn, body); err != nil {
return err
}
if got := binary.LittleEndian.Uint32(head[10:]); got != kind {
return fmt.Errorf("asked %d, answered %d", kind, got)
}
return json.Unmarshal(body, out)
}
// FindSocket is the running i3's IPC socket. The session's I3SOCK when its process carries one;
// else the newest `ipc-socket.<pid>` under the runtime directory whose i3 is alive and answers.
func FindSocket(runtimeDir, i3sock string) (string, error) {
if i3sock != "" {
if _, err := os.Stat(i3sock); err == nil {
return i3sock, nil
}
}
matches, _ := filepath.Glob(filepath.Join(runtimeDir, "i3", "ipc-socket.*"))
type cand struct {
path string
pid int
}
var alive []cand
for _, m := range matches {
pid, err := strconv.Atoi(strings.TrimPrefix(filepath.Ext(m), "."))
if err != nil {
continue
}
if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))); err != nil {
continue
}
alive = append(alive, cand{m, pid})
}
sort.Slice(alive, func(i, j int) bool { return alive[i].pid > alive[j].pid })
for _, c := range alive {
var v map[string]any
if Ask(c.path, GetVersion, "", &v) == nil {
return c.path, nil
}
}
return "", &desktop.NoSession{
Reason: "i3 is not running: no live IPC socket",
Looked: []string{filepath.Join(runtimeDir, "i3", "ipc-socket.*")},
}
}
// Outcome is one command's result as i3 answers RUN_COMMAND.
type Outcome struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// Quote makes a value safe inside an i3 command: double quotes, with backslashes and quotes escaped.
func Quote(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
+191
View File
@@ -0,0 +1,191 @@
// i3's tools (novox/hq ADR 0208, research 026/05): node-display-session's verbs `reload`, `workspaces`
// and `windows`, the module's own tools for focus, moving, layouts, exec, kill, bindings, the
// configuration check, marks and the scratchpad — and, running for as long as the bundle does, the
// watcher that reloads i3 when its configuration changes (ADR 0198).
//
// The tools speak i3's IPC on its socket under the account's runtime directory; they need no display.
// With no i3 running they answer that there is no session, as structured data.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"i3/internal/desktop"
)
func main() {
t := machine()
w := &Watcher{
Dir: t.configDir, Every: 2 * time.Second,
Check: func() ([]ConfigError, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return t.check(ctx)
},
Reload: func() error {
_, err := t.command("reload")
return err
},
Loaded: t.loaded,
}
t.watcher = w
go w.Run(make(chan struct{}))
if err := stdio.Serve("", tools(t)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// machine is the i3 of this machine's operator account.
func machine() *i3 {
home := desktop.Home()
cfg := os.Getenv("XDG_CONFIG_HOME")
if cfg == "" {
cfg = filepath.Join(home, ".config")
}
state := os.Getenv("XDG_STATE_HOME")
if state == "" {
state = filepath.Join(home, ".local", "state")
}
t := &i3{d: desktop.Machine("i3"), configDir: filepath.Join(cfg, "i3"), layouts: filepath.Join(state, "mesh", "i3", "layouts")}
t.socket = func() (string, error) {
runtime := filepath.Join("/run/user", fmt.Sprint(os.Getuid()))
i3sock := ""
if s, err := t.d.Find(); err == nil {
runtime, i3sock = s.RuntimeDir, s.Word("I3SOCK")
}
return FindSocket(runtime, i3sock)
}
return t
}
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return run(ctx, desktop.Args(args))
}
}
func tools(t *i3) []stdio.Tool {
return []stdio.Tool{
{
Name: "node-display-session.reload",
Description: "Reload i3's configuration in place, keeping every window: checked first with i3 -C, and " +
"refused with the errors when it would not load cleanly (force reloads anyway). Answers i3's result " +
"and what the reload watcher has done.",
Input: desktop.Schema(map[string]any{"force": desktop.Flag("reload even when the check finds errors")}),
Run: call(t.reload),
},
{
Name: "node-display-session.workspaces",
Description: "The session's workspaces: number, name, output, and whether each is visible, focused or urgent.",
Input: desktop.Schema(map[string]any{}),
Run: call(t.workspaces),
},
{
Name: "node-display-session.windows",
Description: "The session's windows: container id, X id, class, instance, role, title, workspace and output, " +
"and whether each is focused, urgent, floating, fullscreen or in the scratchpad, with its marks. " +
"Narrowed to one workspace when named.",
Input: desktop.Schema(map[string]any{"workspace": desktop.Str("one workspace, by name (optional)")}),
Run: call(t.windows),
},
{
Name: "i3_focus",
Description: "Focus a window (by con_id, window, class, instance, title or mark) or a workspace (by name; " +
"created if absent, as i3 does).",
Input: desktop.Schema(criteriaProps(map[string]any{"workspace": desktop.Str("the workspace to show")})),
Run: call(t.focus),
},
{
Name: "i3_move",
Description: "Move windows (the focused one, or those the criteria match) to a workspace or an output; or, " +
"with workspace_to_output, move a whole workspace to an output.",
Input: desktop.Schema(criteriaProps(map[string]any{
"to_workspace": desktop.Str("the workspace to move to"),
"to_output": desktop.Str("the output to move to (a name, or left/right/up/down)"),
"workspace_to_output": desktop.Str("move this workspace (by name) to to_output instead of a window"),
})),
Run: call(t.move),
},
{
Name: "i3_layout_save",
Description: "Save a workspace's arrangement (the focused one when none is named) as a named layout: its " +
"containers, splits and shares, each window as a placeholder for the next window of its class, " +
"instance and role. Kept in the account's state directory (~/.local/state/mesh/i3/layouts).",
Input: desktop.Schema(map[string]any{
"name": desktop.Str("the layout's name"),
"workspace": desktop.Str("the workspace to save (optional; the focused one)"),
}, "name"),
Run: call(t.layoutSave),
},
{
Name: "i3_layout_restore",
Description: "Lay a saved layout onto a workspace (the one it was saved from, unless named): its placeholders " +
"wait there and swallow matching windows as they open. With layout list, answers the saved layouts.",
Input: desktop.Schema(map[string]any{
"name": desktop.Str("the layout's name, or list"),
"workspace": desktop.Str("the workspace to lay it on (optional)"),
}, "name"),
Run: call(t.layoutRestore),
},
{
Name: "i3_exec",
Description: "Start a program in the session, through i3 (so it is the session's child, with the session's " +
"environment, and outlives the call). Optionally on a given workspace.",
Input: desktop.Schema(map[string]any{
"command": desktop.Str("the command line, as a shell reads it"),
"workspace": desktop.Str("switch to this workspace first (optional)"),
}, "command"),
Run: call(t.exec),
},
{
Name: "i3_kill",
Description: "Close the windows the criteria match (politely, as the window's close button), or the focused " +
"one when focused is true. Refused with neither, so a call without arguments closes nothing.",
Input: desktop.Schema(criteriaProps(map[string]any{
"focused": desktop.Flag("close the focused window"),
"force": desktop.Flag("kill the client instead of asking the window to close"),
})),
Run: call(t.kill),
},
{
Name: "i3_bindings",
Description: "Every key binding in force and what it runs, by mode, from the configuration i3 loaded " +
"(main file and drop-ins, variables replaced), with the file each comes from. Narrowed by a text " +
"found in the keys or the command.",
Input: desktop.Schema(map[string]any{"match": desktop.Str("only bindings whose keys or command contain this (optional)")}),
Run: call(t.bindings),
},
{
Name: "i3_config_check",
Description: "Check the configuration on disk (main file and every drop-in) with i3 -C, as the next reload " +
"would load it: valid or the errors with file and line; the files i3 loaded last; and what the " +
"reload watcher has done. Needs no running session.",
Input: desktop.Schema(map[string]any{}),
Run: call(t.configCheck),
},
{
Name: "i3_marks",
Description: "Every mark set on a window, with the window it is on.",
Input: desktop.Schema(map[string]any{}),
Run: call(t.marks),
},
{
Name: "i3_scratchpad",
Description: "The scratchpad: list its windows; show (toggle) one — the criteria pick it, else i3 cycles; or " +
"move a window (the criteria's, else the focused one) into it.",
Input: desktop.Schema(criteriaProps(map[string]any{
"action": desktop.Enum("list (default), show or move", "list", "show", "move"),
})),
Run: call(t.scratchpad),
},
}
}
+205
View File
@@ -0,0 +1,205 @@
package main
// i3's shape (novox/hq ADR 0208): it holds node-display-session and requires the X display on its own
// machine; it contributes the session's exec to the last xinitrc slot and the desktop's identity to
// the environment; it owns the main configuration, which ends by including the drop-in directory, and
// the login manager's session entry, which runs the session's start.
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
type manifest struct {
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
Seats any `json:"seats"`
Tools []string
Environment struct {
Variables map[string]string `json:"variables"`
} `json:"environment"`
Shell []struct {
For, Slot, Code string
} `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func resource(t *testing.T, m manifest, id string) map[string]any {
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
func TestItHoldsTheSessionSeatOnAMachineWithAnXDisplay(t *testing.T) {
m := readManifest(t)
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-session" || strings.Join(m.Claims[0].Serves, ",") != "reload,workspaces,windows" {
t.Fatalf("%+v", m.Claims)
}
if strings.Join(m.Requires, ",") != "x11-display" {
t.Fatalf("requires %v", m.Requires)
}
served := map[string]bool{}
for _, tool := range tools(&i3{}) {
served[tool.Name] = true
}
for _, v := range m.Claims[0].Serves {
if !served["node-display-session."+v] {
t.Errorf("the seat's %s is not served", v)
}
}
if len(served) != len(m.Tools)+3 {
t.Fatalf("served %d, listed %d", len(served), len(m.Tools))
}
for _, n := range m.Tools {
if !served[n] || !strings.HasPrefix(n, "i3_") {
t.Errorf("%s", n)
}
}
}
func TestItContributesTheSessionsExecLastAndTheDesktopsIdentity(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "last" {
t.Fatalf("%+v", m.Shell)
}
var code []string
for _, l := range strings.Split(strings.TrimSpace(m.Shell[0].Code), "\n") {
if !strings.HasPrefix(l, "#") {
code = append(code, l)
}
}
if len(code) != 1 || !strings.HasPrefix(code[0], "exec i3") {
t.Fatalf("one line, the exec: %q", code)
}
if v := m.Environment.Variables; len(v) != 2 || v["XDG_CURRENT_DESKTOP"] != "i3" || v["XDG_SESSION_DESKTOP"] != "i3" {
t.Fatalf("%v", v)
}
}
func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testing.T) {
m := readManifest(t)
r := resource(t, m, "config")
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
if r["content"] != string(raw) || r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil {
t.Fatal("the manifest carries config/config whole, as an owned file")
}
c := string(raw)
lines := strings.Split(strings.TrimSpace(c), "\n")
if lines[len(lines)-1] != "include ~/.config/i3/config.d/*.conf" {
t.Fatal("the drop-ins are read last, with every variable in scope")
}
var lines2 []string
for _, l := range lines {
if !strings.HasPrefix(strings.TrimSpace(l), "#") {
lines2 = append(lines2, l)
}
}
code := strings.Join(lines2, "\n")
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
if strings.Contains(code, gone) {
t.Errorf("the configuration still holds %q", gone)
}
}
if !strings.Contains(c, "\nfont pango:JetBrainsMono Nerd Font 11\n") || !strings.Contains(c, "exec --no-startup-id dex --autostart --environment i3") {
t.Fatal("the chosen face for titles; XDG autostart through dex")
}
if !strings.Contains(c, "bindsym $mod+Delete exec --no-startup-id loginctl lock-session") {
t.Fatal("the lock key goes through logind, which the lock screen's module relies on")
}
if i3, err := exec.LookPath("i3"); err == nil {
out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("i3 -C: %v %s", err, out)
}
}
}
func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
m := readManifest(t)
r := resource(t, m, "session")
if r["path"] != "/etc/lemurs/wms/i3" || r["mode"] != "0755" {
t.Fatalf("%v", r)
}
if !strings.Contains(r["content"].(string), `exec /bin/sh "$HOME/.xinitrc"`) {
t.Fatal("the entry runs the session's start, never i3 bare")
}
pkgs := map[string]bool{}
for _, x := range m.Resources {
if x["type"] == "package" {
pkgs[x["package"].(string)] = true
}
}
if !pkgs["i3-wm"] || !pkgs["dex"] || len(pkgs) != 2 {
t.Fatalf("%v", pkgs)
}
a := m.Build.Artifacts[0]
if a["from"] != "cmd/i3-tools" || a["binary"] != "i3-tools" || a["language"] != "go" {
t.Fatalf("a binary not named i3, so nothing that looks for i3 by name finds the tools: %v", a)
}
}
// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as
// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses.
func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
dir := t.TempDir()
drop := filepath.Join(dir, "config.d")
os.MkdirAll(drop, 0o755)
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
var found []string
for _, p := range manifests {
raw, _ := os.ReadFile(p)
var m struct {
Resources []map[string]any `json:"resources"`
}
json.Unmarshal(raw, &m)
for _, r := range m.Resources {
path, _ := r["path"].(string)
if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") {
os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644)
found = append(found, filepath.Base(path))
}
}
}
main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1)
os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644)
out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out)
}
}
+428
View File
@@ -0,0 +1,428 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"i3/internal/desktop"
)
type i3 struct {
d desktop.Desk
socket func() (string, error)
configDir string
layouts string
watcher *Watcher
}
func (t *i3) ask(kind uint32, payload string, out any) error {
sock, err := t.socket()
if err != nil {
return err
}
return Ask(sock, kind, payload, out)
}
// command runs i3 commands and fails when any of them did.
func (t *i3) command(cmd string) ([]Outcome, error) {
var out []Outcome
if err := t.ask(RunCommand, cmd, &out); err != nil {
return nil, err
}
for _, o := range out {
if !o.Success {
return out, fmt.Errorf("i3 refused %q: %s", cmd, o.Error)
}
}
return out, nil
}
func (t *i3) tree() (Node, error) {
var root Node
err := t.ask(GetTree, "", &root)
return root, err
}
// check runs `i3 -C` on the configuration on disk. It needs no display.
func (t *i3) check(ctx context.Context) ([]ConfigError, error) {
res := t.d.Plain(ctx, "i3", "-C", "-c", filepath.Join(t.configDir, "config"))
if res.Code == 127 {
return nil, fmt.Errorf("i3 is not installed here")
}
problems := append([]ConfigError{}, ParseCheck(res.Stdout+"\n"+res.Stderr)...)
if !res.OK() && len(problems) == 0 {
problems = []ConfigError{{Text: strings.TrimSpace(res.Stdout + res.Stderr)}}
}
return problems, nil
}
func (t *i3) watcherStatus() any {
if t.watcher == nil {
return nil
}
return t.watcher.Status()
}
func (t *i3) reload(ctx context.Context, a desktop.Args) (any, error) {
force, _, err := a.Bool("force")
if err != nil {
return nil, err
}
problems, err := t.check(ctx)
if err != nil {
return nil, err
}
if len(problems) > 0 && !force {
return map[string]any{"reloaded": false, "errors": problems, "why": "the configuration on disk has errors; fix them, or force"}, nil
}
out, err := t.command("reload")
if err != nil {
return nil, err
}
return map[string]any{"reloaded": true, "i3": out, "errors": problems, "watcher": t.watcherStatus()}, nil
}
// WorkspaceInfo is one workspace as GET_WORKSPACES answers it.
type WorkspaceInfo struct {
Num int `json:"num"`
Name string `json:"name"`
Output string `json:"output"`
Visible bool `json:"visible"`
Focused bool `json:"focused"`
Urgent bool `json:"urgent"`
}
func (t *i3) workspaces(ctx context.Context, a desktop.Args) (any, error) {
var ws []WorkspaceInfo
if err := t.ask(GetWorkspaces, "", &ws); err != nil {
return nil, err
}
return map[string]any{"workspaces": ws}, nil
}
func (t *i3) windows(ctx context.Context, a desktop.Args) (any, error) {
root, err := t.tree()
if err != nil {
return nil, err
}
all := Windows(root)
ws := a.Opt("workspace", "")
out := []Window{}
for _, w := range all {
if ws == "" || w.Workspace == ws {
out = append(out, w)
}
}
return map[string]any{"windows": out}, nil
}
func (t *i3) focus(ctx context.Context, a desktop.Args) (any, error) {
crit, err := Criteria(a)
if err != nil {
return nil, err
}
var cmd string
switch ws := a.Opt("workspace", ""); {
case crit != "" && ws != "":
return nil, fmt.Errorf("a window or a workspace, not both")
case crit != "":
cmd = crit + " focus"
case ws != "":
cmd = "workspace " + Quote(ws)
default:
return nil, fmt.Errorf("name a window (con_id, window, class, instance, title, mark) or a workspace")
}
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
}
var direction = regexp.MustCompile(`^(left|right|up|down|current|primary|next|prev)$`)
func target(output string) string {
if direction.MatchString(output) {
return output
}
return Quote(output)
}
func (t *i3) move(ctx context.Context, a desktop.Args) (any, error) {
crit, err := Criteria(a)
if err != nil {
return nil, err
}
toWS, toOut, wsMove := a.Opt("to_workspace", ""), a.Opt("to_output", ""), a.Opt("workspace_to_output", "")
var cmd string
switch {
case wsMove != "":
if toOut == "" {
return nil, fmt.Errorf("workspace_to_output needs to_output")
}
cmd = "[workspace=" + Quote("^"+regexp.QuoteMeta(wsMove)+"$") + "] move workspace to output " + target(toOut)
case toWS != "" && toOut != "":
return nil, fmt.Errorf("to a workspace or to an output, not both")
case toWS != "":
cmd = strings.TrimSpace(crit + " move container to workspace " + Quote(toWS))
case toOut != "":
cmd = strings.TrimSpace(crit + " move container to output " + target(toOut))
default:
return nil, fmt.Errorf("name to_workspace or to_output")
}
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
}
var layoutName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
func (t *i3) layoutSave(ctx context.Context, a desktop.Args) (any, error) {
name, err := a.Text("name")
if err != nil {
return nil, err
}
if !layoutName.MatchString(name) {
return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore")
}
root, err := t.tree()
if err != nil {
return nil, err
}
ws, ok := Workspace(root, a.Opt("workspace", ""))
if !ok {
return nil, fmt.Errorf("no workspace %q", a.Opt("workspace", "(focused)"))
}
top, windows := Layout(ws)
if windows == 0 {
return nil, fmt.Errorf("workspace %s holds no windows to save", str(ws.Name))
}
if err := os.MkdirAll(t.layouts, 0o755); err != nil {
return nil, err
}
path := filepath.Join(t.layouts, name+".json")
if err := os.WriteFile(path, []byte(LayoutFile(str(ws.Name), time.Now().Format(time.RFC3339), top)), 0o644); err != nil {
return nil, err
}
return map[string]any{"name": name, "workspace": str(ws.Name), "windows": windows, "path": path}, nil
}
func (t *i3) savedLayouts() []map[string]string {
files, _ := filepath.Glob(filepath.Join(t.layouts, "*.json"))
sort.Strings(files)
out := []map[string]string{}
for _, f := range files {
b, _ := os.ReadFile(f)
out = append(out, map[string]string{"name": strings.TrimSuffix(filepath.Base(f), ".json"), "workspace": SavedWorkspace(string(b)), "path": f})
}
return out
}
func (t *i3) layoutRestore(ctx context.Context, a desktop.Args) (any, error) {
name, err := a.Text("name")
if err != nil {
return nil, err
}
if name == "list" {
return map[string]any{"layouts": t.savedLayouts()}, nil
}
if !layoutName.MatchString(name) {
return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore")
}
path := filepath.Join(t.layouts, name+".json")
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("no saved layout %q (i3_layout_restore with name list shows them)", name)
}
ws := a.Opt("workspace", SavedWorkspace(string(b)))
if ws == "" {
return nil, fmt.Errorf("name the workspace to lay it on")
}
cmd := "workspace " + Quote(ws) + "; append_layout " + Quote(path)
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"layout": name, "workspace": ws, "i3": out,
"then": "each placeholder swallows the next window of its class as it opens; start the programs (i3_exec) to fill them"}, nil
}
func (t *i3) exec(ctx context.Context, a desktop.Args) (any, error) {
command, err := a.Text("command")
if err != nil {
return nil, err
}
cmd := "exec --no-startup-id " + Quote(command)
if ws := a.Opt("workspace", ""); ws != "" {
cmd = "workspace " + Quote(ws) + "; " + cmd
}
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"started": command, "i3": out, "how": "a child of i3, in the session; i3 does not answer whether the program itself started"}, nil
}
func (t *i3) kill(ctx context.Context, a desktop.Args) (any, error) {
crit, err := Criteria(a)
if err != nil {
return nil, err
}
focused, _, err := a.Bool("focused")
if err != nil {
return nil, err
}
if crit == "" && !focused {
return nil, fmt.Errorf("name the windows to close, or focused: true")
}
force, _, _ := a.Bool("force")
cmd := strings.TrimSpace(crit + " kill")
if force {
cmd += " client"
}
before, _ := t.tree()
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out, "windows_before": len(Windows(before))}, nil
}
type configReply struct {
Config string `json:"config"`
Included []struct {
Path string `json:"path"`
Raw string `json:"raw_contents"`
Replaced string `json:"variable_replaced_contents"`
} `json:"included_configs"`
}
// loaded is each file the running i3 loaded, with the text it read.
func (t *i3) loaded() (map[string]string, error) {
var c configReply
if err := t.ask(GetConfig, "", &c); err != nil {
return nil, err
}
out := map[string]string{}
for _, inc := range c.Included {
out[inc.Path] = inc.Raw
}
if len(out) == 0 {
out[filepath.Join(t.configDir, "config")] = c.Config
}
return out, nil
}
func (t *i3) bindings(ctx context.Context, a desktop.Args) (any, error) {
var c configReply
if err := t.ask(GetConfig, "", &c); err != nil {
return nil, err
}
var all []Binding
if len(c.Included) == 0 {
all = Bindings("(main)", c.Config)
}
for _, inc := range c.Included {
all = append(all, Bindings(inc.Path, inc.Replaced)...)
}
match := strings.ToLower(a.Opt("match", ""))
out := []Binding{}
for _, b := range all {
if match == "" || strings.Contains(strings.ToLower(b.Keys+" "+b.Command), match) {
out = append(out, b)
}
}
return map[string]any{"bindings": out, "from": "the configuration i3 loaded at its last start or reload"}, nil
}
func (t *i3) configCheck(ctx context.Context, a desktop.Args) (any, error) {
problems, err := t.check(ctx)
if err != nil {
return nil, err
}
answer := map[string]any{"valid": len(problems) == 0, "errors": problems, "config": filepath.Join(t.configDir, "config"),
"on_disk": sortedKeys(Watched(t.configDir)), "watcher": t.watcherStatus()}
var v struct {
Loaded string `json:"loaded_config_file_name"`
Included []string `json:"included_config_file_names"`
Human string `json:"human_readable"`
}
if err := t.ask(GetVersion, "", &v); err == nil {
answer["running"] = map[string]any{"version": v.Human, "loaded": append([]string{v.Loaded}, v.Included...)}
}
return answer, nil
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func (t *i3) marks(ctx context.Context, a desktop.Args) (any, error) {
root, err := t.tree()
if err != nil {
return nil, err
}
type marked struct {
Mark string `json:"mark"`
Window Window `json:"window"`
}
out := []marked{}
for _, w := range Windows(root) {
for _, m := range w.Marks {
out = append(out, marked{m, w})
}
}
return map[string]any{"marks": out}, nil
}
func (t *i3) scratchpad(ctx context.Context, a desktop.Args) (any, error) {
action, err := a.OneOf("action", "list", "list", "show", "move")
if err != nil {
return nil, err
}
crit, err := Criteria(a)
if err != nil {
return nil, err
}
switch action {
case "list":
root, err := t.tree()
if err != nil {
return nil, err
}
out := []Window{}
for _, w := range Windows(root) {
if w.Scratchpad {
out = append(out, w)
}
}
return map[string]any{"scratchpad": out}, nil
case "show":
cmd := strings.TrimSpace(crit + " scratchpad show")
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
default:
cmd := strings.TrimSpace(crit + " move scratchpad")
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
}
}
+261
View File
@@ -0,0 +1,261 @@
package main
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"i3/internal/desktop"
)
// Node is a container of i3's tree, as GET_TREE answers it.
type Node struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name *string `json:"name"`
Layout string `json:"layout"`
Orientation string `json:"orientation"`
Percent *float64 `json:"percent"`
Border string `json:"border"`
BorderWidth int `json:"current_border_width"`
Floating string `json:"floating"`
Focused bool `json:"focused"`
Urgent bool `json:"urgent"`
Marks []string `json:"marks"`
Window *int64 `json:"window"`
WindowProperties map[string]any `json:"window_properties"`
Fullscreen int `json:"fullscreen_mode"`
Scratchpad string `json:"scratchpad_state"`
Geometry map[string]int `json:"geometry"`
Rect map[string]int `json:"rect"`
Nodes []Node `json:"nodes"`
FloatingNodes []Node `json:"floating_nodes"`
}
// Window is one window, as the windows verb answers it.
type Window struct {
ConID int64 `json:"con_id"`
Window string `json:"window"`
Class string `json:"class,omitempty"`
Instance string `json:"instance,omitempty"`
Role string `json:"role,omitempty"`
Title string `json:"title"`
Workspace string `json:"workspace"`
Output string `json:"output"`
Focused bool `json:"focused,omitempty"`
Urgent bool `json:"urgent,omitempty"`
Floating bool `json:"floating,omitempty"`
Fullscreen bool `json:"fullscreen,omitempty"`
Scratchpad bool `json:"scratchpad,omitempty"`
Marks []string `json:"marks,omitempty"`
}
func str(p *string) string {
if p == nil {
return ""
}
return *p
}
func prop(n Node, key string) string {
if v, ok := n.WindowProperties[key].(string); ok {
return v
}
return ""
}
// Windows is every window of the tree with the workspace and output it is on. The scratchpad's
// windows are on the workspace "__i3_scratch" and say so; the bars, in the dock areas, are not windows
// a person arranges and are left out.
func Windows(root Node) []Window {
var out []Window
var walk func(n Node, output, workspace string, dock, floating bool)
walk = func(n Node, output, workspace string, dock, floating bool) {
switch n.Type {
case "output":
output = str(n.Name)
case "workspace":
workspace = str(n.Name)
case "dockarea":
dock = true
case "floating_con":
floating = true
}
if n.Window != nil && !dock {
out = append(out, Window{
ConID: n.ID, Window: "0x" + strconv.FormatInt(*n.Window, 16),
Class: prop(n, "class"), Instance: prop(n, "instance"), Role: prop(n, "window_role"),
Title: str(n.Name), Workspace: workspace, Output: output,
Focused: n.Focused, Urgent: n.Urgent, Floating: floating || strings.HasSuffix(n.Floating, "_on"),
Fullscreen: n.Fullscreen != 0, Scratchpad: workspace == "__i3_scratch", Marks: n.Marks,
})
}
for _, c := range n.Nodes {
walk(c, output, workspace, dock, floating)
}
for _, c := range n.FloatingNodes {
walk(c, output, workspace, dock, true)
}
}
walk(root, "", "", false, false)
return out
}
// Workspace is the subtree of one workspace, by name; or the focused one's when name is empty.
func Workspace(root Node, name string) (Node, bool) {
var found *Node
var walk func(n Node, ws *Node)
walk = func(n Node, ws *Node) {
if found != nil {
return
}
if n.Type == "workspace" {
nn := n
ws = &nn
if name != "" && str(n.Name) == name {
found = ws
return
}
}
if name == "" && n.Focused && ws != nil {
found = ws
return
}
for _, c := range append(append([]Node{}, n.Nodes...), n.FloatingNodes...) {
walk(c, ws)
}
}
walk(root, nil)
if found == nil {
return Node{}, false
}
return *found, true
}
// Layout is a workspace's arrangement in the form i3's append_layout reads (i3's layout saving
// docs): each container with its layout, share and border, each window replaced by a placeholder that
// swallows the next window matching its class, instance and role. One JSON object per top-level
// container, as i3-save-tree writes them.
func Layout(ws Node) ([]map[string]any, int) {
windows := 0
var conv func(n Node) map[string]any
conv = func(n Node) map[string]any {
out := map[string]any{"border": n.Border, "current_border_width": n.BorderWidth, "floating": n.Floating, "layout": n.Layout, "type": "con"}
if n.Percent != nil {
out["percent"] = *n.Percent
}
if n.Name != nil {
out["name"] = *n.Name
}
if len(n.Marks) > 0 {
out["marks"] = n.Marks
}
if n.Window != nil {
windows++
swallow := map[string]string{}
for key, field := range map[string]string{"class": "class", "instance": "instance", "window_role": "window_role"} {
if v := prop(n, key); v != "" {
swallow[field] = "^" + regexp.QuoteMeta(v) + "$"
}
}
out["swallows"] = []map[string]string{swallow}
return out
}
var kids []map[string]any
for _, c := range n.Nodes {
kids = append(kids, conv(c))
}
if kids != nil {
out["nodes"] = kids
}
return out
}
var top []map[string]any
for _, c := range ws.Nodes {
top = append(top, conv(c))
}
for _, f := range ws.FloatingNodes {
fc := conv(f)
fc["type"] = "floating_con"
if g := f.Rect; g != nil {
fc["rect"] = g
}
top = append(top, fc)
}
return top, windows
}
// LayoutFile is a saved layout as written to disk: a comment naming the workspace, then the objects.
func LayoutFile(workspace, saved string, top []map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "// mesh i3 layout of workspace %s, saved %s\n", strconv.Quote(workspace), saved)
for _, t := range top {
raw, _ := json.MarshalIndent(t, "", " ")
b.Write(raw)
b.WriteString("\n")
}
return b.String()
}
var savedWorkspace = regexp.MustCompile(`^// mesh i3 layout of workspace ("(?:[^"\\]|\\.)*")`)
// SavedWorkspace is the workspace a saved layout was taken from.
func SavedWorkspace(file string) string {
if m := savedWorkspace.FindStringSubmatch(file); m != nil {
if s, err := strconv.Unquote(m[1]); err == nil {
return s
}
}
return ""
}
// Criteria builds i3's window criteria from a tool's arguments: con_id, window (X id), class,
// instance, title, mark, each quoted. Empty when none is given.
func Criteria(a desktop.Args) (string, error) {
var parts []string
if a.Has("con_id") {
f, _, err := a.Number("con_id")
if err != nil || f <= 0 {
return "", fmt.Errorf("con_id is a container's id, as windows answers it")
}
parts = append(parts, "con_id="+strconv.FormatInt(int64(f), 10))
}
if w := a.Opt("window", ""); w != "" {
n, err := strconv.ParseInt(strings.TrimPrefix(strings.ToLower(w), "0x"), 16, 64)
if err != nil || !strings.HasPrefix(strings.ToLower(w), "0x") {
return "", fmt.Errorf("window is an X id, e.g. 0x3a00007")
}
parts = append(parts, "id="+strconv.FormatInt(n, 10))
}
for _, k := range []string{"class", "instance", "title", "con_mark"} {
arg := k
if k == "con_mark" {
arg = "mark"
}
if v := a.Opt(arg, ""); v != "" {
parts = append(parts, k+"="+Quote(v))
}
}
if len(parts) == 0 {
return "", nil
}
return "[" + strings.Join(parts, " ") + "]", nil
}
// criteriaProps are the arguments every window-targeting tool takes.
func criteriaProps(extra map[string]any) map[string]any {
p := map[string]any{
"con_id": desktop.Int("the container's id, as windows answers it"),
"window": desktop.Str("the X window id, e.g. 0x3a00007"),
"class": desktop.Str("windows whose class matches this (a regular expression)"),
"instance": desktop.Str("windows whose instance matches this"),
"title": desktop.Str("windows whose title matches this"),
"mark": desktop.Str("the window holding this mark"),
}
for k, v := range extra {
p[k] = v
}
return p
}
+195
View File
@@ -0,0 +1,195 @@
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
# change the module instead. i3's user guide is the reference.
#
# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named
# <NN>-<module>.conf and read in name order by the include at the end, where every variable set here
# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.
#
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
# result with i3 -C; it never reloads into a configuration with errors.
# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).
font pango:JetBrainsMono Nerd Font 11
# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.
exec --no-startup-id dex --autostart --environment i3
#########################################
###### Keys ####
#########################################
# To find key symbols: xmodmap -pke / xmodmap -pm
set $mod Mod4
set $alt Mod1
set $shift Shift
set $ctrl Control
# use these keys for focus, movement, and resize directions when reaching for
# the arrows is not convenient
set $left h
set $down j
set $up k
set $right l
# use Mouse+$mod to drag floating windows to their wanted position
floating_modifier $mod
# move tiling windows via drag & drop by left-clicking into the title bar,
# or left-clicking anywhere into the window while holding the floating modifier.
tiling_drag modifier titlebar
# start a terminal: whichever the terminal module names in $TERMINAL
bindsym $mod+Return exec i3-sensible-terminal
# kill focused window
bindsym $mod+$shift+q kill
# change focus
bindsym $mod+$left focus left
bindsym $mod+$down focus down
bindsym $mod+$up focus up
bindsym $mod+$right focus right
bindsym $mod+Left focus left
bindsym $mod+Down focus down
bindsym $mod+Up focus up
bindsym $mod+Right focus right
# move focused window
bindsym $mod+$shift+$left move left
bindsym $mod+$shift+$down move down
bindsym $mod+$shift+$up move up
bindsym $mod+$shift+$right move right
bindsym $mod+$shift+Left move left
bindsym $mod+$shift+Down move down
bindsym $mod+$shift+Up move up
bindsym $mod+$shift+Right move right
# split in horizontal orientation
bindsym $mod+c split h
# split in vertical orientation
bindsym $mod+v split v
# enter fullscreen mode for the focused container
bindsym $mod+f fullscreen toggle
# change container layout (stacked, tabbed, toggle split)
bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed
bindsym $mod+e layout toggle split
# toggle tiling / floating
bindsym $mod+$shift+space floating toggle
# change focus between tiling / floating windows
bindsym $mod+space focus mode_toggle
# focus the parent container
bindsym $mod+a focus parent
# alt-tab functionality
bindsym $mod+Tab workspace back_and_forth
#########################################
###### Workspace mgmt ####
#########################################
set $ws1 "1"
set $ws2 "2"
set $ws3 "3"
set $ws4 "4"
set $ws5 "5"
set $ws6 "6"
set $ws7 "7"
set $ws8 "8"
set $ws9 "9"
set $ws10 "10"
bindsym $mod+1 workspace number $ws1
bindsym $mod+2 workspace number $ws2
bindsym $mod+3 workspace number $ws3
bindsym $mod+4 workspace number $ws4
bindsym $mod+5 workspace number $ws5
bindsym $mod+6 workspace number $ws6
bindsym $mod+7 workspace number $ws7
bindsym $mod+8 workspace number $ws8
bindsym $mod+9 workspace number $ws9
bindsym $mod+0 workspace number $ws10
bindsym $mod+$shift+1 move container to workspace number $ws1
bindsym $mod+$shift+2 move container to workspace number $ws2
bindsym $mod+$shift+3 move container to workspace number $ws3
bindsym $mod+$shift+4 move container to workspace number $ws4
bindsym $mod+$shift+5 move container to workspace number $ws5
bindsym $mod+$shift+6 move container to workspace number $ws6
bindsym $mod+$shift+7 move container to workspace number $ws7
bindsym $mod+$shift+8 move container to workspace number $ws8
bindsym $mod+$shift+9 move container to workspace number $ws9
bindsym $mod+$shift+0 move container to workspace number $ws10
#########################################
###### Window mgmt ####
#########################################
set $resize_px 10 px
bindsym $mod+$ctrl+$left resize shrink width $resize_px
bindsym $mod+$ctrl+$down resize grow height $resize_px
bindsym $mod+$ctrl+$up resize shrink height $resize_px
bindsym $mod+$ctrl+$right resize grow width $resize_px
#########################################
###### Session mgmt ####
#########################################
bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"
# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and
# suspend and lid close too).
bindsym $mod+Delete exec --no-startup-id loginctl lock-session
# reload the configuration file
bindsym $mod+$shift+c reload
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
bindsym $mod+$shift+r restart
#########################################
###### Borders ####
#########################################
default_border pixel 1
smart_borders on
#########################################
###### Gaps ####
#########################################
gaps inner 0
gaps outer 0
# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are
# `pixel`, so no title bar shows: the colour says which window has focus.
# border background text indicator child_border
client.focused #de5200 #de5200 #1E2127 #de5200 #de5200
client.urgent #900000 #900000 #ffffff #900000 #900000
#########################################
###### Until their modules carry them ##
#########################################
# Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line as its own drop-in in config.d, and the line goes from here in the same
# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs
# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).
# the peripherals' tray (the operator's application)
exec --no-startup-id polychromatic-tray-applet
# the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down
bindsym XF86AudioMute exec --no-startup-id volume-notify mute
bindsym XF86AudioMicMute exec --no-startup-id mic-notify
bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5
bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5
bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
#########################################
###### Other modules' drop-ins ####
#########################################
include ~/.config/i3/config.d/*.conf
+5
View File
@@ -0,0 +1,5 @@
module i3
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+160
View File
@@ -0,0 +1,160 @@
package desktop
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
)
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
type Args map[string]any
// Text is a required string, trimmed.
func (a Args) Text(name string) (string, error) {
v, ok := a[name].(string)
if !ok || strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is required, as text", name)
}
return strings.TrimSpace(v), nil
}
// Opt is an optional string, trimmed, or def.
func (a Args) Opt(name, def string) string {
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return def
}
// Has is whether the caller gave the argument at all.
func (a Args) Has(name string) bool {
v, ok := a[name]
return ok && v != nil
}
// Bool is an optional boolean: its value, and whether it was given.
func (a Args) Bool(name string) (bool, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return false, false, nil
}
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
return b, true, nil
}
// Number is an optional number: its value, and whether it was given.
func (a Args) Number(name string) (float64, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return 0, false, nil
}
f, isNum := v.(float64)
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false, fmt.Errorf("%s is a number", name)
}
return f, true, nil
}
// Whole is an optional whole number within [lo, hi], or def.
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
f, given, err := a.Number(name)
if err != nil {
return 0, err
}
if !given {
return def, nil
}
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
}
return int(f), nil
}
// OneOf is an optional string that must be one of choices, or def.
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
v := a.Opt(name, def)
for _, c := range choices {
if v == c {
return v, nil
}
}
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
}
// Strings is an optional list of strings.
func (a Args) Strings(name string) ([]string, error) {
v, ok := a[name]
if !ok || v == nil {
return nil, nil
}
list, isList := v.([]any)
if !isList {
return nil, fmt.Errorf("%s is a list of text", name)
}
out := make([]string, 0, len(list))
for _, x := range list {
s, isText := x.(string)
if !isText {
return nil, fmt.Errorf("%s is a list of text", name)
}
out = append(out, s)
}
return out, nil
}
// Home is the operator account's home: the runtime's word for it, else this process's.
func Home() string {
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
return h
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return "/"
}
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
func InHome(path string) (string, error) {
home := Home()
switch {
case path == "~":
path = home
case strings.HasPrefix(path, "~/"):
path = filepath.Join(home, path[2:])
case !filepath.IsAbs(path):
path = filepath.Join(home, path)
}
path = filepath.Clean(path)
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
return "", fmt.Errorf("%s is outside the account's home", path)
}
return path, nil
}
// Schema builds a tool's input schema from property descriptions; required names those that must
// be given. A property is a string unless its description object says otherwise.
func Schema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Str, Num, Flag, List and Enum describe one property.
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
func List(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
func Enum(desc string, values ...string) map[string]any {
return map[string]any{"type": "string", "enum": values, "description": desc}
}
@@ -0,0 +1,42 @@
package desktop
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
mine, err := filepath.Glob("*.go")
if err != nil || len(mine) == 0 {
t.Fatal("no files of this package found", err)
}
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
if _, err := os.Stat(dir); err != nil {
continue
}
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
if len(theirs) != len(mine) {
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
continue
}
for _, f := range mine {
a, _ := os.ReadFile(f)
b, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(a, b) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
+232
View File
@@ -0,0 +1,232 @@
package desktop
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
DefaultTimeout = 10 * time.Second
MostOutput = 256 << 10
)
// Result is what one command did.
type Result struct {
Command []string `json:"command"`
Code int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
}
// OK is whether the command ran and exited 0.
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
// Err is the command's failure as an error naming it and what it said, or nil.
func (r Result) Err() error {
if r.OK() {
return nil
}
said := strings.TrimSpace(r.Stderr)
if said == "" {
said = strings.TrimSpace(r.Stdout)
}
if r.TimedOut {
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
}
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
}
// Runner runs a command with an environment and answers what it did. Tools take one, so their
// tests replace the machine with a table of answers.
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
// Exec is the machine's Runner: the command in its own process group, ended with everything it
// started at the deadline, each stream cut at MostOutput.
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
}
res := Result{Command: append([]string{name}, args...)}
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
res.Code = 127
res.Stderr = err.Error()
return res
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
err = <-done
res.TimedOut = true
}
res.Stdout, res.Stderr = out.String(), errb.String()
res.Truncated = out.cut || errb.cut
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
res.Code = exit.ExitCode()
if res.Code < 0 {
res.Code = 128
}
default:
res.Code = 1
if res.Stderr == "" {
res.Stderr = err.Error()
}
}
return res
}
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
type Desk struct {
Find func() (*Session, error)
Run Runner
// Base is the environment a command starts from, before the session's words.
Base []string
}
// Machine is the real Desk, preferring the named processes as the session's.
func Machine(prefer ...string) Desk {
return Desk{
Find: func() (*Session, error) { return Find(prefer...) },
Run: Exec,
Base: os.Environ(),
}
}
// InSession runs a command in the operator's session, or answers NoSession.
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
}
// InSessionWith is InSession with standard input.
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
}
// Plain runs a command with the base environment: for what needs no session.
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, d.Base, nil, name, args...)
}
// AsUser runs a command with the account's own runtime directory and bus, and no display.
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
}
// Launched is how a program was started in the session.
type Launched struct {
Unit string `json:"unit,omitempty"`
PID int `json:"pid,omitempty"`
How string `json:"how"`
}
// Launch starts a program in the operator's session that outlives the call and the runtime.
//
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
// its control group, and the service manager ends that group whenever the runtime restarts — which
// is every push that changes it. So the program is handed to the account's own service manager as a
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
// operator's user manager does. Without a user manager it is started detached as a last resort, and
// the answer says it will end with the runtime.
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
if len(argv) == 0 {
return Launched{}, errors.New("nothing to launch")
}
env := s.Env(d.Base)
unit := "mesh-" + name + "-" + token()
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
if v := lookup(env, w); v != "" {
args = append(args, "--setenv="+w+"="+v)
}
}
args = append(args, "--")
args = append(args, argv...)
res := d.Run(ctx, env, nil, "systemd-run", args...)
if res.OK() {
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
}
if s.Bus != "" {
return Launched{}, res.Err()
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return Launched{}, err
}
pid := cmd.Process.Pid
go func() { _ = cmd.Wait() }()
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
}
func lookup(env []string, name string) string {
for i := len(env) - 1; i >= 0; i-- {
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
return v
}
}
return ""
}
func token() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+445
View File
@@ -0,0 +1,445 @@
// Package desktop is how a desktop module's tools act in the operator's graphical session
// (novox/hq ADR 0208, research 026/05).
//
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
// process of node-tools.service, started by the system's service manager as the operator account,
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
// was started elsewhere, by the login manager, and the only place its values are written down is
// the environment of the processes it started. So this package finds the session the way a person
// would: it looks at the operator account's own processes, takes the one that is plainly the
// session's (the window manager, or the oldest process carrying a display), confirms with logind
// that its session is a live local one, and checks that the display's socket is really there.
//
// **Only the session's own words are read.** A session's processes also carry whatever its start
// script exported — on the workstations that was a file of secrets — so the environment is filtered
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
//
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
// whenever it exists, because that is where the portal, the notifier and every user service
// listen. A session started on a private bus (a stale session, measured on one workstation) is
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
//
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
// every copy together — the modules' tests compare them.
package desktop
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// SessionWords are the only environment words read from a session's process: the ones that say
// where the session is. Everything else in that environment is the operator's, and is never read.
var SessionWords = []string{
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
"I3SOCK", "SWAYSOCK",
}
// Session is the operator's running graphical session, as a tool needs it.
type Session struct {
UID int `json:"uid"`
ID string `json:"session_id,omitempty"`
Type string `json:"type"`
Display string `json:"display,omitempty"`
WaylandDisplay string `json:"wayland_display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
RuntimeDir string `json:"runtime_dir"`
Bus string `json:"bus,omitempty"`
SessionBus string `json:"session_bus,omitempty"`
Desktop string `json:"desktop,omitempty"`
// FoundIn is the process whose environment named the session.
FoundIn Process `json:"found_in"`
// Active is logind's word on the session, when logind answered.
Active *bool `json:"active,omitempty"`
words map[string]string
}
// Process is one process the search looked at.
type Process struct {
PID int `json:"pid"`
Command string `json:"command"`
start uint64
}
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
// that returns it as its error still answers structured data.
type NoSession struct {
Reason string `json:"reason"`
Looked []string `json:"looked"`
}
func (e *NoSession) Error() string {
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
return string(b)
}
// IsNoSession is whether err says there is no session.
func IsNoSession(err error) bool {
var n *NoSession
return errors.As(err, &n)
}
// Finder holds where the search looks, so a test can point it at a tree of its own.
type Finder struct {
Proc string // the process table: /proc
X11Sockets string // where X servers listen: /tmp/.X11-unix
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
UID int // whose session
// Prefer names the processes that are the session's own, best first: the session's holder.
Prefer []string
// Logind answers `loginctl show-session` for one id; nil skips the check.
Logind func(id string) (map[string]string, error)
}
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
func DefaultFinder(prefer ...string) Finder {
uid := os.Getuid()
if uid == 0 {
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
if u, err := user.Lookup(name); err == nil {
if n, err := strconv.Atoi(u.Uid); err == nil {
uid = n
}
}
}
}
return Finder{
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
UID: uid, Prefer: prefer, Logind: loginctl,
}
}
// Find is the operator's session on this machine, preferring a process named in prefer.
func Find(prefer ...string) (*Session, error) {
return DefaultFinder(prefer...).Find()
}
type candidate struct {
proc Process
words map[string]string
rank int
logind map[string]string
}
// Find looks for the session.
func (f Finder) Find() (*Session, error) {
entries, err := os.ReadDir(f.Proc)
if err != nil {
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
}
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
var found []candidate
stale := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(f.Proc, e.Name())
info, err := os.Stat(dir)
if err != nil {
continue
}
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
continue
}
words := readWords(filepath.Join(dir, "environ"))
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
continue
}
if !f.reachable(words) {
stale++
continue
}
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
}
if len(found) == 0 {
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
if stale > 0 {
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
}
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
}
// logind's word on each session the candidates name, asked once per session.
asked := map[string]map[string]string{}
for i := range found {
id := found[i].words["XDG_SESSION_ID"]
if f.Logind == nil || id == "" {
found[i].rank = 1
continue
}
props, done := asked[id]
if !done {
props, _ = f.Logind(id)
asked[id] = props
}
found[i].logind = props
switch {
case props == nil:
found[i].rank = 1
case props["Remote"] == "yes":
found[i].rank = 3
case props["Active"] == "yes" && props["State"] != "closing":
found[i].rank = 0
case props["State"] == "closing":
found[i].rank = 3
default:
found[i].rank = 2
}
}
if f.Logind != nil {
looked = append(looked, "logind's sessions")
}
preferred := func(c candidate) int {
for i, p := range f.Prefer {
if c.proc.Command == p {
return i
}
}
return len(f.Prefer)
}
sort.SliceStable(found, func(i, j int) bool {
a, b := found[i], found[j]
if a.rank != b.rank {
return a.rank < b.rank
}
if pa, pb := preferred(a), preferred(b); pa != pb {
return pa < pb
}
if a.proc.start != b.proc.start {
return a.proc.start < b.proc.start
}
return a.proc.PID < b.proc.PID
})
best := found[0]
if best.rank == 3 {
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
}
return f.session(best), nil
}
func (f Finder) session(c candidate) *Session {
w := c.words
s := &Session{
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
}
s.Desktop = w["XDG_CURRENT_DESKTOP"]
if s.Desktop == "" {
s.Desktop = w["XDG_SESSION_DESKTOP"]
}
switch {
case w["XDG_SESSION_TYPE"] != "":
s.Type = w["XDG_SESSION_TYPE"]
case s.WaylandDisplay != "":
s.Type = "wayland"
default:
s.Type = "x11"
}
if s.RuntimeDir == "" {
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
s.SessionBus = own
}
if c.logind != nil {
active := c.logind["Active"] == "yes"
s.Active = &active
}
return s
}
// reachable is whether the display a process names is still served: the X server's socket, or the
// Wayland compositor's. A process outliving its session still carries the session's words.
func (f Finder) reachable(w map[string]string) bool {
if d := w["WAYLAND_DISPLAY"]; d != "" {
path := d
if !filepath.IsAbs(d) {
dir := w["XDG_RUNTIME_DIR"]
if dir == "" {
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
path = filepath.Join(dir, d)
}
if isSocket(path) {
return true
}
}
n, ok := DisplayNumber(w["DISPLAY"])
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
}
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
// another host — an ssh session's forwarded one — is not the local session and answers false.
func DisplayNumber(display string) (int, bool) {
host, rest, ok := strings.Cut(display, ":")
if !ok || (host != "" && host != "unix") {
return 0, false
}
num, _, _ := strings.Cut(rest, ".")
n, err := strconv.Atoi(num)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Word is one of the session's words as its process had it ("" when it had none).
func (s *Session) Word(name string) string { return s.words[name] }
// Env is base with the session's words in place of whatever base said for them.
func (s *Session) Env(base []string) []string {
drop := map[string]bool{}
for _, w := range SessionWords {
drop[w] = true
}
out := make([]string, 0, len(base)+8)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if !drop[k] {
out = append(out, kv)
}
}
bus := s.Bus
if bus == "" {
bus = s.SessionBus
}
for _, kv := range [][2]string{
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
} {
if kv[1] != "" {
out = append(out, kv[0]+"="+kv[1])
}
}
return out
}
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
// user manager or the session bus and needs no display — it works with no session at all.
func UserEnv(base []string, uid int) []string {
dir := filepath.Join("/run/user", strconv.Itoa(uid))
out := make([]string, 0, len(base)+2)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
out = append(out, kv)
}
}
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
}
// readWords reads a process's environment and keeps only SessionWords.
func readWords(path string) map[string]string {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
keep := map[string]bool{}
for _, w := range SessionWords {
keep[w] = true
}
out := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
k, v, ok := bytes.Cut(kv, []byte{'='})
if ok && keep[string(k)] {
out[string(k)] = string(v)
}
}
return out
}
func comm(dir string) string {
b, err := os.ReadFile(filepath.Join(dir, "comm"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
// Read after the command's closing parenthesis, because the command may hold spaces.
func startTime(dir string) uint64 {
b, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return ^uint64(0)
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return ^uint64(0)
}
fields := strings.Fields(string(b[i+1:]))
// fields[0] is the state, field 3 of the line; start time is field 22.
if len(fields) < 20 {
return ^uint64(0)
}
n, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return ^uint64(0)
}
return n
}
func isSocket(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode()&os.ModeSocket != 0
}
// loginctl asks logind about one session, by its property lines.
func loginctl(id string) (map[string]string, error) {
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
var out bytes.Buffer
cmd.Stdout = &out
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return nil, err
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
return nil, errors.New("loginctl did not answer in 3s")
}
return ParseProperties(out.String()), nil
}
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
func ParseProperties(text string) map[string]string {
out := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
out[k] = v
}
}
return out
}
+255
View File
@@ -0,0 +1,255 @@
package desktop
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
type fakeMachine struct {
t *testing.T
proc, x11, runtime string
uid int
}
func newMachine(t *testing.T) *fakeMachine {
root, err := os.MkdirTemp("", "desk")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return m
}
func (m *fakeMachine) socket(path string) {
l, err := net.Listen("unix", path)
if err != nil {
m.t.Fatal(err)
}
m.t.Cleanup(func() { l.Close() })
}
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
dir := filepath.Join(m.proc, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
// cutime cstime priority nice threads itrealvalue starttime ...
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
}
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
}
func active(id string) (map[string]string, error) {
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
}
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X1"))
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "bus"))
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
s, err := m.finder(active, "i3").Find()
if err != nil {
t.Fatal(err)
}
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
t.Fatalf("session: %+v", s)
}
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
}
if s.Active == nil || !*s.Active {
t.Fatal("logind's word is carried")
}
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
if !strings.Contains(env, want) {
t.Errorf("env lacks %s:\n%s", want, env)
}
}
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
}
b, _ := json.Marshal(s)
if strings.Contains(string(b), "secret") {
t.Fatal("the answer carries a word outside the session's")
}
}
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
s, err := m.finder(nil).Find()
if err != nil || s.FoundIn.PID != 400 {
t.Fatalf("%+v %v", s, err)
}
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
}
}
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
m := newMachine(t)
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
_, err := m.finder(active, "i3").Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
t.Fatalf("%v", err)
}
var answer map[string]any
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
t.Fatalf("the refusal is structured: %s", err)
}
}
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
m := newMachine(t)
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
_, err := m.finder(active).Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
t.Fatalf("%v", err)
}
}
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.socket(filepath.Join(m.x11, "X1"))
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
logind := func(id string) (map[string]string, error) {
if id == "a" {
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
}
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
}
s, err := m.finder(logind, "i3").Find()
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
t.Fatalf("the active session: %+v %v", s, err)
}
remote := func(string) (map[string]string, error) {
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
}
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
t.Fatalf("a remote session is not the operator's desktop: %v", err)
}
}
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
m := newMachine(t)
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "wayland-1"))
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
s, err := m.finder(nil, "sway").Find()
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
t.Fatalf("%+v %v", s, err)
}
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
t.Fatal("the compositor's socket word passes")
}
}
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
if _, ok := DisplayNumber(d); ok != want {
t.Errorf("%q: %v", d, ok)
}
}
}
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
if !r.OK() || r.Stdout != "hello" {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
if r.OK() || r.Code != 127 {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
if !r.Truncated || len(r.Stdout) != MostOutput {
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
}
}
func TestArgumentsAreReadStrictly(t *testing.T) {
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if v, err := a.Text("name"); err != nil || v != "x" {
t.Fatal(v, err)
}
if _, err := a.Text("missing"); err == nil {
t.Fatal("a missing required text")
}
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
t.Fatal(n, err)
}
if _, err := a.Whole("f", 0, 0, 5); err == nil {
t.Fatal("1.5 is not whole")
}
if _, err := a.Whole("n", 0, 4, 5); err == nil {
t.Fatal("out of range")
}
if b, given, err := a.Bool("b"); !b || !given || err != nil {
t.Fatal("bool")
}
if _, _, err := a.Bool("name"); err == nil {
t.Fatal("text is not a bool")
}
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
t.Fatal(l, err)
}
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
t.Fatal("not one of")
}
}
func TestAPathIsKeptInsideTheHome(t *testing.T) {
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
if got, err := InHome(in); err != nil || got != want {
t.Errorf("%s: %s %v", in, got, err)
}
}
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
if _, err := InHome(out); err == nil {
t.Errorf("%s was accepted", out)
}
}
}
func TestPropertiesAreParsed(t *testing.T) {
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
t.Fatal(p)
}
}
File diff suppressed because one or more lines are too long