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