Files
mesh-catalog/modules/i3/cmd/i3-tools/ipc.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

118 lines
3.2 KiB
Go

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) + `"`
}