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.
287 lines
7.6 KiB
Go
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
|
|
}
|