5 Commits

Author SHA1 Message Date
bde1301e2c added links 2025-08-12 18:20:18 +02:00
977c374197 get rid of os/exec and talk with sway directly via IPC 2025-08-12 18:20:18 +02:00
4741481527 updated docs 2025-08-12 18:20:18 +02:00
dacdc5c214 add doc about inner workings 2025-08-11 11:38:56 +02:00
1bc1b2a963 added release hint 2025-08-11 09:41:17 +02:00
2 changed files with 276 additions and 130 deletions

View File

@@ -9,8 +9,13 @@ the tool to `ALT-tab` and there you go.
## Installation ## Installation
Checkout the repo and execute `make`. You'll need the go toolkit. Then Download the binary for your architecture from the [release
copy the binary `swaycycle` to some location within your `$PATH`. page](https://github.com/TLINDEN/swaycycle/releases) and copy to to to
some location within your `$PATH`.
To build the tool from source, checkout the repo and execute
`make`. You'll need the go toolkit. Then copy the binary `swaycycle`
to some location within your `$PATH`.
## Configuration ## Configuration
@@ -35,6 +40,29 @@ It's also possible to debug an instance executed by sway using the
bindsym $mod+Tab exec ~/bin/swaycycle -d -l /tmp/cycle.log bindsym $mod+Tab exec ~/bin/swaycycle -d -l /tmp/cycle.log
``` ```
## How does it work?
`swaycycle` is being executed by sway when the user presses a key
(e.g. `ALT-tab`). It then connects to the running sway instance via
the provided IPC unix domain socket as available in the environment
variable `SWAYSOCK`. Via that connection it sends the `GET_TREE`
command and processes the retrieved JSON response. This JSON tree
contains all information about the running instance such as outputs,
workspaces and containers.
Then it determines which workspace is the current active one and
builds a list of all windows visible on that workspace, whether
floating or not.
Next it determines which window is following the one in the list with
the current active focus. If the active one is at the end of the list,
it starts from the top.
Finally `swaycycle` sends the propper switch focus command via the IPC
connection to sway, e.g.:
`[con_id=14] focus`
## Getting help ## Getting help
Although I'm happy to hear from swaycycle users in private email, that's the Although I'm happy to hear from swaycycle users in private email, that's the
@@ -44,6 +72,12 @@ In order to report a bug, unexpected behavior, feature requests or to
submit a patch, please open an issue on github: submit a patch, please open an issue on github:
https://github.com/tlinden/swaycycle/issues. https://github.com/tlinden/swaycycle/issues.
## See also
- [sway-ipc(7)](https://www.mankier.com/7/sway-ipc)
- [swaywm](https://github.com/swaywm/sway/)
- [swayfs](https://github.com/WillPower3309/swayfx)
## Copyright and license ## Copyright and license
This software is licensed under the GNU GENERAL PUBLIC LICENSE version 3. This software is licensed under the GNU GENERAL PUBLIC LICENSE version 3.

368
main.go
View File

@@ -18,14 +18,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main package main
import ( import (
"bytes" "encoding/binary"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"log/slog" "log/slog"
"net"
"os" "os"
"os/exec"
"runtime/debug" "runtime/debug"
"github.com/lmittmann/tint" "github.com/lmittmann/tint"
@@ -47,6 +48,12 @@ type Node struct {
Current_workspace string `json:"current_workspace"` Current_workspace string `json:"current_workspace"`
} }
type Response struct {
Success bool `json:"success"`
ParseError bool `json:"parse_error"`
Error string `json:"error"`
}
const ( const (
root = iota + 1 root = iota + 1
output output
@@ -56,7 +63,14 @@ const (
LevelNotice = slog.Level(2) 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 ( var (
@@ -73,15 +87,14 @@ var (
const Usage string = `This is swaycycle - cycle focus through all visible windows on a sway workspace. const Usage string = `This is swaycycle - cycle focus through all visible windows on a sway workspace.
Usage: swaycycle [-vVdDn] [-l <log>] Usage: swaycycle [-vdDn] [-l <log>]
Options: Options:
-n, --no-switch do not switch windows -n, --no-switch do not switch windows
-d, --debug enable debugging -d, --debug enable debugging
-D, --dump dump the sway tree (needs -d as well) -D, --dump dump the sway tree (needs -d as well)
-l, --logfile string write output to logfile -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. Copyleft (L) 2025 Thomas von Dein.
Licensed under the terms of the GNU GPL version 3. 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() { func main() {
flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging") flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging")
flag.BoolVarP(&Dumptree, "dump", "D", false, "dump the sway tree (needs -d as well)") 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(&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.BoolVarP(&Showhelp, "help", "h", Showhelp, "show help")
flag.StringVarP(&Logfile, "logfile", "l", "", "write output to logfile") flag.StringVarP(&Logfile, "logfile", "l", "", "write output to logfile")
@@ -120,156 +132,124 @@ func main() {
setupLogging(os.Stdout) setupLogging(os.Stdout)
} }
// fills Visibles node list // connect to sway unix socket
fetchSwayTree() 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 { if len(Visibles) == 0 {
os.Exit(0) os.Exit(0)
} }
id := findNextWindow() id := findNextWindow()
slog.Debug("findNextWindow", "nextid", id)
if id > 0 && !Notswitch { if id > 0 && !Notswitch {
switchFocus(id) switchFocus(id, unixsock)
} }
} }
func setupLogging(output io.Writer) { // connect to unix socket
logLevel := &slog.LevelVar{} func setupIPC() (net.Conn, error) {
sockfile := os.Getenv("SWAYSOCK")
if !Debug { if sockfile == "" {
// default logging return nil, fmt.Errorf("Environment variable SWAYSOCK does not exist or is empty")
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)
} }
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 // send a sway message header
// one has focus, return the first func sendHeaderIPC(sock net.Conn, messageType uint32, len uint32) error {
func findNextWindow() int { sendPayload := make([]byte, IPC_HEADER_SIZE)
if len(Visibles) == 0 { copy(sendPayload, []byte(IPC_MAGIC))
return 0 binary.LittleEndian.PutUint32(sendPayload[6:], len)
} binary.LittleEndian.PutUint32(sendPayload[10:], messageType)
seenfocused := false _, err := sock.Write(sendPayload)
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()
if err != nil { if err != nil {
slog.Debug("failed to execute swaymsg", "output", out) return fmt.Errorf("failed to send header to IPC %w", err)
log.Fatalf("Failed to execute swaymsg to switch focus: %s", err)
} }
if errbuf.String() != "" { return nil
log.Fatalf("swaymsg error: %s", errbuf.String())
}
slog.Info("switched focus", "con_id", id)
} }
// execute swaymsg to get its internal tree // send a payload, header had to be sent before
func fetchSwayTree() { func sendPayloadIPC(sock net.Conn, payload []byte) error {
var cmd *exec.Cmd _, err := sock.Write(payload)
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()
if err != nil { 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() != "" { return nil
log.Fatalf("swaymsg error: %s", errbuf.String())
}
if err := processJSON(out); err != nil {
log.Fatalf("%s", err)
}
} }
func istype(nd Node, which int) bool { // read a response, reads response header and returns payload only
switch nd.Nodetype { func readResponseIPC(sock net.Conn) ([]byte, error) {
case "root": // read header
return which == root buf := make([]byte, IPC_HEADER_SIZE)
case "output":
return which == output _, err := sock.Read(buf)
case "workspace": if err != nil {
return which == workspace return nil, fmt.Errorf("failed to read header from socket: %s", err)
case "con":
return which == con
case "floating_con":
return which == floating
} }
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 // get into the sway tree, determine current workspace and extract all
@@ -303,6 +283,80 @@ func processJSON(jsoncode []byte) error {
return nil 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 // iterate recursively over given node list extracting visible windows
func recurseNodes(nodes []Node) { func recurseNodes(nodes []Node) {
for _, node := range nodes { 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 // returns TRUE if stdout is NOT a tty or windows
func IsNoTty() bool { func IsNoTty() bool {
if !isatty.IsTerminal(os.Stdout.Fd()) { if !isatty.IsTerminal(os.Stdout.Fd()) {