diff --git a/main.go b/main.go
index c23205c..eaa275d 100644
--- a/main.go
+++ b/main.go
@@ -18,14 +18,15 @@ along with this program. If not, see .
package main
import (
- "bytes"
+ "encoding/binary"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
+ "net"
"os"
- "os/exec"
"runtime/debug"
"github.com/lmittmann/tint"
@@ -47,6 +48,12 @@ type Node struct {
Current_workspace string `json:"current_workspace"`
}
+type Response struct {
+ Success bool `json:"success"`
+ ParseError bool `json:"parse_error"`
+ Error string `json:"error"`
+}
+
const (
root = iota + 1
output
@@ -56,7 +63,14 @@ const (
LevelNotice = slog.Level(2)
- VERSION = "v0.1.3"
+ VERSION = "v0.2.0"
+
+ IPC_HEADER_SIZE = 14
+ IPC_MAGIC = "i3-ipc"
+
+ // message types
+ IPC_GET_TREE = 4
+ IPC_RUN_COMMAND = 0
)
var (
@@ -73,15 +87,14 @@ var (
const Usage string = `This is swaycycle - cycle focus through all visible windows on a sway workspace.
-Usage: swaycycle [-vVdDn] [-l ]
+Usage: swaycycle [-vdDn] [-l ]
Options:
-n, --no-switch do not switch windows
-d, --debug enable debugging
-D, --dump dump the sway tree (needs -d as well)
-l, --logfile string write output to logfile
- -v, --verbose enable verbose logging
- -V, --version show program version
+ -v, --version show program version
Copyleft (L) 2025 Thomas von Dein.
Licensed under the terms of the GNU GPL version 3.
@@ -90,9 +103,8 @@ Licensed under the terms of the GNU GPL version 3.
func main() {
flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging")
flag.BoolVarP(&Dumptree, "dump", "D", false, "dump the sway tree (needs -d as well)")
- flag.BoolVarP(&Verbose, "verbose", "v", false, "enable verbose logging")
flag.BoolVarP(&Notswitch, "no-switch", "n", false, "do not switch windows")
- flag.BoolVarP(&Version, "version", "V", false, "show program version")
+ flag.BoolVarP(&Version, "version", "v", false, "show program version")
flag.BoolVarP(&Showhelp, "help", "h", Showhelp, "show help")
flag.StringVarP(&Logfile, "logfile", "l", "", "write output to logfile")
@@ -120,156 +132,124 @@ func main() {
setupLogging(os.Stdout)
}
- // fills Visibles node list
- fetchSwayTree()
+ // connect to sway unix socket
+ unixsock, err := setupIPC()
+ if err != nil {
+ log.Fatalf("Failed to connect to sway unix socket: %s", err)
+ }
+
+ // retrieve the raw json tree
+ rawjson, err := getTree(unixsock)
+ if err != nil {
+ log.Fatalf("Failed to retrieve raw json tree: %s", err)
+ }
+
+ // traverse the tree and find visible windows
+ if err := processJSON(rawjson); err != nil {
+ log.Fatalf("%s", err)
+ }
if len(Visibles) == 0 {
os.Exit(0)
}
id := findNextWindow()
+ slog.Debug("findNextWindow", "nextid", id)
if id > 0 && !Notswitch {
- switchFocus(id)
+ switchFocus(id, unixsock)
}
}
-func setupLogging(output io.Writer) {
- logLevel := &slog.LevelVar{}
+// connect to unix socket
+func setupIPC() (net.Conn, error) {
+ sockfile := os.Getenv("SWAYSOCK")
- if !Debug {
- // default logging
- opts := &tint.Options{
- Level: logLevel,
- AddSource: false,
- NoColor: IsNoTty(),
- }
-
- if Verbose {
- logLevel.Set(slog.LevelInfo)
- } else {
- logLevel.Set(LevelNotice)
- }
-
- handler := tint.NewHandler(output, opts)
- logger := slog.New(handler)
-
- slog.SetDefault(logger)
- } else {
- // we're using a more verbose logger in debug mode
- buildInfo, _ := debug.ReadBuildInfo()
- opts := &yadu.Options{
- Level: logLevel,
- AddSource: true,
- }
-
- logLevel.Set(slog.LevelDebug)
-
- handler := yadu.NewHandler(output, opts)
- debuglogger := slog.New(handler).With(
- slog.Group("program_info",
- slog.Int("pid", os.Getpid()),
- slog.String("go_version", buildInfo.GoVersion),
- ),
- )
- slog.SetDefault(debuglogger)
+ if sockfile == "" {
+ return nil, fmt.Errorf("Environment variable SWAYSOCK does not exist or is empty")
}
+
+ conn, err := net.Dial("unix", sockfile)
+ if err != nil {
+ return nil, err
+ }
+
+ return conn, nil
}
-// find the next window after the one with current focus. if the last
-// one has focus, return the first
-func findNextWindow() int {
- if len(Visibles) == 0 {
- return 0
- }
+// send a sway message header
+func sendHeaderIPC(sock net.Conn, messageType uint32, len uint32) error {
+ sendPayload := make([]byte, IPC_HEADER_SIZE)
+ copy(sendPayload, []byte(IPC_MAGIC))
+ binary.LittleEndian.PutUint32(sendPayload[6:], len)
+ binary.LittleEndian.PutUint32(sendPayload[10:], messageType)
- seenfocused := false
-
- for _, node := range Visibles {
- if node.Focused {
- seenfocused = true
- continue
- }
-
- if seenfocused {
- return node.Id
- }
- }
-
- if seenfocused {
- return Visibles[0].Id
- }
-
- return 0
-}
-
-// actually switch focus using a swaymsg command
-func switchFocus(id int) {
- var cmd *exec.Cmd
- arg := fmt.Sprintf("[con_id=%d]", id)
-
- slog.Debug("executing", "command", "swaymsg "+arg+" focus")
-
- cmd = exec.Command("swaymsg", arg, "focus")
-
- errbuf := &bytes.Buffer{}
- cmd.Stderr = errbuf
-
- out, err := cmd.Output()
+ _, err := sock.Write(sendPayload)
if err != nil {
- slog.Debug("failed to execute swaymsg", "output", out)
- log.Fatalf("Failed to execute swaymsg to switch focus: %s", err)
+ return fmt.Errorf("failed to send header to IPC %w", err)
}
- if errbuf.String() != "" {
- log.Fatalf("swaymsg error: %s", errbuf.String())
- }
-
- slog.Info("switched focus", "con_id", id)
+ return nil
}
-// execute swaymsg to get its internal tree
-func fetchSwayTree() {
- var cmd *exec.Cmd
-
- slog.Debug("executing", "command", "swaymsg -t get_tree -r")
-
- cmd = exec.Command("swaymsg", "-t", "get_tree", "-r")
-
- errbuf := &bytes.Buffer{}
- cmd.Stderr = errbuf
-
- out, err := cmd.Output()
+// send a payload, header had to be sent before
+func sendPayloadIPC(sock net.Conn, payload []byte) error {
+ _, err := sock.Write(payload)
if err != nil {
- log.Fatalf("Failed to execute swaymsg to get json tree: %s", err)
+ return fmt.Errorf("failed to send payload to IPC %w", err)
}
- if errbuf.String() != "" {
- log.Fatalf("swaymsg error: %s", errbuf.String())
- }
-
- if err := processJSON(out); err != nil {
- log.Fatalf("%s", err)
- }
+ return nil
}
-func istype(nd Node, which int) bool {
- switch nd.Nodetype {
- case "root":
- return which == root
- case "output":
- return which == output
- case "workspace":
- return which == workspace
- case "con":
- return which == con
- case "floating_con":
- return which == floating
+// read a response, reads response header and returns payload only
+func readResponseIPC(sock net.Conn) ([]byte, error) {
+ // read header
+ buf := make([]byte, IPC_HEADER_SIZE)
+
+ _, err := sock.Read(buf)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read header from socket: %s", err)
}
- return false
+ slog.Debug("got IPC header", "header", hex.EncodeToString(buf))
+
+ if string(buf[:6]) != IPC_MAGIC {
+ return nil, fmt.Errorf("got invalid IPC response from sway socket")
+ }
+
+ payloadLen := binary.LittleEndian.Uint32(buf[6:10])
+
+ if payloadLen == 0 {
+ return nil, fmt.Errorf("got empty payload IPC response from sway socket")
+ }
+
+ // read payload
+ payload := make([]byte, payloadLen)
+
+ _, err = sock.Read(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read payload from socket: %s", err)
+ }
+
+ return payload, nil
+}
+
+// get raw JSON tree via sway IPC
+func getTree(sock net.Conn) ([]byte, error) {
+ err := sendHeaderIPC(sock, IPC_GET_TREE, 0)
+ if err != nil {
+ return nil, err
+ }
+
+ payload, err := readResponseIPC(sock)
+ if err != nil {
+ return nil, err
+ }
+
+ return payload, nil
}
// get into the sway tree, determine current workspace and extract all
@@ -303,6 +283,80 @@ func processJSON(jsoncode []byte) error {
return nil
}
+// find the next window after the one with current focus. if the last
+// one has focus, return the first
+func findNextWindow() int {
+ if len(Visibles) == 0 {
+ return 0
+ }
+
+ seenfocused := false
+
+ for _, node := range Visibles {
+ if node.Focused {
+ seenfocused = true
+ continue
+ }
+
+ if seenfocused {
+ return node.Id
+ }
+ }
+
+ if seenfocused {
+ return Visibles[0].Id
+ }
+
+ return 0
+}
+
+// actually switch focus using a swaymsg command
+func switchFocus(id int, sock net.Conn) error {
+ command := fmt.Sprintf("[con_id=%d] focus", id)
+
+ slog.Debug("executing", "command", command)
+
+ // send switch focus command
+ err := sendHeaderIPC(sock, IPC_RUN_COMMAND, uint32(len(command)))
+ if err != nil {
+ return err
+ }
+
+ if err != nil {
+ return fmt.Errorf("failed to send run_command to IPC %w", err)
+ }
+
+ err = sendPayloadIPC(sock, []byte(command))
+ if err != nil {
+ return fmt.Errorf("failed to send switch focus command: %w", err)
+ }
+
+ // check response from sway
+ payload, err := readResponseIPC(sock)
+ if err != nil {
+ return err
+ }
+
+ responses := []Response{}
+
+ if err := json.Unmarshal(payload, &responses); err != nil {
+ return fmt.Errorf("Failed to unmarshal json response: %w", err)
+ }
+
+ if len(responses) == 0 {
+ return fmt.Errorf("Got invalid IPC zero response")
+ }
+
+ if !responses[0].Success {
+ slog.Debug("IPC response to switch focus command", "response", responses)
+ return fmt.Errorf("Failed to switch focus: %s", responses[0].Error)
+ }
+
+ slog.Info("switched focus", "con_id", id)
+
+ return nil
+}
+
// iterate recursively over given node list extracting visible windows
func recurseNodes(nodes []Node) {
for _, node := range nodes {
@@ -330,6 +384,64 @@ func recurseNodes(nodes []Node) {
}
}
+// we use line wise logging, unless debugging is enabled
+func setupLogging(output io.Writer) {
+ logLevel := &slog.LevelVar{}
+
+ if !Debug {
+ // default logging
+ opts := &tint.Options{
+ Level: logLevel,
+ AddSource: false,
+ NoColor: IsNoTty(),
+ }
+
+ logLevel.Set(slog.LevelInfo)
+
+ handler := tint.NewHandler(output, opts)
+ logger := slog.New(handler)
+
+ slog.SetDefault(logger)
+ } else {
+ // we're using a more verbose logger in debug mode
+ buildInfo, _ := debug.ReadBuildInfo()
+ opts := &yadu.Options{
+ Level: logLevel,
+ AddSource: true,
+ }
+
+ logLevel.Set(slog.LevelDebug)
+
+ handler := yadu.NewHandler(output, opts)
+ debuglogger := slog.New(handler).With(
+ slog.Group("program_info",
+ slog.Int("pid", os.Getpid()),
+ slog.String("go_version", buildInfo.GoVersion),
+ ),
+ )
+
+ slog.SetDefault(debuglogger)
+ }
+}
+
+// little helper to distinguish sway tree node types
+func istype(nd Node, which int) bool {
+ switch nd.Nodetype {
+ case "root":
+ return which == root
+ case "output":
+ return which == output
+ case "workspace":
+ return which == workspace
+ case "con":
+ return which == con
+ case "floating_con":
+ return which == floating
+ }
+
+ return false
+}
+
// returns TRUE if stdout is NOT a tty or windows
func IsNoTty() bool {
if !isatty.IsTerminal(os.Stdout.Fd()) {