i3: the window manager holds node-display-session; its config improved and a checked reload watcher (hq ADR 0208)
It requires x11-display and contributes exec i3 to xinitrc's last slot, and XDG_CURRENT_DESKTOP/XDG_SESSION_DESKTOP to the environment. It owns ~/.config/i3/config, ending with the config.d include where other modules drop their files, and /etc/lemurs/wms/i3, which runs the session's start. Over today's identical config it drops the dead lxpolkit, the D-Bus-activated portal, the xrdb merge xorg now does, and the execs that XDG autostart already started. It sets JetBrainsMono Nerd Font, runs i3-sensible-terminal, and declares dex, which the desktop lacked. The tools speak i3's IPC: the seat's reload, workspaces and windows, and focus, move, layout save/restore, exec, kill, bindings, config check, marks and the scratchpad. A watcher in the bundle (ADR 0198) replaces the predecessor's inotify script and user unit. It reloads only a configuration i3 -C accepts, and at its start whatever i3 has not loaded.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# i3
|
||||
|
||||
The window manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 4).
|
||||
|
||||
- **Claims `node-display-session`** and serves its verbs `reload`, `workspaces` and `windows`.
|
||||
- **Requires `x11-display`**, which only `xorg` on the same machine provides.
|
||||
- **Packages:** `i3-wm`, and `dex`, which the configuration has always run for XDG autostart. `dex`
|
||||
was missing on the desktop, so its autostart entries never started there.
|
||||
- **Environment** (ADR 0203): `XDG_CURRENT_DESKTOP=i3` and `XDG_SESSION_DESKTOP=i3`. They reach
|
||||
shells, the session (through `xorg`'s block) and the user manager (`environment.d`). The portal
|
||||
keys off the first.
|
||||
- **Session code** (ADR 0208 §4): `exec i3 --shmlog-size=26214400` in the `xinitrc` slot `last`.
|
||||
That is the end of `xorg`'s block in `~/.xinitrc`.
|
||||
|
||||
## What it owns
|
||||
|
||||
| path | class (ADR 0182) | what |
|
||||
|---|---|---|
|
||||
| `~/.config/i3/` | owned directory | its contents other than `config` are found and kept |
|
||||
| `~/.config/i3/config.d/` | owned directory | other modules' drop-ins, and yours |
|
||||
| `~/.config/i3/config` | owned (the found file kept once) | the main configuration, from [`config/config`](config/config) |
|
||||
| `/etc/lemurs/wms/i3` | owned | the login manager's session entry: `exec /bin/sh "$HOME/.xinitrc"` |
|
||||
|
||||
**Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/<NN>-<module>.conf`. The
|
||||
`include` is the last line of the main file, so every variable set there (`$mod`, `$ws1`…`$ws10`) is
|
||||
in scope. Files are read in name order. A file of yours there is yours.
|
||||
|
||||
## The reload watcher
|
||||
|
||||
The bundle runs the watcher for as long as the runtime runs it (ADR 0198). It replaces the
|
||||
predecessor's `i3-reload-watcher` script and its user unit, so this needs **no user-scoped unit**:
|
||||
|
||||
- every 2 s it looks at `config` and `config.d/*.conf` (size and time);
|
||||
- at its start, if the files differ from what the running i3 loaded, that counts as a change;
|
||||
- after a change, once the files have been still for one more interval, it checks the result with
|
||||
`i3 -C`;
|
||||
- it **reloads only a configuration without errors**. Otherwise it keeps the running one and records
|
||||
the errors.
|
||||
|
||||
What it has done is in the answers of `reload` and `i3_config_check`. It polls rather than using
|
||||
inotify, so a file replaced by rename and a directory created later are seen without a fresh watch.
|
||||
It reloads i3 only. Re-running the bar, the compositor and the notifier is each of those modules'
|
||||
own business.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-display-session.reload` | a | checks, then reloads, keeping windows. Refused with the errors when the check fails; `force` reloads anyway |
|
||||
| `node-display-session.workspaces` | r | number, name, output, visible, focused, urgent |
|
||||
| `node-display-session.windows` | r | container id, X id, class, instance, role, title, workspace, output, focused, urgent, floating, fullscreen, scratchpad, marks; narrowed to one workspace |
|
||||
| `i3_focus` | d | a window by criteria, or a workspace |
|
||||
| `i3_move` | d | windows to a workspace or output; a workspace to an output |
|
||||
| `i3_layout_save` | d | a workspace's arrangement as a named layout of placeholders (class, instance, role), in `~/.local/state/mesh/i3/layouts/` |
|
||||
| `i3_layout_restore` | d | lays a saved layout back; `name: list` lists them |
|
||||
| `i3_exec` | d | starts a program as i3's child, optionally on a workspace |
|
||||
| `i3_kill` | d | closes the matching windows, or the focused one when asked; with no arguments it closes nothing |
|
||||
| `i3_bindings` | r | every binding by mode, with its command and file, from what i3 loaded |
|
||||
| `i3_config_check` | r | `i3 -C` on the files on disk (errors with file and line), the files i3 loaded, the watcher's record |
|
||||
| `i3_marks` | r | each mark and its window |
|
||||
| `i3_scratchpad` | r/d | list, show or move into the scratchpad |
|
||||
|
||||
The tools speak i3's IPC on its socket in the account's runtime directory, and need no display. Every
|
||||
value a caller gives is quoted before it reaches an i3 command. With no i3 running, the answer is
|
||||
`{"error": "no-graphical-session", …}`. The bundle's binary is `i3-tools`, so nothing that looks for
|
||||
`i3` by name finds it.
|
||||
|
||||
## What it improves over today's configuration
|
||||
|
||||
Both workstations ran the same configuration. These changes are against it:
|
||||
|
||||
- **dead:** `exec lxpolkit` (installed on neither machine), the unused `$refresh_i3status`, and the
|
||||
predecessor's `rice_set` comment;
|
||||
- **duplicates:**
|
||||
- `exec xdg-desktop-portal` (it is D-Bus activated);
|
||||
- `exec xrdb -merge ~/.Xresources` (`xorg`'s session start merges it);
|
||||
- the `picom`, `nm-applet`, `blueman-applet` and `nextcloud` execs. Each also has an XDG autostart
|
||||
entry that `dex` starts, and both machines carry those entries;
|
||||
- **font:** `JetBrainsMono Nerd Font 11` for titles, replacing Hack (research 026/04);
|
||||
- **terminal:** `$mod+Return` runs `i3-sensible-terminal`, which starts whatever `$TERMINAL` names
|
||||
(the terminal module's contribution), instead of naming xterm;
|
||||
- **reloads:** the watcher never reloads into a broken configuration.
|
||||
|
||||
**Moved to their own modules' drop-ins** (the companion modules of research 026). These leave this
|
||||
file because each module carries them now:
|
||||
|
||||
| lines | now in |
|
||||
|---|---|
|
||||
| the launcher bindings (`$mod+d`, `$mod+t`, `$mod+Shift+t`) and the power menu (`$mod+Escape`) | `rofi`'s `50-rofi.conf`, which also adds `$mod+Shift+w` |
|
||||
| the greenclip daemon and its menu (`$mod+period`) | `clipmenu`'s `50-clipmenu.conf` and its session line |
|
||||
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
|
||||
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
|
||||
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
|
||||
|
||||
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
|
||||
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
|
||||
because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDropInLoadTogether`
|
||||
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
|
||||
key.
|
||||
|
||||
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
|
||||
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
|
||||
leaves when the module that owns it is written.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
`~/.config/i3/scripts/`, `~/.config/i3/unlock-keyring.sh`, every file in `config.d/`, the
|
||||
`/usr/share/xsessions` entries (the package's, no longer offered by `lemurs`), and i3's restart
|
||||
state and logs.
|
||||
|
||||
## The one-off migration (ADR 0182)
|
||||
|
||||
1. **Before the push that assigns `i3`:** do `xorg`'s migration (its README). From that push on, your
|
||||
lines below `xorg`'s block in `~/.xinitrc` no longer run.
|
||||
2. **Disable the predecessor's watcher:** `systemctl --user disable --now i3-reload-watcher.service`.
|
||||
Then remove `~/.config/systemd/user/i3-reload-watcher.service` and `~/scripts/i3-reload-watcher`.
|
||||
One thing reloads i3 now. Kept running, it would also `i3-msg restart` on every change, without
|
||||
checking first. **Keep `i3-bar-watchdog.service` as it is**: the bar is `i3status-rust`'s, and that
|
||||
module decides.
|
||||
3. **Delete `/etc/lemurs/wms/i3wm`** (see `lemurs`).
|
||||
4. **`config.d/` fragments:**
|
||||
- `10-asus.conf` and `20-g14.conf` (the laptop) belong to that machine model's hardware module.
|
||||
Keep them until it exists. The desktop no longer carries them.
|
||||
- `50-slack.conf` (both) is yours, or a future `slack` module's. Keep it.
|
||||
- `99-tmp-wine-focus.conf` (the desktop), a test of a predecessor change by its own comment:
|
||||
**delete** it, or keep it as yours.
|
||||
5. **The main file's predecessor copy** is kept once by the host and written over. Nothing to do.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| packages | none (`i3-wm` and `dex` present) | `dex` installed |
|
||||
| `~/.config/i3/config` | written: the improved configuration | the same |
|
||||
| running i3 | **the watcher reloads it once the file changes**. i3 keeps every window, and the title font becomes JetBrains Mono. **Assigned without `rofi`, `clipmenu`, `feh` and `i3status-rust`, the reload takes away the bars and those keys** until they are assigned too, so assign them in the same push. The dropped duplicate execs end nothing that runs | the same |
|
||||
| `/etc/lemurs/wms/i3` | new: offered as `i3` at the next boot | the same |
|
||||
| `~/.xinitrc` | `xorg`'s block now ends in `exec i3`: the lines below it stop running at the next login | the same |
|
||||
| `~/.config/environment.d/50-mesh.conf`, `environment.sh` | gain `XDG_CURRENT_DESKTOP=i3`, `XDG_SESSION_DESKTOP=i3` | the same. Its user manager lacked them, and gets them at the next login |
|
||||
| next login | XDG autostart as before | **XDG autostart runs for the first time**: Slack, JetBrains Toolbox, Nextcloud, the FortiClient tray, the print applet, the geoclue demo agent and snap's user daemon start from their entries |
|
||||
|
||||
**One reload on assignment is the one live change.** To avoid it, assign during a session you are
|
||||
about to end, or accept it: a reload keeps every window and workspace.
|
||||
|
||||
## Blockers
|
||||
|
||||
- **`fonts` first** (to-be 42 orders it first). Without `ttf-jetbrains-mono-nerd`, i3's titles and
|
||||
`i3status-rust`'s bars fall back to pango's default face. On 2026-10-04 the laptop had the package, and the desktop
|
||||
only a hand-copied file of the face.
|
||||
- **None for the watcher.** The runtime restarts with every push that changes it, after the push has
|
||||
written the files. So at its start the watcher compares the files on disk with what the running i3
|
||||
loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its
|
||||
configuration, is therefore reloaded although it also restarted the watcher.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"i3/internal/desktop"
|
||||
)
|
||||
|
||||
// i3's IPC (https://i3wm.org/docs/ipc.html): "i3-ipc", a little-endian length and type, a JSON
|
||||
// payload; the reply carries the same header. Spoken directly, so the tools need no i3-msg and no
|
||||
// display — only the socket, which lives under the account's runtime directory.
|
||||
const (
|
||||
RunCommand = 0
|
||||
GetWorkspaces = 1
|
||||
GetTree = 4
|
||||
GetMarks = 5
|
||||
GetVersion = 7
|
||||
GetConfig = 9
|
||||
)
|
||||
|
||||
const magic = "i3-ipc"
|
||||
|
||||
// Ask sends one message to i3 at socket and decodes its reply into out.
|
||||
func Ask(socket string, kind uint32, payload string, out any) error {
|
||||
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
msg := make([]byte, 14+len(payload))
|
||||
copy(msg, magic)
|
||||
binary.LittleEndian.PutUint32(msg[6:], uint32(len(payload)))
|
||||
binary.LittleEndian.PutUint32(msg[10:], kind)
|
||||
copy(msg[14:], payload)
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
head := make([]byte, 14)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
return fmt.Errorf("i3 did not answer: %w", err)
|
||||
}
|
||||
if string(head[:6]) != magic {
|
||||
return errors.New("the socket's answer is not i3's")
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(head[6:])
|
||||
if n > 64<<20 {
|
||||
return fmt.Errorf("an answer of %d bytes", n)
|
||||
}
|
||||
body := make([]byte, n)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(head[10:]); got != kind {
|
||||
return fmt.Errorf("asked %d, answered %d", kind, got)
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
// FindSocket is the running i3's IPC socket. The session's I3SOCK when its process carries one;
|
||||
// else the newest `ipc-socket.<pid>` under the runtime directory whose i3 is alive and answers.
|
||||
func FindSocket(runtimeDir, i3sock string) (string, error) {
|
||||
if i3sock != "" {
|
||||
if _, err := os.Stat(i3sock); err == nil {
|
||||
return i3sock, nil
|
||||
}
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(runtimeDir, "i3", "ipc-socket.*"))
|
||||
type cand struct {
|
||||
path string
|
||||
pid int
|
||||
}
|
||||
var alive []cand
|
||||
for _, m := range matches {
|
||||
pid, err := strconv.Atoi(strings.TrimPrefix(filepath.Ext(m), "."))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))); err != nil {
|
||||
continue
|
||||
}
|
||||
alive = append(alive, cand{m, pid})
|
||||
}
|
||||
sort.Slice(alive, func(i, j int) bool { return alive[i].pid > alive[j].pid })
|
||||
for _, c := range alive {
|
||||
var v map[string]any
|
||||
if Ask(c.path, GetVersion, "", &v) == nil {
|
||||
return c.path, nil
|
||||
}
|
||||
}
|
||||
return "", &desktop.NoSession{
|
||||
Reason: "i3 is not running: no live IPC socket",
|
||||
Looked: []string{filepath.Join(runtimeDir, "i3", "ipc-socket.*")},
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome is one command's result as i3 answers RUN_COMMAND.
|
||||
type Outcome struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Quote makes a value safe inside an i3 command: double quotes, with backslashes and quotes escaped.
|
||||
func Quote(s string) string {
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
|
||||
# change the module instead. i3's user guide is the reference.
|
||||
#
|
||||
# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named
|
||||
# <NN>-<module>.conf and read in name order by the include at the end, where every variable set here
|
||||
# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.
|
||||
#
|
||||
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
|
||||
# result with i3 -C; it never reloads into a configuration with errors.
|
||||
|
||||
# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).
|
||||
font pango:JetBrainsMono Nerd Font 11
|
||||
|
||||
# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.
|
||||
exec --no-startup-id dex --autostart --environment i3
|
||||
|
||||
#########################################
|
||||
###### Keys ####
|
||||
#########################################
|
||||
# To find key symbols: xmodmap -pke / xmodmap -pm
|
||||
set $mod Mod4
|
||||
set $alt Mod1
|
||||
set $shift Shift
|
||||
set $ctrl Control
|
||||
|
||||
# use these keys for focus, movement, and resize directions when reaching for
|
||||
# the arrows is not convenient
|
||||
set $left h
|
||||
set $down j
|
||||
set $up k
|
||||
set $right l
|
||||
|
||||
# use Mouse+$mod to drag floating windows to their wanted position
|
||||
floating_modifier $mod
|
||||
|
||||
# move tiling windows via drag & drop by left-clicking into the title bar,
|
||||
# or left-clicking anywhere into the window while holding the floating modifier.
|
||||
tiling_drag modifier titlebar
|
||||
|
||||
# start a terminal: whichever the terminal module names in $TERMINAL
|
||||
bindsym $mod+Return exec i3-sensible-terminal
|
||||
|
||||
# kill focused window
|
||||
bindsym $mod+$shift+q kill
|
||||
|
||||
# change focus
|
||||
bindsym $mod+$left focus left
|
||||
bindsym $mod+$down focus down
|
||||
bindsym $mod+$up focus up
|
||||
bindsym $mod+$right focus right
|
||||
|
||||
bindsym $mod+Left focus left
|
||||
bindsym $mod+Down focus down
|
||||
bindsym $mod+Up focus up
|
||||
bindsym $mod+Right focus right
|
||||
|
||||
# move focused window
|
||||
bindsym $mod+$shift+$left move left
|
||||
bindsym $mod+$shift+$down move down
|
||||
bindsym $mod+$shift+$up move up
|
||||
bindsym $mod+$shift+$right move right
|
||||
|
||||
bindsym $mod+$shift+Left move left
|
||||
bindsym $mod+$shift+Down move down
|
||||
bindsym $mod+$shift+Up move up
|
||||
bindsym $mod+$shift+Right move right
|
||||
|
||||
# split in horizontal orientation
|
||||
bindsym $mod+c split h
|
||||
# split in vertical orientation
|
||||
bindsym $mod+v split v
|
||||
|
||||
# enter fullscreen mode for the focused container
|
||||
bindsym $mod+f fullscreen toggle
|
||||
|
||||
# change container layout (stacked, tabbed, toggle split)
|
||||
bindsym $mod+s layout stacking
|
||||
bindsym $mod+w layout tabbed
|
||||
bindsym $mod+e layout toggle split
|
||||
|
||||
# toggle tiling / floating
|
||||
bindsym $mod+$shift+space floating toggle
|
||||
|
||||
# change focus between tiling / floating windows
|
||||
bindsym $mod+space focus mode_toggle
|
||||
|
||||
# focus the parent container
|
||||
bindsym $mod+a focus parent
|
||||
|
||||
# alt-tab functionality
|
||||
bindsym $mod+Tab workspace back_and_forth
|
||||
|
||||
#########################################
|
||||
###### Workspace mgmt ####
|
||||
#########################################
|
||||
set $ws1 "1"
|
||||
set $ws2 "2"
|
||||
set $ws3 "3"
|
||||
set $ws4 "4"
|
||||
set $ws5 "5"
|
||||
set $ws6 "6"
|
||||
set $ws7 "7"
|
||||
set $ws8 "8"
|
||||
set $ws9 "9"
|
||||
set $ws10 "10"
|
||||
|
||||
bindsym $mod+1 workspace number $ws1
|
||||
bindsym $mod+2 workspace number $ws2
|
||||
bindsym $mod+3 workspace number $ws3
|
||||
bindsym $mod+4 workspace number $ws4
|
||||
bindsym $mod+5 workspace number $ws5
|
||||
bindsym $mod+6 workspace number $ws6
|
||||
bindsym $mod+7 workspace number $ws7
|
||||
bindsym $mod+8 workspace number $ws8
|
||||
bindsym $mod+9 workspace number $ws9
|
||||
bindsym $mod+0 workspace number $ws10
|
||||
|
||||
bindsym $mod+$shift+1 move container to workspace number $ws1
|
||||
bindsym $mod+$shift+2 move container to workspace number $ws2
|
||||
bindsym $mod+$shift+3 move container to workspace number $ws3
|
||||
bindsym $mod+$shift+4 move container to workspace number $ws4
|
||||
bindsym $mod+$shift+5 move container to workspace number $ws5
|
||||
bindsym $mod+$shift+6 move container to workspace number $ws6
|
||||
bindsym $mod+$shift+7 move container to workspace number $ws7
|
||||
bindsym $mod+$shift+8 move container to workspace number $ws8
|
||||
bindsym $mod+$shift+9 move container to workspace number $ws9
|
||||
bindsym $mod+$shift+0 move container to workspace number $ws10
|
||||
|
||||
#########################################
|
||||
###### Window mgmt ####
|
||||
#########################################
|
||||
set $resize_px 10 px
|
||||
bindsym $mod+$ctrl+$left resize shrink width $resize_px
|
||||
bindsym $mod+$ctrl+$down resize grow height $resize_px
|
||||
bindsym $mod+$ctrl+$up resize shrink height $resize_px
|
||||
bindsym $mod+$ctrl+$right resize grow width $resize_px
|
||||
|
||||
#########################################
|
||||
###### Session mgmt ####
|
||||
#########################################
|
||||
bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"
|
||||
|
||||
# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and
|
||||
# suspend and lid close too).
|
||||
bindsym $mod+Delete exec --no-startup-id loginctl lock-session
|
||||
|
||||
# reload the configuration file
|
||||
bindsym $mod+$shift+c reload
|
||||
|
||||
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
|
||||
bindsym $mod+$shift+r restart
|
||||
|
||||
#########################################
|
||||
###### Borders ####
|
||||
#########################################
|
||||
default_border pixel 1
|
||||
smart_borders on
|
||||
|
||||
#########################################
|
||||
###### Gaps ####
|
||||
#########################################
|
||||
gaps inner 0
|
||||
gaps outer 0
|
||||
|
||||
# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are
|
||||
# `pixel`, so no title bar shows: the colour says which window has focus.
|
||||
# border background text indicator child_border
|
||||
client.focused #de5200 #de5200 #1E2127 #de5200 #de5200
|
||||
client.urgent #900000 #900000 #ffffff #900000 #900000
|
||||
|
||||
#########################################
|
||||
###### Until their modules carry them ##
|
||||
#########################################
|
||||
# Each line below belongs to something other than i3, named on its line. When that module is written
|
||||
# it contributes the line as its own drop-in in config.d, and the line goes from here in the same
|
||||
# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs
|
||||
# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).
|
||||
|
||||
# the peripherals' tray (the operator's application)
|
||||
exec --no-startup-id polychromatic-tray-applet
|
||||
|
||||
# the operator's scripts: volume, games volume, sessions, screenshot
|
||||
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
|
||||
bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down
|
||||
bindsym XF86AudioMute exec --no-startup-id volume-notify mute
|
||||
bindsym XF86AudioMicMute exec --no-startup-id mic-notify
|
||||
bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5
|
||||
bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5
|
||||
bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh
|
||||
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||
|
||||
#########################################
|
||||
###### Other modules' drop-ins ####
|
||||
#########################################
|
||||
include ~/.config/i3/config.d/*.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
module i3
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -0,0 +1,160 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
|
||||
type Args map[string]any
|
||||
|
||||
// Text is a required string, trimmed.
|
||||
func (a Args) Text(name string) (string, error) {
|
||||
v, ok := a[name].(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return "", fmt.Errorf("%s is required, as text", name)
|
||||
}
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
|
||||
// Opt is an optional string, trimmed, or def.
|
||||
func (a Args) Opt(name, def string) string {
|
||||
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Has is whether the caller gave the argument at all.
|
||||
func (a Args) Has(name string) bool {
|
||||
v, ok := a[name]
|
||||
return ok && v != nil
|
||||
}
|
||||
|
||||
// Bool is an optional boolean: its value, and whether it was given.
|
||||
func (a Args) Bool(name string) (bool, bool, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
b, isBool := v.(bool)
|
||||
if !isBool {
|
||||
return false, false, fmt.Errorf("%s is true or false", name)
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Number is an optional number: its value, and whether it was given.
|
||||
func (a Args) Number(name string) (float64, bool, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
f, isNum := v.(float64)
|
||||
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return 0, false, fmt.Errorf("%s is a number", name)
|
||||
}
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
// Whole is an optional whole number within [lo, hi], or def.
|
||||
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
|
||||
f, given, err := a.Number(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !given {
|
||||
return def, nil
|
||||
}
|
||||
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
|
||||
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// OneOf is an optional string that must be one of choices, or def.
|
||||
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
|
||||
v := a.Opt(name, def)
|
||||
for _, c := range choices {
|
||||
if v == c {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
|
||||
}
|
||||
|
||||
// Strings is an optional list of strings.
|
||||
func (a Args) Strings(name string) ([]string, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, isList := v.([]any)
|
||||
if !isList {
|
||||
return nil, fmt.Errorf("%s is a list of text", name)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, x := range list {
|
||||
s, isText := x.(string)
|
||||
if !isText {
|
||||
return nil, fmt.Errorf("%s is a list of text", name)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Home is the operator account's home: the runtime's word for it, else this process's.
|
||||
func Home() string {
|
||||
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
|
||||
return h
|
||||
}
|
||||
if h, err := os.UserHomeDir(); err == nil {
|
||||
return h
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
|
||||
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
|
||||
func InHome(path string) (string, error) {
|
||||
home := Home()
|
||||
switch {
|
||||
case path == "~":
|
||||
path = home
|
||||
case strings.HasPrefix(path, "~/"):
|
||||
path = filepath.Join(home, path[2:])
|
||||
case !filepath.IsAbs(path):
|
||||
path = filepath.Join(home, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%s is outside the account's home", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Schema builds a tool's input schema from property descriptions; required names those that must
|
||||
// be given. A property is a string unless its description object says otherwise.
|
||||
func Schema(props map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Str, Num, Flag, List and Enum describe one property.
|
||||
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
||||
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
|
||||
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
|
||||
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
|
||||
func List(desc string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
|
||||
}
|
||||
func Enum(desc string, values ...string) map[string]any {
|
||||
return map[string]any{"type": "string", "enum": values, "description": desc}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
|
||||
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
|
||||
|
||||
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
|
||||
mine, err := filepath.Glob("*.go")
|
||||
if err != nil || len(mine) == 0 {
|
||||
t.Fatal("no files of this package found", err)
|
||||
}
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
|
||||
if len(theirs) != len(mine) {
|
||||
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
|
||||
continue
|
||||
}
|
||||
for _, f := range mine {
|
||||
a, _ := os.ReadFile(f)
|
||||
b, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(a, b) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Package desktop is how a desktop module's tools act in the operator's graphical session
|
||||
// (novox/hq ADR 0208, research 026/05).
|
||||
//
|
||||
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
|
||||
// process of node-tools.service, started by the system's service manager as the operator account,
|
||||
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
|
||||
// was started elsewhere, by the login manager, and the only place its values are written down is
|
||||
// the environment of the processes it started. So this package finds the session the way a person
|
||||
// would: it looks at the operator account's own processes, takes the one that is plainly the
|
||||
// session's (the window manager, or the oldest process carrying a display), confirms with logind
|
||||
// that its session is a live local one, and checks that the display's socket is really there.
|
||||
//
|
||||
// **Only the session's own words are read.** A session's processes also carry whatever its start
|
||||
// script exported — on the workstations that was a file of secrets — so the environment is filtered
|
||||
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
|
||||
//
|
||||
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
|
||||
// whenever it exists, because that is where the portal, the notifier and every user service
|
||||
// listen. A session started on a private bus (a stale session, measured on one workstation) is
|
||||
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
|
||||
//
|
||||
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
|
||||
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
|
||||
// every copy together — the modules' tests compare them.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionWords are the only environment words read from a session's process: the ones that say
|
||||
// where the session is. Everything else in that environment is the operator's, and is never read.
|
||||
var SessionWords = []string{
|
||||
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
|
||||
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
|
||||
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
|
||||
"I3SOCK", "SWAYSOCK",
|
||||
}
|
||||
|
||||
// Session is the operator's running graphical session, as a tool needs it.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
ID string `json:"session_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Display string `json:"display,omitempty"`
|
||||
WaylandDisplay string `json:"wayland_display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
SessionBus string `json:"session_bus,omitempty"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
// FoundIn is the process whose environment named the session.
|
||||
FoundIn Process `json:"found_in"`
|
||||
// Active is logind's word on the session, when logind answered.
|
||||
Active *bool `json:"active,omitempty"`
|
||||
|
||||
words map[string]string
|
||||
}
|
||||
|
||||
// Process is one process the search looked at.
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
start uint64
|
||||
}
|
||||
|
||||
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
|
||||
// that returns it as its error still answers structured data.
|
||||
type NoSession struct {
|
||||
Reason string `json:"reason"`
|
||||
Looked []string `json:"looked"`
|
||||
}
|
||||
|
||||
func (e *NoSession) Error() string {
|
||||
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// IsNoSession is whether err says there is no session.
|
||||
func IsNoSession(err error) bool {
|
||||
var n *NoSession
|
||||
return errors.As(err, &n)
|
||||
}
|
||||
|
||||
// Finder holds where the search looks, so a test can point it at a tree of its own.
|
||||
type Finder struct {
|
||||
Proc string // the process table: /proc
|
||||
X11Sockets string // where X servers listen: /tmp/.X11-unix
|
||||
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
|
||||
UID int // whose session
|
||||
// Prefer names the processes that are the session's own, best first: the session's holder.
|
||||
Prefer []string
|
||||
// Logind answers `loginctl show-session` for one id; nil skips the check.
|
||||
Logind func(id string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
|
||||
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
|
||||
func DefaultFinder(prefer ...string) Finder {
|
||||
uid := os.Getuid()
|
||||
if uid == 0 {
|
||||
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
|
||||
if u, err := user.Lookup(name); err == nil {
|
||||
if n, err := strconv.Atoi(u.Uid); err == nil {
|
||||
uid = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Finder{
|
||||
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
|
||||
UID: uid, Prefer: prefer, Logind: loginctl,
|
||||
}
|
||||
}
|
||||
|
||||
// Find is the operator's session on this machine, preferring a process named in prefer.
|
||||
func Find(prefer ...string) (*Session, error) {
|
||||
return DefaultFinder(prefer...).Find()
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
proc Process
|
||||
words map[string]string
|
||||
rank int
|
||||
logind map[string]string
|
||||
}
|
||||
|
||||
// Find looks for the session.
|
||||
func (f Finder) Find() (*Session, error) {
|
||||
entries, err := os.ReadDir(f.Proc)
|
||||
if err != nil {
|
||||
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
|
||||
}
|
||||
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
|
||||
var found []candidate
|
||||
stale := 0
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(f.Proc, e.Name())
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
|
||||
continue
|
||||
}
|
||||
words := readWords(filepath.Join(dir, "environ"))
|
||||
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
if !f.reachable(words) {
|
||||
stale++
|
||||
continue
|
||||
}
|
||||
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
|
||||
if stale > 0 {
|
||||
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
|
||||
}
|
||||
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
|
||||
}
|
||||
|
||||
// logind's word on each session the candidates name, asked once per session.
|
||||
asked := map[string]map[string]string{}
|
||||
for i := range found {
|
||||
id := found[i].words["XDG_SESSION_ID"]
|
||||
if f.Logind == nil || id == "" {
|
||||
found[i].rank = 1
|
||||
continue
|
||||
}
|
||||
props, done := asked[id]
|
||||
if !done {
|
||||
props, _ = f.Logind(id)
|
||||
asked[id] = props
|
||||
}
|
||||
found[i].logind = props
|
||||
switch {
|
||||
case props == nil:
|
||||
found[i].rank = 1
|
||||
case props["Remote"] == "yes":
|
||||
found[i].rank = 3
|
||||
case props["Active"] == "yes" && props["State"] != "closing":
|
||||
found[i].rank = 0
|
||||
case props["State"] == "closing":
|
||||
found[i].rank = 3
|
||||
default:
|
||||
found[i].rank = 2
|
||||
}
|
||||
}
|
||||
if f.Logind != nil {
|
||||
looked = append(looked, "logind's sessions")
|
||||
}
|
||||
preferred := func(c candidate) int {
|
||||
for i, p := range f.Prefer {
|
||||
if c.proc.Command == p {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(f.Prefer)
|
||||
}
|
||||
sort.SliceStable(found, func(i, j int) bool {
|
||||
a, b := found[i], found[j]
|
||||
if a.rank != b.rank {
|
||||
return a.rank < b.rank
|
||||
}
|
||||
if pa, pb := preferred(a), preferred(b); pa != pb {
|
||||
return pa < pb
|
||||
}
|
||||
if a.proc.start != b.proc.start {
|
||||
return a.proc.start < b.proc.start
|
||||
}
|
||||
return a.proc.PID < b.proc.PID
|
||||
})
|
||||
best := found[0]
|
||||
if best.rank == 3 {
|
||||
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
|
||||
}
|
||||
return f.session(best), nil
|
||||
}
|
||||
|
||||
func (f Finder) session(c candidate) *Session {
|
||||
w := c.words
|
||||
s := &Session{
|
||||
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
|
||||
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
|
||||
}
|
||||
s.Desktop = w["XDG_CURRENT_DESKTOP"]
|
||||
if s.Desktop == "" {
|
||||
s.Desktop = w["XDG_SESSION_DESKTOP"]
|
||||
}
|
||||
switch {
|
||||
case w["XDG_SESSION_TYPE"] != "":
|
||||
s.Type = w["XDG_SESSION_TYPE"]
|
||||
case s.WaylandDisplay != "":
|
||||
s.Type = "wayland"
|
||||
default:
|
||||
s.Type = "x11"
|
||||
}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
|
||||
}
|
||||
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
|
||||
s.SessionBus = own
|
||||
}
|
||||
if c.logind != nil {
|
||||
active := c.logind["Active"] == "yes"
|
||||
s.Active = &active
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// reachable is whether the display a process names is still served: the X server's socket, or the
|
||||
// Wayland compositor's. A process outliving its session still carries the session's words.
|
||||
func (f Finder) reachable(w map[string]string) bool {
|
||||
if d := w["WAYLAND_DISPLAY"]; d != "" {
|
||||
path := d
|
||||
if !filepath.IsAbs(d) {
|
||||
dir := w["XDG_RUNTIME_DIR"]
|
||||
if dir == "" {
|
||||
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
|
||||
}
|
||||
path = filepath.Join(dir, d)
|
||||
}
|
||||
if isSocket(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
n, ok := DisplayNumber(w["DISPLAY"])
|
||||
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
|
||||
}
|
||||
|
||||
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
|
||||
// another host — an ssh session's forwarded one — is not the local session and answers false.
|
||||
func DisplayNumber(display string) (int, bool) {
|
||||
host, rest, ok := strings.Cut(display, ":")
|
||||
if !ok || (host != "" && host != "unix") {
|
||||
return 0, false
|
||||
}
|
||||
num, _, _ := strings.Cut(rest, ".")
|
||||
n, err := strconv.Atoi(num)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Word is one of the session's words as its process had it ("" when it had none).
|
||||
func (s *Session) Word(name string) string { return s.words[name] }
|
||||
|
||||
// Env is base with the session's words in place of whatever base said for them.
|
||||
func (s *Session) Env(base []string) []string {
|
||||
drop := map[string]bool{}
|
||||
for _, w := range SessionWords {
|
||||
drop[w] = true
|
||||
}
|
||||
out := make([]string, 0, len(base)+8)
|
||||
for _, kv := range base {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if !drop[k] {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
bus := s.Bus
|
||||
if bus == "" {
|
||||
bus = s.SessionBus
|
||||
}
|
||||
for _, kv := range [][2]string{
|
||||
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
|
||||
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
|
||||
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
|
||||
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
|
||||
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
|
||||
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
|
||||
} {
|
||||
if kv[1] != "" {
|
||||
out = append(out, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
|
||||
// user manager or the session bus and needs no display — it works with no session at all.
|
||||
func UserEnv(base []string, uid int) []string {
|
||||
dir := filepath.Join("/run/user", strconv.Itoa(uid))
|
||||
out := make([]string, 0, len(base)+2)
|
||||
for _, kv := range base {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
|
||||
}
|
||||
|
||||
// readWords reads a process's environment and keeps only SessionWords.
|
||||
func readWords(path string) map[string]string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, w := range SessionWords {
|
||||
keep[w] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
k, v, ok := bytes.Cut(kv, []byte{'='})
|
||||
if ok && keep[string(k)] {
|
||||
out[string(k)] = string(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func comm(dir string) string {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "comm"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
|
||||
// Read after the command's closing parenthesis, because the command may hold spaces.
|
||||
func startTime(dir string) uint64 {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||
if err != nil {
|
||||
return ^uint64(0)
|
||||
}
|
||||
i := bytes.LastIndexByte(b, ')')
|
||||
if i < 0 {
|
||||
return ^uint64(0)
|
||||
}
|
||||
fields := strings.Fields(string(b[i+1:]))
|
||||
// fields[0] is the state, field 3 of the line; start time is field 22.
|
||||
if len(fields) < 20 {
|
||||
return ^uint64(0)
|
||||
}
|
||||
n, err := strconv.ParseUint(fields[19], 10, 64)
|
||||
if err != nil {
|
||||
return ^uint64(0)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isSocket(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode()&os.ModeSocket != 0
|
||||
}
|
||||
|
||||
// loginctl asks logind about one session, by its property lines.
|
||||
func loginctl(id string) (map[string]string, error) {
|
||||
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
done := make(chan error, 1)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
return nil, errors.New("loginctl did not answer in 3s")
|
||||
}
|
||||
return ParseProperties(out.String()), nil
|
||||
}
|
||||
|
||||
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
|
||||
func ParseProperties(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
|
||||
type fakeMachine struct {
|
||||
t *testing.T
|
||||
proc, x11, runtime string
|
||||
uid int
|
||||
}
|
||||
|
||||
func newMachine(t *testing.T) *fakeMachine {
|
||||
root, err := os.MkdirTemp("", "desk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(root) })
|
||||
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
|
||||
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *fakeMachine) socket(path string) {
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
m.t.Cleanup(func() { l.Close() })
|
||||
}
|
||||
|
||||
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
|
||||
dir := filepath.Join(m.proc, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
||||
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
|
||||
// cutime cstime priority nice threads itrealvalue starttime ...
|
||||
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
|
||||
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
|
||||
}
|
||||
|
||||
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
|
||||
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
|
||||
}
|
||||
|
||||
func active(id string) (map[string]string, error) {
|
||||
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
|
||||
}
|
||||
|
||||
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X1"))
|
||||
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
||||
m.socket(filepath.Join(run, "bus"))
|
||||
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
|
||||
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
|
||||
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
|
||||
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
|
||||
|
||||
s, err := m.finder(active, "i3").Find()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
|
||||
t.Fatalf("session: %+v", s)
|
||||
}
|
||||
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
|
||||
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
|
||||
}
|
||||
if s.Active == nil || !*s.Active {
|
||||
t.Fatal("logind's word is carried")
|
||||
}
|
||||
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
|
||||
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("env lacks %s:\n%s", want, env)
|
||||
}
|
||||
}
|
||||
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
|
||||
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
if strings.Contains(string(b), "secret") {
|
||||
t.Fatal("the answer carries a word outside the session's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X0"))
|
||||
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
||||
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
||||
s, err := m.finder(nil).Find()
|
||||
if err != nil || s.FoundIn.PID != 400 {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
|
||||
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
|
||||
_, err := m.finder(active, "i3").Find()
|
||||
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var answer map[string]any
|
||||
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
|
||||
t.Fatalf("the refusal is structured: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
|
||||
_, err := m.finder(active).Find()
|
||||
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X0"))
|
||||
m.socket(filepath.Join(m.x11, "X1"))
|
||||
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
|
||||
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
|
||||
logind := func(id string) (map[string]string, error) {
|
||||
if id == "a" {
|
||||
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
|
||||
}
|
||||
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
|
||||
}
|
||||
s, err := m.finder(logind, "i3").Find()
|
||||
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
|
||||
t.Fatalf("the active session: %+v %v", s, err)
|
||||
}
|
||||
remote := func(string) (map[string]string, error) {
|
||||
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
|
||||
}
|
||||
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
|
||||
t.Fatalf("a remote session is not the operator's desktop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
||||
m.socket(filepath.Join(run, "wayland-1"))
|
||||
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
|
||||
s, err := m.finder(nil, "sway").Find()
|
||||
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
|
||||
t.Fatal("the compositor's socket word passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
|
||||
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
|
||||
if _, ok := DisplayNumber(d); ok != want {
|
||||
t.Errorf("%q: %v", d, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
|
||||
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
|
||||
if !r.OK() || r.Stdout != "hello" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
|
||||
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
|
||||
if r.OK() || r.Code != 127 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
|
||||
if !r.Truncated || len(r.Stdout) != MostOutput {
|
||||
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgumentsAreReadStrictly(t *testing.T) {
|
||||
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if v, err := a.Text("name"); err != nil || v != "x" {
|
||||
t.Fatal(v, err)
|
||||
}
|
||||
if _, err := a.Text("missing"); err == nil {
|
||||
t.Fatal("a missing required text")
|
||||
}
|
||||
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
|
||||
t.Fatal(n, err)
|
||||
}
|
||||
if _, err := a.Whole("f", 0, 0, 5); err == nil {
|
||||
t.Fatal("1.5 is not whole")
|
||||
}
|
||||
if _, err := a.Whole("n", 0, 4, 5); err == nil {
|
||||
t.Fatal("out of range")
|
||||
}
|
||||
if b, given, err := a.Bool("b"); !b || !given || err != nil {
|
||||
t.Fatal("bool")
|
||||
}
|
||||
if _, _, err := a.Bool("name"); err == nil {
|
||||
t.Fatal("text is not a bool")
|
||||
}
|
||||
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
|
||||
t.Fatal(l, err)
|
||||
}
|
||||
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
|
||||
t.Fatal("not one of")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathIsKeptInsideTheHome(t *testing.T) {
|
||||
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
||||
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
|
||||
if got, err := InHome(in); err != nil || got != want {
|
||||
t.Errorf("%s: %s %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
|
||||
if _, err := InHome(out); err == nil {
|
||||
t.Errorf("%s was accepted", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropertiesAreParsed(t *testing.T) {
|
||||
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
|
||||
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user