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