Files
mesh-catalog/modules/i3/cmd/i3-tools/config.go
T
jochen 34829e39fe i3: the window manager holds node-display-session; its config improved and a checked reload watcher (hq ADR 0208)
It requires x11-display and contributes exec i3 to xinitrc's last slot, and
XDG_CURRENT_DESKTOP/XDG_SESSION_DESKTOP to the environment. It owns
~/.config/i3/config, ending with the config.d include where other modules drop
their files, and /etc/lemurs/wms/i3, which runs the session's start.

Over today's identical config it drops the dead lxpolkit, the D-Bus-activated
portal, the xrdb merge xorg now does, and the execs that XDG autostart already
started. It sets JetBrainsMono Nerd Font, runs i3-sensible-terminal, and declares
dex, which the desktop lacked.

The tools speak i3's IPC: the seat's reload, workspaces and windows, and focus,
move, layout save/restore, exec, kill, bindings, config check, marks and the
scratchpad. A watcher in the bundle (ADR 0198) replaces the predecessor's inotify
script and user unit. It reloads only a configuration i3 -C accepts, and at its
start whatever i3 has not loaded.
2026-10-04 13:21:47 +02:00

287 lines
7.6 KiB
Go

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
}