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.
429 lines
12 KiB
Go
429 lines
12 KiB
Go
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
|
|
}
|
|
}
|