commit f816d601383a2230eb8501c0e5cda27d84387aea Author: Magnus Ă…hall Date: Tue Dec 21 19:43:46 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8e1c68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +i3-session-manager +test/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b1ccc67 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module i3-session-manager + +go 1.16 diff --git a/main.go b/main.go new file mode 100644 index 0000000..ead1997 --- /dev/null +++ b/main.go @@ -0,0 +1,109 @@ +package main + +import ( + // Standard + "fmt" + "net" + "os" + "regexp" + "strings" +) + +var ( + session I3Session + sessionSubscription I3Session + workspaces []I3Workspace + outputIndices map[string]int +) + +func init() { + outputIndices = make(map[string]int) + workspaces = []I3Workspace{ + { Num: 0, Name: "Development" }, + { Num: 1, Name: "Terminal" }, + { Num: 2, Name: "Browser" }, + { Num: 3, Name: "Communication" }, + { Num: 4, Name: "Multimedia" }, + { Num: 5, Name: "Graphics" }, + { Num: 6, Name: "SQL" }, + { Num: 7, Name: "Debug" }, + { Num: 8, Name: "Email" }, + { Num: 9, Name: "Virtualization" }, + } +} + +func main() { + var err error + + // session is used for interactively communicating with i3, + // like getting workspace and output data. + if session, err = NewI3Session(); err != nil { + fmt.Printf("%s\n", err) + os.Exit(1) + } + defer session.Close() + + // sessionSubscription is used for listening to subscribed events + // from i3. + if sessionSubscription, err = NewI3Session(); err != nil { + fmt.Printf("%s\n", err) + os.Exit(1) + } + sessionSubscription.Subscribe([]string{"binding", "output"}) + defer sessionSubscription.Close() + go sessionSubscription.Loop() + + // Socket server reading commands and acting upon them + var listener net.Listener + listener, err = net.Listen("unix", "/tmp/i3-session.sock") + if err != nil { + fmt.Printf("Socket listener: %s\n", err) + os.Exit(1) + } + + // Find outputs and assign an index to them + outputs, err := session.Outputs() + if err != nil { + fmt.Printf("Output error: %s\n", err) + os.Exit(1) + } + idx := 0 + for _, output := range outputs { + if output.Active { + outputIndices[output.Name] = idx + idx++ + } + } + + // XXX: still needed? Find the configured workspaces + if false { + var configData string + var configLines []string + configData, err = session.Config() + configLines = strings.Split(configData, "\n") + r := regexp.MustCompile(`(?i)^\s*workspace\s+["']?([^"']+?)["']?\s+output\s+["']?([^"']+?)["']?\s*$`) + for _, line := range configLines { + matches := r.FindAllStringSubmatch(line, -1) + if len(matches) > 0 && len(matches[0]) == 3 { + fmt.Printf("%#v\n", matches[0][1:]) + } + } + } + + // Listen for external commands + var conn net.Conn + for { + conn, err = listener.Accept() + if err != nil { + fmt.Printf("Connection accept: %s\n", err) + } + buf := make([]byte, 1024) + _, err = conn.Read(buf) + if err != nil { + fmt.Printf("Connection read: %s\n", err) + } else { + fmt.Printf("%s\n", buf) + } + conn.Close() + } +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..08fc85d --- /dev/null +++ b/session.go @@ -0,0 +1,371 @@ +package main + +import ( + // Standard + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// NewI3Session finds the i3 socket path and connects to it. +func NewI3Session() (session I3Session, err error) { + // Find path to i3 socket + i3SocketPathCmd := exec.Command("i3", "--get-socketpath") + i3SocketPathBytes, _ := i3SocketPathCmd.CombinedOutput() + i3SocketPath := strings.TrimSpace(string(i3SocketPathBytes)) + session.Socket, err = NewI3Socket(i3SocketPath) + return +} + +// Close closes the socket. +func (sess I3Session) Close() error { + return sess.Socket.Close() +} + +// Outputs query I3 for output data. +func (sess I3Session) Outputs() (outputs []I3Output, err error) { + var outputJson []byte + + outputJson, err = sess.Socket.Request(GET_OUTPUTS, "") + if err != nil { + return + } + + err = json.Unmarshal(outputJson, &outputs) + return +} + +// Marks query I3 for mark data. +func (sess I3Session) Marks() (marks []string, err error) { + var outputJson []byte + + outputJson, err = sess.Socket.Request(GET_MARKS, "") + if err != nil { + return + } + + err = json.Unmarshal(outputJson, &marks) + return +} + +// Workspaces query I3 for workspace data. +func (sess I3Session) Workspaces() (workspaces []I3Workspace, err error) { + var workspacesJson []byte + workspacesJson, err = sess.Socket.Request(GET_WORKSPACES, "") + if err != nil { + return + } + + err = json.Unmarshal(workspacesJson, &workspaces) + return +} + +// Subscribe subscribes to events. +func (sess I3Session) Subscribe(events []string) error { + eventsJSON, _ := json.Marshal(events) + res, err := sess.Socket.Request(SUBSCRIBE, string(eventsJSON)) + if err != nil { + return err + } + fmt.Printf("Subscribe: %s\n", res) + return nil +} + +// Config returns the currently running configuration data, as +// written in the configuration file. +func (sess I3Session) Config() (config string, err error) { + var configJSON []byte + var configStruct I3Config + configJSON, err = sess.Socket.Request(GET_CONFIG, "") + err = json.Unmarshal(configJSON, &configStruct) + if err != nil { + return + } + + config = configStruct.Config + return +} + +func (sess I3Session) Loop() { + var msg []byte + var err error + var cmdBinding I3EventBinding + + cmdOutput := regexp.MustCompile(`(?i)^\s*nop\s*output\s*(.*?)\s*$`) + cmdWS := regexp.MustCompile(`(?i)^\s*nop\s*workspace\s*(\d+)\s*$`) + cmdSession := regexp.MustCompile(`(?i)^\s*nop\s*manage\s+session`) + cmdMarkTag := regexp.MustCompile(`(?i)^\s*nop\s*mark\s+tag`) + cmdMarkMove := regexp.MustCompile(`(?i)^\s*nop\s*mark\s+move`) + cmdMarkClear := regexp.MustCompile(`(?i)^\s*nop\s*mark\s+clear`) + + for { + msg, err = I3ReadMessage(sess.Socket) + if err != nil { + fmt.Printf("Loop error: %s\n", err) + os.Exit(1) + } + + cmdBinding = I3EventBinding{} + err = json.Unmarshal(msg, &cmdBinding) + if err != nil { + fmt.Printf( + "Loop JSON parse: %s\n\nMessage:\n(%s)\n", + err, + msg, + ) + } + binding := cmdBinding.Binding + + // Switch output + m := cmdOutput.FindAllStringSubmatch(binding.Command, 1) + if len(m) == 1 && len(m[0]) == 2 { + err = sess.SwitchOutput(m[0][1]) + if err != nil { + fmt.Printf("%s\n", err) + } + continue + } + + // Switch output + m = cmdWS.FindAllStringSubmatch(binding.Command, 1) + if len(m) == 1 && len(m[0]) == 2 { + i, _ := strconv.Atoi(m[0][1]) + err = sess.SwitchWorkspace(i) + if err != nil { + fmt.Printf("%s\n", err) + } + continue + } + + // Manage session + if cmdSession.Match([]byte(binding.Command)) { + err = sess.ManageSession() + if err != nil { + fmt.Printf("Manage session: %s\n", err) + } + continue + } + + // Mark current window with move tag + if cmdMarkTag.Match([]byte(binding.Command)) { + err = sess.MarkTag() + if err != nil { + fmt.Printf("Mark tag: %s\n", err) + } + continue + } + + // Remove all move tags + if cmdMarkClear.Match([]byte(binding.Command)) { + err = sess.MarkClear() + if err != nil { + fmt.Printf("Mark clear: %s\n", err) + } + continue + } + + // Move all windows with move tag + if cmdMarkMove.Match([]byte(binding.Command)) { + err = sess.MarkMove() + if err != nil { + fmt.Printf("Mark move: %s\n", err) + } + continue + } + + fmt.Printf("%s\n", msg) + + } +} + +// SwitchWorkspace switches to the given workspace index on the same output +// that has the workspace that is currently focused. +func (sess I3Session) SwitchWorkspace(workspaceIndex int) error { + var output string + i3Workspaces, err := sess.Workspaces() + if err != nil { + return err + } + + for _, ws := range i3Workspaces { + if ws.Focused { + output = ws.Output + break + } + } + + if output == "" { + return fmt.Errorf("Focused workspace not found") + } + + outputIdx := outputIndices[output] + workspaceName := fmt.Sprintf( + "%d:%s", + outputIdx*10+workspaceIndex, + workspaces[workspaceIndex].Name, + ) + fmt.Printf("Switch to workspace: %s\n", workspaceName) + sess.Socket.Request( + RUN_COMMAND, + fmt.Sprintf("workspace \"%s\"", workspaceName), + ) + + return nil +} + +// SwitchOutput looks up the visible workspace on the given monitor, +// and switches to that. +func (sess I3Session) SwitchOutput(output string) error { + workspaces, err := sess.Workspaces() + if err != nil { + return err + } + + var nextWorkspace string + for _, workspace := range workspaces { + if workspace.Output == output && workspace.Visible { + nextWorkspace = workspace.Name + break + } + } + + if nextWorkspace == "" { + return fmt.Errorf( + "Switch to output (%s) failed, "+ + "no visible workspace found", + output, + ) + } + + fmt.Printf("Switching to output: %s\n", output) + sess.Socket.Request(RUN_COMMAND, "workspace "+nextWorkspace) + + return nil +} + +// ManageSession runs dmenu and asks for reload, restart or exit. +func (sess I3Session) ManageSession() error { + cmd := exec.Command( + "sh", "-c", + "echo \"reload\nrestart\nexit\" | dmenu -l 3 -i", + ) + out, err := cmd.CombinedOutput() + if err != nil { + return err + } + choice := strings.TrimSpace(string(out)) + + switch(choice) { + case "reload", "restart", "exit": + res, err := sess.Socket.Request(RUN_COMMAND, choice) + if err != nil { + return err + } + fmt.Printf("Management %s: %s\n", choice, res) + default: + return fmt.Errorf("Unknown management command") + } + + return nil +} + +// MarkTag marks a window with a tag for moving +func (sess I3Session) MarkTag() error { + var max int + var cur int + var match []string + r := regexp.MustCompile(`^move (\d+)$`) + + marks, err := sess.Marks() + if err != nil { + return err + } + + for _, mark := range marks { + match = r.FindStringSubmatch(mark) + if len(match) == 2 { + cur, _ = strconv.Atoi(match[1]) + if cur > max { + max = cur + } + } + } + + res, err := sess.Socket.Request( + RUN_COMMAND, + fmt.Sprintf("mark \"move %d\"", max+1), + ) + if err != nil { + return err + } + fmt.Printf("Mark tag: %s\n", res) + + return nil +} + +// MarkTag marks a window with a tag for moving +func (sess I3Session) MarkClear() error { + marks, err := sess.Marks() + if err != nil { + return err + } + + r := regexp.MustCompile(`^move \d+$`) + for _, mark := range marks { + if r.Match([]byte(mark)) { + _, err := sess.Socket.Request( + RUN_COMMAND, + fmt.Sprintf("unmark \"%s\"", mark), + ) + if err != nil { + return err + } + } + } + + return nil +} + +// MarkMove moves tagged windows to current workspace +func (sess I3Session) MarkMove() error { + i3Workspaces, err := sess.Workspaces() + if err != nil { + return err + } + + var focusedWorkspace *I3Workspace + for _, ws := range i3Workspaces { + if ws.Focused { + focusedWorkspace = &ws + break + } + } + + if focusedWorkspace == nil { + return fmt.Errorf("No focused workspace") + } + + cmd := fmt.Sprintf( + "[con_mark=\"^move [0-9]\"] move workspace \"%s\"", + focusedWorkspace.Name, + ) + res, err := sess.Socket.Request(RUN_COMMAND, cmd) + if err != nil { + return err + } + + fmt.Printf("Mark move: %s\n", res) + + err = sess.MarkClear() + if err != nil { + return err + } + + return nil +} + +// vim: foldmethod=marker diff --git a/socket.go b/socket.go new file mode 100644 index 0000000..cd4c969 --- /dev/null +++ b/socket.go @@ -0,0 +1,91 @@ +package main + +import ( + // Standard + "encoding/binary" + "fmt" + "net" +) + +const ( + RUN_COMMAND = 0 + GET_WORKSPACES = 1 + SUBSCRIBE = 2 + GET_OUTPUTS = 3 + GET_TREE = 4 + GET_MARKS = 5 + GET_BAR_CONFIG = 6 + GET_VERSION = 7 + GET_BINDING_MODES = 8 + GET_CONFIG = 9 + SEND_TICK = 10 + SYNC = 11 +) + +func NewI3Socket(filename string) (I3Socket, error) { + var i3Socket I3Socket + var err error + + i3Socket.conn, err = net.Dial("unix", filename) + if err != nil { + return i3Socket, err + } + + return i3Socket, nil +} + +func (sock I3Socket) Close() error { + return sock.conn.Close() +} + +func I3ReadMessage(sock I3Socket) (msg []byte, err error) { + buf := make([]byte, 6) + if _, err = sock.conn.Read(buf); err != nil { + return nil, err + } + if string(buf) != "i3-ipc" { + return nil, fmt.Errorf("Invalid i3 IPC answer") + } + + // LE encoded length + buf = make([]byte, 4) + if _, err = sock.conn.Read(buf); err != nil { + return nil, err + } + msgLength := binary.LittleEndian.Uint32(buf) + + // LE encoded msg type + buf = make([]byte, 4) + if _, err = sock.conn.Read(buf); err != nil { + return nil, err + } + + // Requested data + buf = make([]byte, msgLength) + if _, err = sock.conn.Read(buf); err != nil { + return nil, err + } + msg = buf + + return +} + +func (sock I3Socket) Request(typ int, message string) ([]byte, error) { + header := []byte("i3-ipc") + buf := make([]byte, 8) + binary.LittleEndian.PutUint32(buf, uint32(len(message))) + binary.LittleEndian.PutUint32(buf[4:], uint32(typ)) + msg := append(header, buf...) + msg = append(msg, ([]byte(message))...) + + _, err := sock.conn.Write(msg) + if err != nil { + return nil, err + } + + buf, err = I3ReadMessage(sock) + if err != nil { + return nil, err + } + return buf, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..2beeafc --- /dev/null +++ b/types.go @@ -0,0 +1,46 @@ +package main + +import ( + // Standard + "net" +) + +type I3Session struct { + Socket I3Socket +} + +type I3Socket struct { + filename string + socket string + conn net.Conn +} + +type I3Output struct { + Name string + Active bool + CurrentWorkspace string `json:"current_workspace"` +} + +type I3Workspace struct { + Id int + Num int + Name string + Visible bool + Focused bool + Output string +} + +type I3Config struct { + Config string +} + +type I3EventBinding struct { + Change string + Binding struct { + InputCode int `json:"input_code"` + Symbol string + Command string + Mods []string + EventStateMask []string `json:"event_state_mask"` + } +}