From 66e28f3069736e7ba20be3bd185f5865ad518a76 Mon Sep 17 00:00:00 2001 From: Joshua Rubin Date: Fri, 12 Apr 2019 16:31:58 -0600 Subject: [PATCH] starting to work --- client.go | 142 ++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 9 ++++ go.sum | 11 ++++ internal.go | 69 ++++++++++++++++++++++++ socket_test.go | 61 +++++++++++++++++++++ subscribe.go | 90 +++++++++++++++++++++++++++++++ types.go | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 525 insertions(+) create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal.go create mode 100644 socket_test.go create mode 100644 subscribe.go create mode 100644 types.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..ec47145 --- /dev/null +++ b/client.go @@ -0,0 +1,142 @@ +package sway + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net" + "os" + "strings" + + "go.uber.org/multierr" +) + +type client struct { + conn net.Conn + path string +} + +type Client interface { + GetTree(context.Context) (*Node, error) + RunCommand(context.Context, string) ([]RunCommandReply, error) + Close() error +} + +func New(ctx context.Context) (_ Client, err error) { + c := &client{} + + if c.path = strings.TrimSpace(os.Getenv("SWAYSOCK")); c.path == "" { + return nil, fmt.Errorf("$SWAYSOCK is empty") + } + + c.conn, err = (&net.Dialer{}).DialContext(ctx, "unix", c.path) + return c, err +} + +func (c *client) Close() error { + return c.conn.Close() +} + +type payloadReader struct { + io.Reader +} + +func (r payloadReader) Close() error { + _, err := ioutil.ReadAll(r) + return err +} + +func (c *client) recvMsg(ctx context.Context) (*message, error) { + var h header + err := do(ctx, func() error { + return binary.Read(c.conn, binary.LittleEndian, &h) + }) + if err != nil { + return nil, err + } + + return &message{ + Type: h.Type, + Payload: payloadReader{io.LimitReader(c.conn, int64(h.Length))}, + }, nil +} + +func (c *client) roundTrip(ctx context.Context, t messageType, payload []byte) (*message, error) { + if c == nil { + return nil, fmt.Errorf("not connected") + } + + err := do(ctx, func() error { + err := binary.Write(c.conn, binary.LittleEndian, &header{magic, uint32(len(payload)), t}) + if err != nil { + return nil + } + + _, err = c.conn.Write(payload) + return err + }) + if err != nil { + return nil, err + } + + return c.recvMsg(ctx) +} + +func (c *client) GetTree(ctx context.Context) (*Node, error) { + b, err := c.roundTrip(ctx, messageTypeGetTree, nil) + if err != nil { + return nil, err + } + + var n Node + return &n, b.Decode(&n) +} + +func (c *client) subscribe(ctx context.Context, events ...EventType) error { + payload, err := json.Marshal(events) + if err != nil { + return err + } + + msg, err := c.roundTrip(ctx, messageTypeSubscribe, payload) + if err != nil { + return err + } + + var reply struct { + Success bool `json:"success"` + } + + if err = msg.Decode(&reply); err != nil { + return err + } + + if !reply.Success { + return fmt.Errorf("subscribe unsuccessful") + } + + return nil +} + +func (c *client) RunCommand(ctx context.Context, command string) ([]RunCommandReply, error) { + msg, err := c.roundTrip(ctx, messageTypeRunCommand, []byte(command)) + if err != nil { + return nil, err + } + + var replies []RunCommandReply + if err = msg.Decode(&replies); err != nil { + return nil, err + } + + for _, reply := range replies { + if !reply.Success { + err = multierr.Append(err, fmt.Errorf("command %q unsuccessful: %v", command, reply.Error)) + } + } + + return replies, err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1af4ebf --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/joshuarubin/go-sway + +go 1.12 + +require ( + github.com/stretchr/testify v1.3.0 // indirect + go.uber.org/atomic v1.3.2 // indirect + go.uber.org/multierr v1.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a4925fd --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.uber.org/atomic v1.3.2 h1:2Oa65PReHzfn29GpvgsYwloV9AVFHPDk8tYxt2c2tr4= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= diff --git a/internal.go b/internal.go new file mode 100644 index 0000000..27edf52 --- /dev/null +++ b/internal.go @@ -0,0 +1,69 @@ +package sway + +import ( + "context" + "encoding/json" + "io" +) + +type header struct { + Magic [6]byte + Length uint32 + Type messageType +} + +type message struct { + Type messageType + Payload io.ReadCloser +} + +func (m message) Decode(v interface{}) error { + defer m.Payload.Close() + return json.NewDecoder(m.Payload).Decode(v) +} + +type messageType uint32 + +const ( + messageTypeRunCommand messageType = iota + messageTypeGetWorkspaces + messageTypeSubscribe + messageTypeGetOutputs + messageTypeGetTree + messageTypeGetMarks + messageTypeGetBarConfig + messageTypeGetVersion + messageTypeGetBindingModes + messageTypeGetConfig + messageTypeSendTick + messageTypeSync +) + +const ( + eventReplyTypeWorkspace messageType = 0x80000000 + eventReplyTypeMode messageType = 0x80000002 + eventReplyTypeWindow messageType = 0x80000003 + eventReplyTypeBarConfigUpdate messageType = 0x80000004 + eventReplyTypeBinding messageType = 0x80000005 + eventReplyTypeShutdown messageType = 0x80000006 + eventReplyTypeTick messageType = 0x80000007 + eventReplyTypeBarStatusUpdate messageType = 0x80000014 +) + +var magic = [6]byte{'i', '3', '-', 'i', 'p', 'c'} + +func do(ctx context.Context, fn func() error) error { + done := make(chan struct{}) + var err error + go func() { + err = fn() + close(done) + }() + + select { + case <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/socket_test.go b/socket_test.go new file mode 100644 index 0000000..068a77e --- /dev/null +++ b/socket_test.go @@ -0,0 +1,61 @@ +package sway_test + +import ( + "context" + "log" + "testing" + "time" + + sway "github.com/joshuarubin/go-sway" +) + +func TestSocket(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client, err := sway.New(ctx) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + n, err := client.GetTree(ctx) + if err != nil { + t.Fatal(err) + } + + fh := focusHandler(client) + + fh(ctx, n.FocusedNode()) + + h := sway.EventHandler{ + Window: func(ctx context.Context, e sway.WindowEvent) { + if e.Change != sway.WindowChangeFocus { + return + } + fh(ctx, e.Container.FocusedNode()) + }, + } + + err = sway.Subscribe(ctx, h, sway.EventTypeWindow, sway.EventTypeShutdown) + if err != context.DeadlineExceeded && err != nil { + t.Fatal(err) + } +} + +func focusHandler(client sway.Client) func(context.Context, *sway.Node) { + return func(ctx context.Context, node *sway.Node) { + if node == nil { + return + } + + opt := "none" + if node.AppID == nil || *node.AppID != "kitty" { + opt = "altwin:ctrl_win" + } + + if _, err := client.RunCommand(ctx, `input '*' xkb_options `+opt); err != nil { + log.Println(err) + } + } +} diff --git a/subscribe.go b/subscribe.go new file mode 100644 index 0000000..292db84 --- /dev/null +++ b/subscribe.go @@ -0,0 +1,90 @@ +package sway + +import ( + "context" +) + +type EventType string + +const ( + EventTypeWorkspace EventType = "workspace" + EventTypeMode EventType = "mode" + EventTypeWindow EventType = "window" + EventTypeBarConfigUpdate EventType = "barconfig_update" + EventTypeBinding EventType = "binding" + EventTypeShutdown EventType = "shutdown" + EventTypeTick EventType = "tick" + EventTypeBarStatusUpdate EventType = "bar_status_update" +) + +type EventHandler struct { + Workspace func(context.Context, WorkspaceEvent) + Window func(context.Context, WindowEvent) + Shutdown func(context.Context, ShutdownEvent) +} + +func Subscribe(ctx context.Context, handler EventHandler, events ...EventType) error { + n, err := New(ctx) + if err != nil { + return err + } + defer n.Close() + + c := n.(*client) + + if err = c.subscribe(ctx, events...); err != nil { + return err + } + + for { + reply, err := c.recvMsg(ctx) + if err != nil { + return err + } + + handler.process(ctx, reply) + } +} + +func (h EventHandler) process(ctx context.Context, reply *message) { + switch reply.Type { + case eventReplyTypeWorkspace: + if h.Workspace == nil { + return + } + + var e WorkspaceEvent + if err := reply.Decode(&e); err != nil { + return + } + + h.Workspace(ctx, e) + case eventReplyTypeMode: + case eventReplyTypeWindow: + if h.Window == nil { + return + } + + var e WindowEvent + if err := reply.Decode(&e); err != nil { + return + } + + h.Window(ctx, e) + case eventReplyTypeBarConfigUpdate: + case eventReplyTypeBinding: + case eventReplyTypeShutdown: + if h.Shutdown == nil { + return + } + + var e ShutdownEvent + if err := reply.Decode(&e); err != nil { + return + } + + h.Shutdown(ctx, e) + case eventReplyTypeTick: + case eventReplyTypeBarStatusUpdate: + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..59b97bf --- /dev/null +++ b/types.go @@ -0,0 +1,143 @@ +package sway + +type NodeID int64 + +type NodeType string + +const ( + NodeTypeRoot NodeType = "root" + NodeTypeOutput NodeType = "output" + NodeTypeCon NodeType = "con" + NodeTypeFloatingCon NodeType = "floating_con" + NodeTypeWorkspace NodeType = "workspace" +) + +type BorderStyle string + +const ( + BorderStyleNormal BorderStyle = "normal" + BorderStyleNone BorderStyle = "none" + BorderStylePixel BorderStyle = "pixel" + BorderStyleCSD BorderStyle = "csd" +) + +type Layout string + +const ( + LayoutSplitH Layout = "splith" + LayoutSplitV Layout = "splitv" + LayoutStacked Layout = "stacked" + LayoutTabbed Layout = "tabbed" + LayoutOutput Layout = "output" +) + +type Rect struct { + X int64 `json:"x"` + Y int64 `json:"y"` + Width int64 `json:"width"` + Height int64 `json:"height"` +} + +type WindowProperties struct { + Title string `json:"title"` + Instance string `json:"instance"` + Class string `json:"class"` + Role string `json:"window_role"` + TransientFor NodeID `json:"transient_for"` +} + +type Node struct { + ID NodeID `json:"id"` + Name string `json:"name"` + Type NodeType `json:"type"` + Border BorderStyle `json:"border"` + CurrentBorderWidth int64 `json:"current_border_width"` + Layout Layout `json:"layout"` + Percent *float64 `json:"percent"` + Rect Rect `json:"rect"` + WindowRect Rect `json:"window_rect"` + DecoRect Rect `json:"deco_rect"` + Geometry Rect `json:"geometry"` + Urgent *bool `json:"urgent"` + Focused bool `json:"focused"` + Focus []NodeID `json:"focus"` + Nodes []*Node `json:"nodes"` + FloatingNodes []*Node `json:"floating_nodes"` + Representation *string `json:"representation"` + AppID *string `json:"app_id"` + PID *uint32 `json:"pid"` + Window *int64 `json:"window"` + WindowProperties *WindowProperties `json:"window_properties"` +} + +func (n *Node) FocusedNode() *Node { + queue := []*Node{n} + for len(queue) > 0 { + n = queue[0] + queue = queue[1:] + + if n == nil { + continue + } + + if n.Focused { + return n + } + + queue = append(queue, n.Nodes...) + queue = append(queue, n.FloatingNodes...) + } + return nil +} + +type Event interface{} + +type WorkspaceChange string + +const ( + WorkspaceChangeInit WorkspaceChange = "init" + WorkspaceChangeEmpty WorkspaceChange = "empty" + WorkspaceChangeFocus WorkspaceChange = "focus" + WorkspaceChangeMove WorkspaceChange = "move" + WorkspaceChangeRename WorkspaceChange = "rename" + WorkspaceChangeUrgent WorkspaceChange = "urgent" + WorkspaceChangeReload WorkspaceChange = "reload" +) + +type WorkspaceEvent struct { + Change WorkspaceChange `json:"change"` + Current *Node `json:"current"` + Old *Node `json:"old"` +} + +type WindowChange string + +const ( + WindowChangeNew WindowChange = "new" + WindowChangeClose WindowChange = "close" + WindowChangeFocus WindowChange = "focus" + WindowChangeTitle WindowChange = "title" + WindowChangeFullscreenMode WindowChange = "fullscreen_mode" + WindowChangeMove WindowChange = "move" + WindowChangeFloating WindowChange = "floating" + WindowChangeUrgent WindowChange = "urgent" + WindowChangeMark WindowChange = "mark" +) + +type WindowEvent struct { + Change WindowChange `json:"change"` + Container Node `json:"container"` +} + +type ShutdownChange string + +const ShutdownChangeExit ShutdownChange = "exit" + +type ShutdownEvent struct { + Change ShutdownChange `json:"change"` +} + +type RunCommandReply struct { + Success bool `json:"success"` + Error string `json:"error"` +}