Initial commit

This commit is contained in:
Magnus Åhall 2021-12-21 19:43:46 +01:00
commit f816d60138
6 changed files with 622 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
i3-session-manager
test/

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module i3-session-manager
go 1.16

109
main.go Normal file
View File

@ -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()
}
}

371
session.go Normal file
View File

@ -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

91
socket.go Normal file
View File

@ -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
}

46
types.go Normal file
View File

@ -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"`
}
}