├── .github └── workflows │ └── go.yml ├── LICENSE ├── README.md ├── barconfig.go ├── bindingmodes.go ├── byteorder.go ├── byteorder_test.go ├── close_test.go ├── command.go ├── common_test.go ├── config.go ├── doc.go ├── example_test.go ├── getpid.go ├── go.mod ├── go.sum ├── golden_test.go ├── marks.go ├── outputs.go ├── restart_test.go ├── socket.go ├── subscribe.go ├── subscribe_test.go ├── sync.go ├── sync_test.go ├── testdata └── i3.config ├── tick.go ├── travis └── Dockerfile ├── tree.go ├── tree_utils.go ├── tree_utils_test.go ├── validpid.go ├── version.go └── workspaces.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: CI 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-go@v5 19 | with: 20 | # Latest stable version of Go, e.g. go1.24.1 21 | go-version: 'stable' 22 | 23 | - name: Ensure all files were formatted as per gofmt 24 | run: | 25 | [ "$(gofmt -l $(find . -name '*.go') 2>&1)" = "" ] 26 | 27 | - name: Get dependencies 28 | run: | 29 | go get -v -t -d ./... 30 | 31 | - name: Go Vet 32 | run: | 33 | go vet 34 | 35 | - name: Build 36 | run: | 37 | go build -v . 38 | 39 | - name: Test 40 | run: | 41 | go test -c 42 | 43 | - name: Build Docker container with i3 44 | run: | 45 | docker build --pull --no-cache --rm -t=goi3 -f travis/Dockerfile . 46 | 47 | - name: Run tests in Docker container 48 | # The --init flag is load-bearing! Xserver(1) (including the Xvfb variant) 49 | # will not send SIGUSR1 for readiness notification to pid 1, so we need to 50 | # ensure that the i3.test process is not pid 1: 51 | # https://gitlab.freedesktop.org/xorg/xserver/-/blob/4195e8035645007be313ade79032b8d561ceec6c/os/connection.c#L207 52 | run: | 53 | docker run --init -v $PWD:/usr/src goi3 ./i3.test -test.v 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2017, Michael Stapelberg and contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Michael Stapelberg nor the 15 | names of contributors may be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY Michael Stapelberg ''AS IS'' AND ANY 19 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL Michael Stapelberg BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub Actions CI](https://github.com/i3/go-i3/workflows/GitHub%20Actions%20CI/badge.svg) 2 | [![Go Report Card](https://goreportcard.com/badge/go.i3wm.org/i3)](https://goreportcard.com/report/go.i3wm.org/i3) 3 | [![GoDoc](https://godoc.org/go.i3wm.org/i3?status.svg)](https://godoc.org/go.i3wm.org/i3) 4 | 5 | Package i3 provides a convenient interface to the i3 window manager via [its IPC 6 | interface](https://i3wm.org/docs/ipc.html). 7 | 8 | See [its documentation](https://godoc.org/go.i3wm.org/i3) for more details. 9 | 10 | ## Start using it 11 | 12 | In [module mode](https://github.com/golang/go/wiki/Modules), use import path 13 | `go.i3wm.org/i3/v4`. 14 | 15 | In non-module mode, use import path `go.i3wm.org/i3`. 16 | 17 | ## Advantages over other i3 IPC packages 18 | 19 | Here comes a grab bag of features to which we paid attention. At the time of 20 | writing, most other i3 IPC packages lack at least a good number of these 21 | features: 22 | 23 | * Retries are transparently handled: programs using this package will recover 24 | automatically from in-place i3 restarts. Additionally, programs can be started 25 | from xsession or user sessions before i3 is even running. 26 | 27 | * Version checks are transparently handled: if your program uses features which 28 | are not supported by the running i3 version, helpful error messages will be 29 | returned at run time. 30 | 31 | * Comprehensive: the entire documented IPC interface of the latest stable i3 32 | version is covered by this package. Tagged releases match i3’s major and minor 33 | version. 34 | 35 | * Consistent and familiar: once familiar with the i3 IPC protocol’s features, 36 | you should have no trouble matching the documentation to API and vice-versa. 37 | 38 | * Good test coverage (hard to display in a badge, as our multi-process setup 39 | breaks `go test`’s `-coverprofile` flag). 40 | 41 | * Implemented in pure Go, without resorting to the `unsafe` package. 42 | 43 | * Works on little and big endian architectures. 44 | 45 | ## Scope 46 | 47 | i3’s entire documented IPC interface is available in this package. 48 | 49 | In addition, helper functions which are useful for a broad range of programs 50 | (and only those!) are provided, e.g. Node’s FindChild and FindFocused. 51 | 52 | Packages which introduce higher-level abstractions should feel free to use this 53 | package as a building block. 54 | 55 | ## Assumptions 56 | 57 | * The `i3(1)` binary must be in `$PATH` so that the IPC socket path can be retrieved. 58 | * For transparent version checks to work, the running i3 version must be ≥ 4.3 (released 2012-09-19). 59 | 60 | ## Testing 61 | 62 | Be sure to include the target i3 version (the most recent stable release) in 63 | `$PATH` and use `go test` as usual: 64 | 65 | ```shell 66 | PATH=~/i3/build/i3:$PATH go test -v go.i3wm.org/i3 67 | ``` 68 | -------------------------------------------------------------------------------- /barconfig.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // BarConfigColors describes a serialized bar colors configuration block. 6 | // 7 | // See https://i3wm.org/docs/ipc.html#_bar_config_reply for more details. 8 | type BarConfigColors struct { 9 | Background string `json:"background"` 10 | Statusline string `json:"statusline"` 11 | Separator string `json:"separator"` 12 | 13 | FocusedBackground string `json:"focused_background"` 14 | FocusedStatusline string `json:"focused_statusline"` 15 | FocusedSeparator string `json:"focused_separator"` 16 | 17 | FocusedWorkspaceText string `json:"focused_workspace_text"` 18 | FocusedWorkspaceBackground string `json:"focused_workspace_bg"` 19 | FocusedWorkspaceBorder string `json:"focused_workspace_border"` 20 | 21 | ActiveWorkspaceText string `json:"active_workspace_text"` 22 | ActiveWorkspaceBackground string `json:"active_workspace_bg"` 23 | ActiveWorkspaceBorder string `json:"active_workspace_border"` 24 | 25 | InactiveWorkspaceText string `json:"inactive_workspace_text"` 26 | InactiveWorkspaceBackground string `json:"inactive_workspace_bg"` 27 | InactiveWorkspaceBorder string `json:"inactive_workspace_border"` 28 | 29 | UrgentWorkspaceText string `json:"urgent_workspace_text"` 30 | UrgentWorkspaceBackground string `json:"urgent_workspace_bg"` 31 | UrgentWorkspaceBorder string `json:"urgent_workspace_border"` 32 | 33 | BindingModeText string `json:"binding_mode_text"` 34 | BindingModeBackground string `json:"binding_mode_bg"` 35 | BindingModeBorder string `json:"binding_mode_border"` 36 | } 37 | 38 | // BarConfig describes a serialized bar configuration block. 39 | // 40 | // See https://i3wm.org/docs/ipc.html#_bar_config_reply for more details. 41 | type BarConfig struct { 42 | ID string `json:"id"` 43 | Mode string `json:"mode"` 44 | Position string `json:"position"` 45 | StatusCommand string `json:"status_command"` 46 | Font string `json:"font"` 47 | WorkspaceButtons bool `json:"workspace_buttons"` 48 | BindingModeIndicator bool `json:"binding_mode_indicator"` 49 | Verbose bool `json:"verbose"` 50 | Colors BarConfigColors `json:"colors"` 51 | } 52 | 53 | // GetBarIDs returns an array of configured bar IDs. 54 | // 55 | // GetBarIDs is supported in i3 ≥ v4.1 (2011-11-11). 56 | func GetBarIDs() ([]string, error) { 57 | reply, err := roundTrip(messageTypeGetBarConfig, nil) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | var ids []string 63 | err = json.Unmarshal(reply.Payload, &ids) 64 | return ids, err 65 | } 66 | 67 | // GetBarConfig returns the configuration for the bar with the specified barID. 68 | // 69 | // Obtain the barID from GetBarIDs. 70 | // 71 | // GetBarConfig is supported in i3 ≥ v4.1 (2011-11-11). 72 | func GetBarConfig(barID string) (BarConfig, error) { 73 | reply, err := roundTrip(messageTypeGetBarConfig, []byte(barID)) 74 | if err != nil { 75 | return BarConfig{}, err 76 | } 77 | 78 | cfg := BarConfig{ 79 | Colors: BarConfigColors{ 80 | Background: "#000000", 81 | Statusline: "#ffffff", 82 | Separator: "#666666", 83 | 84 | FocusedBackground: "#000000", 85 | FocusedStatusline: "#ffffff", 86 | FocusedSeparator: "#666666", 87 | 88 | FocusedWorkspaceText: "#4c7899", 89 | FocusedWorkspaceBackground: "#285577", 90 | FocusedWorkspaceBorder: "#ffffff", 91 | 92 | ActiveWorkspaceText: "#333333", 93 | ActiveWorkspaceBackground: "#5f676a", 94 | ActiveWorkspaceBorder: "#ffffff", 95 | 96 | InactiveWorkspaceText: "#333333", 97 | InactiveWorkspaceBackground: "#222222", 98 | InactiveWorkspaceBorder: "#888888", 99 | 100 | UrgentWorkspaceText: "#2f343a", 101 | UrgentWorkspaceBackground: "#900000", 102 | UrgentWorkspaceBorder: "#ffffff", 103 | 104 | BindingModeText: "#2f343a", 105 | BindingModeBackground: "#900000", 106 | BindingModeBorder: "#ffffff", 107 | }, 108 | } 109 | err = json.Unmarshal(reply.Payload, &cfg) 110 | return cfg, err 111 | } 112 | -------------------------------------------------------------------------------- /bindingmodes.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // GetBindingModes returns the names of all currently configured binding modes. 6 | // 7 | // GetBindingModes is supported in i3 ≥ v4.13 (2016-11-08). 8 | func GetBindingModes() ([]string, error) { 9 | reply, err := roundTrip(messageTypeGetBindingModes, nil) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | var bm []string 15 | err = json.Unmarshal(reply.Payload, &bm) 16 | return bm, err 17 | } 18 | 19 | // BindingState indicates which binding mode is currently active. 20 | type BindingState struct { 21 | Name string `json:"name"` 22 | } 23 | 24 | // GetBindingState returns the currently active binding mode. 25 | // 26 | // GetBindingState is supported in i3 ≥ 4.19 (2020-11-15). 27 | func GetBindingState() (BindingState, error) { 28 | reply, err := roundTrip(messageTypeGetBindingState, nil) 29 | if err != nil { 30 | return BindingState{}, err 31 | } 32 | 33 | var bm BindingState 34 | err = json.Unmarshal(reply.Payload, &bm) 35 | return bm, err 36 | } 37 | -------------------------------------------------------------------------------- /byteorder.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "encoding/binary" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | // detectByteOrder sends messages to i3 to determine the byte order it uses. 10 | // For details on this technique, see: 11 | // https://build.i3wm.org/docs/ipc.html#_appendix_a_detecting_byte_order_in_memory_safe_languages 12 | func detectByteOrder(conn io.ReadWriter) (binary.ByteOrder, error) { 13 | const ( 14 | // targetLen is 0x00 01 01 00 in both, big and little endian 15 | targetLen = 65536 + 256 16 | 17 | // SUBSCRIBE was introduced in 3.e (2010-03-30) 18 | prefixSubscribe = "[]" 19 | 20 | // RUN_COMMAND was always present 21 | prefixCmd = "nop byte-order detection. padding: " 22 | ) 23 | 24 | // 2. Send a big endian encoded message of type SUBSCRIBE: 25 | payload := []byte(prefixSubscribe + strings.Repeat(" ", targetLen-len(prefixSubscribe))) 26 | if err := binary.Write(conn, binary.BigEndian, &header{magic, uint32(len(payload)), messageTypeSubscribe}); err != nil { 27 | return nil, err 28 | } 29 | if _, err := conn.Write(payload); err != nil { 30 | return nil, err 31 | } 32 | 33 | // 3. Send a byte order independent RUN_COMMAND message: 34 | payload = []byte(prefixCmd + strings.Repeat("a", targetLen-len(prefixCmd))) 35 | if err := binary.Write(conn, binary.BigEndian, &header{magic, uint32(len(payload)), messageTypeRunCommand}); err != nil { 36 | return nil, err 37 | } 38 | if _, err := conn.Write(payload); err != nil { 39 | return nil, err 40 | } 41 | 42 | // 4. Receive a message header, decode the message type as big endian: 43 | var header [14]byte 44 | if _, err := io.ReadFull(conn, header[:]); err != nil { 45 | return nil, err 46 | } 47 | if messageType(binary.BigEndian.Uint32(header[10:14])) == messageReplyTypeCommand { 48 | order := binary.LittleEndian // our big endian message was not answered 49 | // Read remaining payload 50 | _, err := io.ReadFull(conn, make([]byte, order.Uint32(header[6:10]))) 51 | return order, err 52 | } 53 | order := binary.BigEndian // our big endian message was answered 54 | // Read remaining payload 55 | if _, err := io.ReadFull(conn, make([]byte, order.Uint32(header[6:10]))); err != nil { 56 | return order, err 57 | } 58 | 59 | // Slurp the pending RUN_COMMAND reply. 60 | sock := &socket{conn: conn, order: order} 61 | _, err := sock.recvMsg() 62 | return binary.BigEndian, err 63 | } 64 | -------------------------------------------------------------------------------- /byteorder_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "testing" 14 | 15 | "golang.org/x/sync/errgroup" 16 | ) 17 | 18 | func msgBytes(order binary.ByteOrder, t messageType, payload string) []byte { 19 | var buf bytes.Buffer 20 | if err := binary.Write(&buf, order, &header{magic, uint32(len(payload)), t}); err != nil { 21 | panic(err) 22 | } 23 | _, err := buf.WriteString(payload) 24 | if err != nil { 25 | panic(err) 26 | } 27 | return buf.Bytes() 28 | } 29 | 30 | func TestDetectByteOrder(t *testing.T) { 31 | t.Parallel() 32 | 33 | for _, i3order := range []binary.ByteOrder{binary.BigEndian, binary.LittleEndian} { 34 | i3order := i3order // copy 35 | t.Run(fmt.Sprintf("%T", i3order), func(t *testing.T) { 36 | t.Parallel() 37 | 38 | var ( 39 | subscribeRequest = msgBytes(i3order, messageTypeSubscribe, "[]"+strings.Repeat(" ", 65536+256-2)) 40 | subscribeReply = msgBytes(i3order, messageReplyTypeSubscribe, `{"success": true}`) 41 | 42 | nopPrefix = "nop byte-order detection. padding: " 43 | runCommandRequest = msgBytes(i3order, messageTypeRunCommand, nopPrefix+strings.Repeat("a", 65536+256-len(nopPrefix))) 44 | runCommandReply = msgBytes(i3order, messageReplyTypeCommand, `[{"success": true}]`) 45 | 46 | protocol = map[string][]byte{ 47 | string(subscribeRequest): subscribeReply, 48 | string(runCommandRequest): runCommandReply, 49 | } 50 | ) 51 | 52 | // Abstract socket addresses are a linux-only feature, so we must 53 | // use file system paths for listening/dialing: 54 | dir, err := ioutil.TempDir("", "i3test") 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | defer os.RemoveAll(dir) 59 | path := filepath.Join(dir, fmt.Sprintf("i3test-%T.sock", i3order)) 60 | i3addr, err := net.ResolveUnixAddr("unix", path) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | i3ln, err := net.ListenUnix("unix", i3addr) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | var ( 70 | eg errgroup.Group 71 | order binary.ByteOrder 72 | orderErr error 73 | ) 74 | eg.Go(func() error { 75 | addr, err := net.ResolveUnixAddr("unix", path) 76 | if err != nil { 77 | return err 78 | } 79 | conn, err := net.DialUnix("unix", nil, addr) 80 | if err != nil { 81 | return err 82 | } 83 | order, orderErr = detectByteOrder(conn) 84 | conn.Close() 85 | i3ln.Close() // unblock Accept and return an error 86 | return orderErr 87 | }) 88 | eg.Go(func() error { 89 | for { 90 | conn, err := i3ln.Accept() 91 | if err != nil { 92 | return err 93 | } 94 | eg.Go(func() error { 95 | defer conn.Close() 96 | for { 97 | var request [14 + 65536 + 256]byte 98 | if _, err := io.ReadFull(conn, request[:]); err != nil { 99 | return err 100 | } 101 | if reply := protocol[string(request[:])]; reply != nil { 102 | if _, err := io.Copy(conn, bytes.NewReader(reply)); err != nil { 103 | return err 104 | } 105 | continue 106 | } 107 | // silently drop unexpected messages like i3 108 | } 109 | }) 110 | } 111 | }) 112 | if err := eg.Wait(); err != nil { 113 | // If order != nil && orderErr == nil, the test succeeded and any 114 | // returned errors are from teardown. 115 | if order == nil || orderErr != nil { 116 | t.Fatal(err) 117 | } 118 | } 119 | if got, want := order, i3order; got != want { 120 | t.Fatalf("unexpected byte order: got %v, want %v", got, want) 121 | } 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /close_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // TestCloseSubprocess runs in a process which has been started with 12 | // DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. 13 | func TestCloseSubprocess(t *testing.T) { 14 | if os.Getenv("GO_WANT_XVFB") != "1" { 15 | t.Skip("parent process") 16 | } 17 | 18 | ws := Subscribe(WorkspaceEventType) 19 | received := make(chan Event) 20 | go func() { 21 | defer close(received) 22 | for ws.Next() { 23 | } 24 | received <- nil 25 | }() 26 | ws.Close() 27 | select { 28 | case <-received: 29 | case <-time.After(5 * time.Second): 30 | t.Fatalf("timeout waiting for a Close()") 31 | } 32 | } 33 | 34 | func TestClose(t *testing.T) { 35 | t.Parallel() 36 | 37 | ctx, canc := context.WithCancel(context.Background()) 38 | defer canc() 39 | 40 | _, DISPLAY, err := launchXvfb(ctx) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | cleanup, err := launchI3(ctx, DISPLAY, "") 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | defer cleanup() 50 | 51 | cmd := exec.Command(os.Args[0], "-test.run=TestCloseSubprocess", "-test.v") 52 | cmd.Env = []string{ 53 | "GO_WANT_XVFB=1", 54 | "DISPLAY=" + DISPLAY, 55 | "PATH=" + os.Getenv("PATH"), 56 | } 57 | cmd.Stdout = os.Stdout 58 | cmd.Stderr = os.Stderr 59 | if err := cmd.Run(); err != nil { 60 | t.Fatal(err.Error()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // CommandResult always contains Success, and command-specific fields where 9 | // appropriate. 10 | type CommandResult struct { 11 | // Success indicates whether the command was run without any errors. 12 | Success bool `json:"success"` 13 | 14 | // Error is a human-readable error message, non-empty for unsuccessful 15 | // commands. 16 | Error string `json:"error"` 17 | } 18 | 19 | // IsUnsuccessful is a convenience function which can be used to check if an 20 | // error is a CommandUnsuccessfulError. 21 | func IsUnsuccessful(err error) bool { 22 | _, ok := err.(*CommandUnsuccessfulError) 23 | return ok 24 | } 25 | 26 | // CommandUnsuccessfulError is returned by RunCommand for unsuccessful 27 | // commands. This type is exported so that you can ignore this error if you 28 | // expect your command(s) to fail. 29 | type CommandUnsuccessfulError struct { 30 | command string 31 | cr CommandResult 32 | } 33 | 34 | // Error implements error. 35 | func (e *CommandUnsuccessfulError) Error() string { 36 | return fmt.Sprintf("command %q unsuccessful: %v", e.command, e.cr.Error) 37 | } 38 | 39 | // RunCommand makes i3 run the specified command. 40 | // 41 | // Error is non-nil if any CommandResult.Success is not true. See IsUnsuccessful 42 | // if you send commands which are expected to fail. 43 | // 44 | // RunCommand is supported in i3 ≥ v4.0 (2011-07-31). 45 | func RunCommand(command string) ([]CommandResult, error) { 46 | reply, err := roundTrip(messageTypeRunCommand, []byte(command)) 47 | if err != nil { 48 | return []CommandResult{}, err 49 | } 50 | 51 | var crs []CommandResult 52 | err = json.Unmarshal(reply.Payload, &crs) 53 | if err == nil { 54 | for _, cr := range crs { 55 | if !cr.Success { 56 | return crs, &CommandUnsuccessfulError{ 57 | command: command, 58 | cr: cr, 59 | } 60 | } 61 | } 62 | } 63 | return crs, err 64 | } 65 | -------------------------------------------------------------------------------- /common_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "syscall" 15 | ) 16 | 17 | func displayLikelyAvailable(display int) bool { 18 | // The path to this lock is hard-coded to /tmp in the Xorg source code, at 19 | // least in xorg-server-1.19.3. If the path ever changes, that’s no big 20 | // deal. We’ll fall through to starting Xvfb and having Xvfb fail, which is 21 | // only a performance hit, no failure. 22 | b, err := ioutil.ReadFile(fmt.Sprintf("/tmp/.X%d-lock", display)) 23 | if err != nil { 24 | if os.IsNotExist(err) { 25 | return true 26 | } 27 | // Maybe a starting process is just replacing the file? The display 28 | // is likely not available. 29 | return false 30 | } 31 | 32 | pid, err := strconv.Atoi(strings.TrimSpace(string(b))) 33 | if err != nil { 34 | // No pid inside the lock file, so Xvfb will remove the file. 35 | return true 36 | } 37 | 38 | return !pidValid(pid) 39 | } 40 | 41 | func launchI3(ctx context.Context, DISPLAY, I3SOCK string) (cleanup func(), _ error) { 42 | abs, err := filepath.Abs("testdata/i3.config") 43 | if err != nil { 44 | return nil, err 45 | } 46 | wm := exec.CommandContext(ctx, "i3", "-c", abs, "-d", "all", fmt.Sprintf("--shmlog-size=%d", 5*1024*1024)) 47 | wm.Env = []string{ 48 | "DISPLAY=" + DISPLAY, 49 | "PATH=" + os.Getenv("PATH"), 50 | } 51 | if I3SOCK != "" { 52 | wm.Env = append(wm.Env, "I3SOCK="+I3SOCK) 53 | } 54 | wm.Stderr = os.Stderr 55 | if err := wm.Start(); err != nil { 56 | return nil, err 57 | } 58 | return func() { wm.Process.Kill() }, nil 59 | } 60 | 61 | var signalMu sync.Mutex 62 | 63 | func launchXvfb(ctx context.Context) (xvfb *exec.Cmd, DISPLAY string, _ error) { 64 | // Only one goroutine can wait for Xvfb to start at any point in time, as 65 | // signal handlers are global (per-process, not per-goroutine). 66 | signalMu.Lock() 67 | defer signalMu.Unlock() 68 | 69 | var lastErr error 70 | display := 0 // :0 is usually an active session 71 | for attempt := 0; attempt < 100; attempt++ { 72 | display++ 73 | if !displayLikelyAvailable(display) { 74 | continue 75 | } 76 | // display likely available, try to start Xvfb 77 | DISPLAY := fmt.Sprintf(":%d", display) 78 | // Indicate we implement Xvfb’s readiness notification mechanism. 79 | // 80 | // We ignore SIGUSR1 in a shell wrapper process as there is currently no 81 | // way to ignore signals in a child process, other than ignoring it in 82 | // the parent (using signal.Ignore), which is prone to race conditions 83 | // for this particular use-case: 84 | // https://github.com/golang/go/issues/20479#issuecomment-303791827 85 | ch := make(chan os.Signal, 1) 86 | signal.Notify(ch, syscall.SIGUSR1) 87 | xvfb := exec.CommandContext(ctx, 88 | "sh", 89 | "-c", 90 | "trap '' USR1 && exec Xvfb "+DISPLAY+" -screen 0 1280x800x24") 91 | if attempt == 99 { // last attempt 92 | xvfb.Stderr = os.Stderr 93 | } 94 | if lastErr = xvfb.Start(); lastErr != nil { 95 | continue 96 | } 97 | 98 | // The buffer of 1 allows the Wait() goroutine to return. 99 | status := make(chan error, 1) 100 | go func() { 101 | defer signal.Stop(ch) 102 | for range ch { 103 | status <- nil // success 104 | return 105 | } 106 | }() 107 | go func() { 108 | defer func() { 109 | signal.Stop(ch) 110 | close(ch) // avoid leaking the other goroutine 111 | }() 112 | ps, err := xvfb.Process.Wait() 113 | if err != nil { 114 | status <- err 115 | return 116 | } 117 | if ps.Exited() { 118 | status <- fmt.Errorf("Xvfb exited: %v", ps) 119 | return 120 | } 121 | status <- fmt.Errorf("BUG: Wait returned, but !ps.Exited()") 122 | }() 123 | if lastErr = <-status; lastErr == nil { 124 | return xvfb, DISPLAY, nil // Xvfb ready 125 | } 126 | } 127 | return nil, "", lastErr 128 | } 129 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // IncludedConfig represents a single file that i3 has read, either because the 6 | // file is the main config file, or because the file is included. 7 | // 8 | // IncludedConfig is supported in i3 ≥ v4.20 (2021-10-19). 9 | type IncludedConfig struct { 10 | Path string `json:"path"` 11 | RawContents string `json:"raw_contents"` 12 | VariableReplacedContents string `json:"variable_replaced_contents"` 13 | } 14 | 15 | // Config contains details about the configuration file. 16 | // 17 | // See https://i3wm.org/docs/ipc.html#_config_reply for more details. 18 | type Config struct { 19 | Config string `json:"config"` 20 | 21 | // The IncludedConfigs field was added in i3 v4.20 (2021-10-19). 22 | IncludedConfigs []IncludedConfig `json:"included_configs"` 23 | } 24 | 25 | // GetConfig returns i3’s in-memory copy of the configuration file contents. 26 | // 27 | // GetConfig is supported in i3 ≥ v4.14 (2017-09-04). 28 | func GetConfig() (Config, error) { 29 | reply, err := roundTrip(messageTypeGetConfig, nil) 30 | if err != nil { 31 | return Config{}, err 32 | } 33 | 34 | var cfg Config 35 | err = json.Unmarshal(reply.Payload, &cfg) 36 | return cfg, err 37 | } 38 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package i3 provides a convenient interface to the i3 window manager. 2 | // 3 | // Its function and type names don’t stutter, and all functions and methods are 4 | // safe for concurrent use (except where otherwise noted). The package does not 5 | // import "unsafe" and hence should be widely applicable. 6 | // 7 | // UNIX socket connections to i3 are transparently managed by the package. Upon 8 | // any read/write errors on a UNIX socket, the package transparently retries for 9 | // up to 10 seconds, but only as long as the i3 process keeps running. 10 | // 11 | // The package is published in versioned releases, where the major and minor 12 | // version are identical to the i3 release the package is compatible with 13 | // (e.g. 4.14 implements the entire documented IPC interface of i3 4.14). 14 | // 15 | // This package will only ever receive additions, so versioning should only be 16 | // relevant to you if you are interested in a recently-introduced IPC feature. 17 | // 18 | // Message type functions and event types are annotated with the i3 version in 19 | // which they were introduced. Under the covers, they use AtLeast, so they 20 | // return a helpful error message at runtime if the running i3 version is too 21 | // old. 22 | package i3 23 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package i3_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "go.i3wm.org/i3/v4" 9 | ) 10 | 11 | func ExampleIsUnsuccessful() { 12 | cr, err := i3.RunCommand("norp") 13 | // “norp” is not implemented, so this command is expected to fail. 14 | if err != nil && !i3.IsUnsuccessful(err) { 15 | log.Fatal(err) 16 | } 17 | log.Printf("error for norp: %v", cr[0].Error) 18 | } 19 | 20 | func ExampleSubscribe() { 21 | recv := i3.Subscribe(i3.WindowEventType) 22 | for recv.Next() { 23 | ev := recv.Event().(*i3.WindowEvent) 24 | log.Printf("change: %s", ev.Change) 25 | } 26 | log.Fatal(recv.Close()) 27 | } 28 | 29 | func ExampleGetTree() { 30 | // Focus or start Google Chrome on the focused workspace. 31 | 32 | tree, err := i3.GetTree() 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | 37 | ws := tree.Root.FindFocused(func(n *i3.Node) bool { 38 | return n.Type == i3.WorkspaceNode 39 | }) 40 | if ws == nil { 41 | log.Fatalf("could not locate workspace") 42 | } 43 | 44 | chrome := ws.FindChild(func(n *i3.Node) bool { 45 | return strings.HasSuffix(n.Name, "- Google Chrome") 46 | }) 47 | if chrome != nil { 48 | _, err = i3.RunCommand(fmt.Sprintf(`[con_id="%d"] focus`, chrome.ID)) 49 | } else { 50 | _, err = i3.RunCommand(`exec google-chrome`) 51 | } 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /getpid.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/BurntSushi/xgbutil" 7 | "github.com/BurntSushi/xgbutil/xprop" 8 | ) 9 | 10 | func i3Pid() int { 11 | xu, err := xgbutil.NewConn() 12 | if err != nil { 13 | return -1 // X session terminated 14 | } 15 | defer xu.Conn().Close() 16 | reply, err := xprop.GetProperty(xu, xu.RootWin(), "I3_PID") 17 | if err != nil { 18 | return -1 // I3_PID no longer present (X session replaced?) 19 | } 20 | num, err := xprop.PropValNum(reply, err) 21 | if err != nil { 22 | return -1 23 | } 24 | return int(num) 25 | } 26 | 27 | var lastPid struct { 28 | sync.Mutex 29 | pid int 30 | } 31 | 32 | // IsRunningHook provides a method to override the method which detects if i3 is running or not 33 | var IsRunningHook = func() bool { 34 | lastPid.Lock() 35 | defer lastPid.Unlock() 36 | if !wasRestart || lastPid.pid == 0 { 37 | lastPid.pid = i3Pid() 38 | } 39 | return pidValid(lastPid.pid) 40 | } 41 | 42 | func i3Running() bool { 43 | return IsRunningHook() 44 | } 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.i3wm.org/i3/v4 2 | 3 | require ( 4 | github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc 5 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 6 | github.com/google/go-cmp v0.7.0 7 | golang.org/x/sync v0.12.0 8 | ) 9 | 10 | go 1.23.0 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= 2 | github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 3 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= 4 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= 5 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 7 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 8 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 9 | -------------------------------------------------------------------------------- /golden_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | ) 14 | 15 | // TestGoldensSubprocess runs in a process which has been started with 16 | // DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. 17 | func TestGoldensSubprocess(t *testing.T) { 18 | if os.Getenv("GO_WANT_XVFB") != "1" { 19 | t.Skip("parent process") 20 | } 21 | 22 | if _, err := RunCommand("open; mark foo"); err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | t.Run("GetVersion", func(t *testing.T) { 27 | t.Parallel() 28 | got, err := GetVersion() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | got.HumanReadable = "" // too brittle to compare 33 | got.Patch = 0 // the IPC interface does not change across patch releases 34 | abs, err := filepath.Abs("testdata/i3.config") 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | want := Version{ 39 | Major: 4, 40 | Minor: 24, 41 | Patch: 0, 42 | LoadedConfigFileName: abs, 43 | } 44 | if diff := cmp.Diff(want, got); diff != "" { 45 | t.Fatalf("unexpected GetVersion reply: (-want +got)\n%s", diff) 46 | } 47 | }) 48 | 49 | t.Run("AtLeast", func(t *testing.T) { 50 | t.Parallel() 51 | if err := AtLeast(4, 14); err != nil { 52 | t.Errorf("AtLeast(4, 14) unexpectedly returned an error: %v", err) 53 | } 54 | if err := AtLeast(4, 0); err != nil { 55 | t.Errorf("AtLeast(4, 0) unexpectedly returned an error: %v", err) 56 | } 57 | if err := AtLeast(4, 999); err == nil { 58 | t.Errorf("AtLeast(4, 999) unexpectedly did not return an error") 59 | } 60 | }) 61 | 62 | t.Run("GetBarIDs", func(t *testing.T) { 63 | t.Parallel() 64 | got, err := GetBarIDs() 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | want := []string{"bar-0"} 69 | if diff := cmp.Diff(want, got); diff != "" { 70 | t.Fatalf("unexpected GetBarIDs reply: (-want +got)\n%s", diff) 71 | } 72 | }) 73 | 74 | t.Run("GetBarConfig", func(t *testing.T) { 75 | t.Parallel() 76 | got, err := GetBarConfig("bar-0") 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | want := BarConfig{ 81 | ID: "bar-0", 82 | Mode: "dock", 83 | Position: "bottom", 84 | StatusCommand: "i3status", 85 | Font: "fixed", 86 | WorkspaceButtons: true, 87 | BindingModeIndicator: true, 88 | Colors: BarConfigColors{ 89 | Background: "#000000", 90 | Statusline: "#ffffff", 91 | Separator: "#666666", 92 | FocusedBackground: "#000000", 93 | FocusedStatusline: "#ffffff", 94 | FocusedSeparator: "#666666", 95 | FocusedWorkspaceText: "#4c7899", 96 | FocusedWorkspaceBackground: "#285577", 97 | FocusedWorkspaceBorder: "#ffffff", 98 | ActiveWorkspaceText: "#333333", 99 | ActiveWorkspaceBackground: "#5f676a", 100 | ActiveWorkspaceBorder: "#ffffff", 101 | InactiveWorkspaceText: "#333333", 102 | InactiveWorkspaceBackground: "#222222", 103 | InactiveWorkspaceBorder: "#888888", 104 | UrgentWorkspaceText: "#2f343a", 105 | UrgentWorkspaceBackground: "#900000", 106 | UrgentWorkspaceBorder: "#ffffff", 107 | BindingModeText: "#2f343a", 108 | BindingModeBackground: "#900000", 109 | BindingModeBorder: "#ffffff", 110 | }, 111 | } 112 | if diff := cmp.Diff(want, got); diff != "" { 113 | t.Fatalf("unexpected GetBarConfig reply: (-want +got)\n%s", diff) 114 | } 115 | }) 116 | 117 | t.Run("GetBindingModes", func(t *testing.T) { 118 | t.Parallel() 119 | got, err := GetBindingModes() 120 | if err != nil { 121 | t.Fatal(err) 122 | } 123 | want := []string{"default"} 124 | if diff := cmp.Diff(want, got); diff != "" { 125 | t.Fatalf("unexpected GetBindingModes reply: (-want +got)\n%s", diff) 126 | } 127 | }) 128 | 129 | t.Run("GetMarks", func(t *testing.T) { 130 | t.Parallel() 131 | got, err := GetMarks() 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | want := []string{"foo"} 136 | if diff := cmp.Diff(want, got); diff != "" { 137 | t.Fatalf("unexpected GetMarks reply: (-want +got)\n%s", diff) 138 | } 139 | }) 140 | 141 | t.Run("GetOutputs", func(t *testing.T) { 142 | t.Parallel() 143 | got, err := GetOutputs() 144 | if err != nil { 145 | t.Fatal(err) 146 | } 147 | want := []Output{ 148 | { 149 | Name: "xroot-0", 150 | Rect: Rect{Width: 1280, Height: 800}, 151 | }, 152 | { 153 | Name: "screen", 154 | Active: true, 155 | CurrentWorkspace: "1", 156 | Rect: Rect{Width: 1280, Height: 800}, 157 | }, 158 | } 159 | if diff := cmp.Diff(want, got); diff != "" { 160 | t.Fatalf("unexpected GetOutputs reply: (-want +got)\n%s", diff) 161 | } 162 | }) 163 | 164 | t.Run("GetWorkspaces", func(t *testing.T) { 165 | t.Parallel() 166 | got, err := GetWorkspaces() 167 | if err != nil { 168 | t.Fatal(err) 169 | } 170 | want := []Workspace{ 171 | { 172 | Num: 1, 173 | Name: "1", 174 | Visible: true, 175 | Focused: true, 176 | Rect: Rect{Width: 1280, Height: 800}, 177 | Output: "screen", 178 | }, 179 | } 180 | cmpopts := []cmp.Option{ 181 | cmp.FilterPath( 182 | func(p cmp.Path) bool { 183 | return p.Last().String() == ".ID" 184 | }, 185 | cmp.Ignore()), 186 | } 187 | if diff := cmp.Diff(want, got, cmpopts...); diff != "" { 188 | t.Fatalf("unexpected GetWorkspaces reply: (-want +got)\n%s", diff) 189 | } 190 | }) 191 | 192 | t.Run("RunCommand", func(t *testing.T) { 193 | t.Parallel() 194 | got, err := RunCommand("norp") 195 | if err != nil && !IsUnsuccessful(err) { 196 | t.Fatal(err) 197 | } 198 | if !IsUnsuccessful(err) { 199 | t.Fatalf("command unexpectedly succeeded") 200 | } 201 | if len(got) != 1 { 202 | t.Fatalf("expected precisely one reply, got %+v", got) 203 | } 204 | if got, want := got[0].Success, false; got != want { 205 | t.Errorf("CommandResult.Success: got %v, want %v", got, want) 206 | } 207 | if want := "Expected one of these tokens:"; !strings.HasPrefix(got[0].Error, want) { 208 | t.Errorf("CommandResult.Error: unexpected error: got %q, want prefix %q", got[0].Error, want) 209 | } 210 | }) 211 | 212 | t.Run("GetConfig", func(t *testing.T) { 213 | t.Parallel() 214 | got, err := GetConfig() 215 | if err != nil { 216 | t.Fatal(err) 217 | } 218 | configBytes, err := ioutil.ReadFile("testdata/i3.config") 219 | if err != nil { 220 | t.Fatal(err) 221 | } 222 | configPath, err := filepath.Abs("testdata/i3.config") 223 | if err != nil { 224 | t.Fatal(err) 225 | } 226 | want := Config{ 227 | Config: string(configBytes), 228 | IncludedConfigs: []IncludedConfig{ 229 | { 230 | Path: configPath, 231 | RawContents: string(configBytes), 232 | // Our testdata configuration contains no variables, 233 | // so this field contains configBytes as-is. 234 | VariableReplacedContents: string(configBytes), 235 | }, 236 | }, 237 | } 238 | if diff := cmp.Diff(want, got); diff != "" { 239 | t.Fatalf("unexpected GetConfig reply: (-want +got)\n%s", diff) 240 | } 241 | }) 242 | 243 | t.Run("GetTree", func(t *testing.T) { 244 | t.Parallel() 245 | got, err := GetTree() 246 | if err != nil { 247 | t.Fatal(err) 248 | } 249 | 250 | // Basic sanity checks: 251 | if got.Root == nil { 252 | t.Fatalf("tree.Root unexpectedly is nil") 253 | } 254 | 255 | if got, want := got.Root.Name, "root"; got != want { 256 | t.Fatalf("unexpected tree root name: got %q, want %q", got, want) 257 | } 258 | 259 | // Exercise FindFocused to locate at least one workspace. 260 | if node := got.Root.FindFocused(func(n *Node) bool { return n.Type == WorkspaceNode }); node == nil { 261 | t.Fatalf("unexpectedly could not find any workspace node in GetTree reply") 262 | } 263 | 264 | // Exercise FindChild to locate at least one workspace. 265 | if node := got.Root.FindChild(func(n *Node) bool { return n.Type == WorkspaceNode }); node == nil { 266 | t.Fatalf("unexpectedly could not find any workspace node in GetTree reply") 267 | } 268 | }) 269 | } 270 | 271 | func TestGoldens(t *testing.T) { 272 | t.Parallel() 273 | 274 | ctx, canc := context.WithCancel(context.Background()) 275 | defer canc() 276 | 277 | _, DISPLAY, err := launchXvfb(ctx) 278 | if err != nil { 279 | t.Fatal(err) 280 | } 281 | 282 | cleanup, err := launchI3(ctx, DISPLAY, "") 283 | if err != nil { 284 | t.Fatal(err) 285 | } 286 | defer cleanup() 287 | 288 | cmd := exec.Command(os.Args[0], "-test.run=TestGoldensSubprocess", "-test.v") 289 | cmd.Env = []string{ 290 | "GO_WANT_XVFB=1", 291 | "DISPLAY=" + DISPLAY, 292 | "PATH=" + os.Getenv("PATH"), 293 | } 294 | cmd.Stdout = os.Stdout 295 | cmd.Stderr = os.Stderr 296 | if err := cmd.Run(); err != nil { 297 | t.Fatal(err.Error()) 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /marks.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // GetMarks returns the names of all currently set marks. 6 | // 7 | // GetMarks is supported in i3 ≥ v4.1 (2011-11-11). 8 | func GetMarks() ([]string, error) { 9 | reply, err := roundTrip(messageTypeGetMarks, nil) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | var marks []string 15 | err = json.Unmarshal(reply.Payload, &marks) 16 | return marks, err 17 | } 18 | -------------------------------------------------------------------------------- /outputs.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // Output describes an i3 output. 6 | // 7 | // See https://i3wm.org/docs/ipc.html#_outputs_reply for more details. 8 | type Output struct { 9 | Name string `json:"name"` 10 | Active bool `json:"active"` 11 | Primary bool `json:"primary"` 12 | CurrentWorkspace string `json:"current_workspace"` 13 | Rect Rect `json:"rect"` 14 | } 15 | 16 | // GetOutputs returns i3’s current outputs. 17 | // 18 | // GetOutputs is supported in i3 ≥ v4.0 (2011-07-31). 19 | func GetOutputs() ([]Output, error) { 20 | reply, err := roundTrip(messageTypeGetOutputs, nil) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | var outputs []Output 26 | err = json.Unmarshal(reply.Payload, &outputs) 27 | return outputs, err 28 | } 29 | -------------------------------------------------------------------------------- /restart_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "testing" 11 | ) 12 | 13 | // TestRestartSubprocess runs in a process which has been started with 14 | // DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. 15 | func TestRestartSubprocess(t *testing.T) { 16 | if os.Getenv("GO_WANT_XVFB") != "1" { 17 | t.Skip("parent process") 18 | } 19 | 20 | // received is buffered so that we can blockingly read on tick. 21 | received := make(chan *ShutdownEvent, 1) 22 | tick := make(chan *TickEvent) 23 | fatal := make(chan bool) 24 | go func() { 25 | defer close(tick) 26 | defer close(received) 27 | defer close(fatal) 28 | recv := Subscribe(ShutdownEventType, TickEventType) 29 | defer recv.Close() 30 | log.Printf("reading events") 31 | for recv.Next() { 32 | log.Printf("received: %#v", recv.Event()) 33 | switch ev := recv.Event().(type) { 34 | case *ShutdownEvent: 35 | received <- ev 36 | case *TickEvent: 37 | tick <- ev 38 | } 39 | } 40 | log.Printf("done reading events") 41 | fatal <- true 42 | }() 43 | 44 | log.Printf("read initial tick") 45 | <-tick // Wait until the subscription is ready 46 | log.Printf("restart") 47 | if err := Restart(); err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | // Restarting i3 triggered a close of the connection, i.e. also a new 52 | // subscribe and initial tick event: 53 | log.Printf("read next initial tick") 54 | ev := <-tick 55 | if !ev.First { 56 | t.Fatalf("expected first tick after restart, got %#v instead", ev) 57 | } 58 | 59 | if _, err := SendTick(""); err != nil { 60 | t.Fatal(err) 61 | } 62 | log.Printf("read tick") 63 | <-tick // Wait until tick was received 64 | log.Printf("read received") 65 | <-received // Verify shutdown event was received 66 | log.Printf("getversion") 67 | if _, err := GetVersion(); err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | select { 72 | case _ = <-fatal: 73 | t.Fatal("Subscribe has been canceled by restart") 74 | default: 75 | 76 | } 77 | } 78 | 79 | func TestRestart(t *testing.T) { 80 | t.Parallel() 81 | 82 | ctx, canc := context.WithCancel(context.Background()) 83 | defer canc() 84 | 85 | _, DISPLAY, err := launchXvfb(ctx) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | dir, err := ioutil.TempDir("", "i3restart") 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | defer os.RemoveAll(dir) 95 | I3SOCK := filepath.Join(dir, "i3.sock") 96 | 97 | cleanup, err := launchI3(ctx, DISPLAY, I3SOCK) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | defer cleanup() 102 | 103 | cmd := exec.Command(os.Args[0], "-test.run=TestRestartSubprocess", "-test.v") 104 | cmd.Env = []string{ 105 | "GO_WANT_XVFB=1", 106 | "DISPLAY=" + DISPLAY, 107 | "PATH=" + os.Getenv("PATH"), 108 | } 109 | cmd.Stdout = os.Stdout 110 | cmd.Stderr = os.Stderr 111 | if err := cmd.Run(); err != nil { 112 | t.Fatal(err.Error()) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /socket.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | "math/rand" 8 | "net" 9 | "os/exec" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | // If your computer takes more than 10s to restart i3, it must be seriously 16 | // overloaded, in which case we are probably doing you a favor by erroring out. 17 | const reconnectTimeout = 10 * time.Second 18 | 19 | // remote is a singleton containing the socket path and auto-detected byte order 20 | // which i3 is using. It is lazily initialized by getIPCSocket. 21 | var remote struct { 22 | path string 23 | order binary.ByteOrder 24 | mu sync.Mutex 25 | } 26 | 27 | // SocketPathHook Provides a way to override the default socket path lookup mechanism. Overriding this is unsupported. 28 | var SocketPathHook = func() (string, error) { 29 | out, err := exec.Command("i3", "--get-socketpath").CombinedOutput() 30 | if err != nil { 31 | return "", fmt.Errorf("getting i3 socketpath: %v (output: %s)", err, out) 32 | } 33 | return string(out), nil 34 | } 35 | 36 | func getIPCSocket(updateSocketPath bool) (*socket, net.Conn, error) { 37 | remote.mu.Lock() 38 | defer remote.mu.Unlock() 39 | path := remote.path 40 | if (!wasRestart && updateSocketPath) || remote.path == "" { 41 | out, err := SocketPathHook() 42 | if err != nil { 43 | return nil, nil, err 44 | } 45 | path = strings.TrimSpace(string(out)) 46 | } 47 | conn, err := net.Dial("unix", path) 48 | if err != nil { 49 | return nil, nil, err 50 | } 51 | remote.path = path 52 | if remote.order == nil { 53 | remote.order, err = detectByteOrder(conn) 54 | if err != nil { 55 | conn.Close() 56 | return nil, nil, err 57 | } 58 | } 59 | 60 | return &socket{conn: conn, order: remote.order}, conn, err 61 | } 62 | 63 | type messageType uint32 64 | 65 | const ( 66 | messageTypeRunCommand messageType = iota 67 | messageTypeGetWorkspaces 68 | messageTypeSubscribe 69 | messageTypeGetOutputs 70 | messageTypeGetTree 71 | messageTypeGetMarks 72 | messageTypeGetBarConfig 73 | messageTypeGetVersion 74 | messageTypeGetBindingModes 75 | messageTypeGetConfig 76 | messageTypeSendTick 77 | messageTypeSync 78 | messageTypeGetBindingState 79 | ) 80 | 81 | var messageAtLeast = map[messageType]majorMinor{ 82 | messageTypeRunCommand: {4, 0}, 83 | messageTypeGetWorkspaces: {4, 0}, 84 | messageTypeSubscribe: {4, 0}, 85 | messageTypeGetOutputs: {4, 0}, 86 | messageTypeGetTree: {4, 0}, 87 | messageTypeGetMarks: {4, 1}, 88 | messageTypeGetBarConfig: {4, 1}, 89 | messageTypeGetVersion: {4, 3}, 90 | messageTypeGetBindingModes: {4, 13}, 91 | messageTypeGetConfig: {4, 14}, 92 | messageTypeSendTick: {4, 15}, 93 | messageTypeSync: {4, 16}, 94 | messageTypeGetBindingState: {4, 19}, 95 | } 96 | 97 | const ( 98 | messageReplyTypeCommand messageType = iota 99 | messageReplyTypeWorkspaces 100 | messageReplyTypeSubscribe 101 | ) 102 | 103 | var magic = [6]byte{'i', '3', '-', 'i', 'p', 'c'} 104 | 105 | type header struct { 106 | Magic [6]byte 107 | Length uint32 108 | Type messageType 109 | } 110 | 111 | type message struct { 112 | Type messageType 113 | Payload []byte 114 | } 115 | 116 | type socket struct { 117 | conn io.ReadWriter 118 | order binary.ByteOrder 119 | } 120 | 121 | func (s *socket) recvMsg() (message, error) { 122 | if s == nil { 123 | return message{}, fmt.Errorf("not connected") 124 | } 125 | var h header 126 | if err := binary.Read(s.conn, s.order, &h); err != nil { 127 | return message{}, err 128 | } 129 | msg := message{ 130 | Type: h.Type, 131 | Payload: make([]byte, h.Length), 132 | } 133 | _, err := io.ReadFull(s.conn, msg.Payload) 134 | return msg, err 135 | } 136 | 137 | func (s *socket) roundTrip(t messageType, payload []byte) (message, error) { 138 | if s == nil { 139 | return message{}, fmt.Errorf("not connected") 140 | } 141 | 142 | if err := binary.Write(s.conn, s.order, &header{magic, uint32(len(payload)), t}); err != nil { 143 | return message{}, err 144 | } 145 | if len(payload) > 0 { // skip empty Write()s for net.Pipe 146 | _, err := s.conn.Write(payload) 147 | if err != nil { 148 | return message{}, err 149 | } 150 | } 151 | return s.recvMsg() 152 | } 153 | 154 | // defaultSock is a singleton, lazily initialized by roundTrip. All 155 | // request/response messages are sent to i3 via this socket, whereas 156 | // subscriptions use their own connection. 157 | var defaultSock struct { 158 | sock *socket 159 | conn net.Conn 160 | mu sync.Mutex 161 | } 162 | 163 | // roundTrip sends a message to i3 and returns the received result in a 164 | // concurrency-safe fashion. 165 | func roundTrip(t messageType, payload []byte) (message, error) { 166 | // Error out early in case the message type is not yet supported by the 167 | // running i3 version. 168 | if t != messageTypeGetVersion { 169 | if err := AtLeast(messageAtLeast[t].major, messageAtLeast[t].minor); err != nil { 170 | return message{}, err 171 | } 172 | } 173 | 174 | defaultSock.mu.Lock() 175 | defer defaultSock.mu.Unlock() 176 | 177 | Outer: 178 | for { 179 | msg, err := defaultSock.sock.roundTrip(t, payload) 180 | if err == nil { 181 | return msg, nil // happy path: success 182 | } 183 | 184 | // reconnect 185 | start := time.Now() 186 | for time.Since(start) < reconnectTimeout && (defaultSock.sock == nil || i3Running()) { 187 | if defaultSock.sock != nil { 188 | defaultSock.conn.Close() 189 | } 190 | defaultSock.sock, defaultSock.conn, err = getIPCSocket(defaultSock.sock != nil) 191 | if err == nil { 192 | continue Outer 193 | } 194 | 195 | // Reconnect within [10, 20) ms to prevent CPU-starving i3. 196 | time.Sleep(time.Duration(10+rand.Int63n(10)) * time.Millisecond) 197 | } 198 | return msg, err 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /subscribe.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net" 9 | "time" 10 | ) 11 | 12 | // Event is an event received from i3. 13 | // 14 | // Type-assert or type-switch on Event to obtain a more specific type. 15 | type Event interface{} 16 | 17 | // WorkspaceEvent contains details about various workspace-related changes. 18 | // 19 | // See https://i3wm.org/docs/ipc.html#_workspace_event for more details. 20 | type WorkspaceEvent struct { 21 | Change string `json:"change"` 22 | Current Node `json:"current"` 23 | Old Node `json:"old"` 24 | } 25 | 26 | // OutputEvent contains details about various output-related changes. 27 | // 28 | // See https://i3wm.org/docs/ipc.html#_output_event for more details. 29 | type OutputEvent struct { 30 | Change string `json:"change"` 31 | } 32 | 33 | // ModeEvent contains details about various mode-related changes. 34 | // 35 | // See https://i3wm.org/docs/ipc.html#_mode_event for more details. 36 | type ModeEvent struct { 37 | Change string `json:"change"` 38 | PangoMarkup bool `json:"pango_markup"` 39 | } 40 | 41 | // WindowEvent contains details about various window-related changes. 42 | // 43 | // See https://i3wm.org/docs/ipc.html#_window_event for more details. 44 | type WindowEvent struct { 45 | Change string `json:"change"` 46 | Container Node `json:"container"` 47 | } 48 | 49 | // BarconfigUpdateEvent contains details about various bar config-related changes. 50 | // 51 | // See https://i3wm.org/docs/ipc.html#_barconfig_update_event for more details. 52 | type BarconfigUpdateEvent BarConfig 53 | 54 | // BindingEvent contains details about various binding-related changes. 55 | // 56 | // See https://i3wm.org/docs/ipc.html#_binding_event for more details. 57 | type BindingEvent struct { 58 | Change string `json:"change"` 59 | Binding struct { 60 | Command string `json:"command"` 61 | EventStateMask []string `json:"event_state_mask"` 62 | InputCode int64 `json:"input_code"` 63 | Symbol string `json:"symbol"` 64 | InputType string `json:"input_type"` 65 | } `json:"binding"` 66 | Mode string `json:"mode"` 67 | } 68 | 69 | // ShutdownEvent contains the reason for which the IPC connection is about to be 70 | // shut down. 71 | // 72 | // See https://i3wm.org/docs/ipc.html#_shutdown_event for more details. 73 | type ShutdownEvent struct { 74 | Change string `json:"change"` 75 | } 76 | 77 | // TickEvent contains the payload of the last tick command. 78 | // 79 | // See https://i3wm.org/docs/ipc.html#_tick_event for more details. 80 | type TickEvent struct { 81 | First bool `json:"first"` 82 | Payload string `json:"payload"` 83 | } 84 | 85 | type eventReplyType int 86 | 87 | const ( 88 | eventReplyTypeWorkspace eventReplyType = iota 89 | eventReplyTypeOutput 90 | eventReplyTypeMode 91 | eventReplyTypeWindow 92 | eventReplyTypeBarconfigUpdate 93 | eventReplyTypeBinding 94 | eventReplyTypeShutdown 95 | eventReplyTypeTick 96 | ) 97 | 98 | const ( 99 | eventFlagMask = uint32(0x80000000) 100 | eventTypeMask = ^eventFlagMask 101 | ) 102 | 103 | // EventReceiver is not safe for concurrent use. 104 | type EventReceiver struct { 105 | types []EventType // for re-subscribing on io.EOF 106 | sock *socket 107 | conn net.Conn 108 | ev Event 109 | err error 110 | reconnect bool 111 | closed bool 112 | } 113 | 114 | // Event returns the most recent event received from i3 by a call to Next. 115 | func (r *EventReceiver) Event() Event { 116 | return r.ev 117 | } 118 | 119 | func (r *EventReceiver) subscribe() error { 120 | var err error 121 | if r.conn != nil { 122 | r.conn.Close() 123 | } 124 | if wasRestart { 125 | r.reconnect = false 126 | } 127 | r.sock, r.conn, err = getIPCSocket(r.reconnect) 128 | r.reconnect = true 129 | if err != nil { 130 | return err 131 | } 132 | payload, err := json.Marshal(r.types) 133 | if err != nil { 134 | return err 135 | } 136 | b, err := r.sock.roundTrip(messageTypeSubscribe, payload) 137 | if err != nil { 138 | return err 139 | } 140 | var reply struct { 141 | Success bool `json:"success"` 142 | } 143 | if err := json.Unmarshal(b.Payload, &reply); err != nil { 144 | return err 145 | } 146 | if !reply.Success { 147 | return fmt.Errorf("could not subscribe, check the i3 log") 148 | } 149 | r.err = nil 150 | return nil 151 | } 152 | 153 | func (r *EventReceiver) next() (Event, error) { 154 | reply, err := r.sock.recvMsg() 155 | if err != nil { 156 | return nil, err 157 | } 158 | if (uint32(reply.Type) & eventFlagMask) == 0 { 159 | return nil, fmt.Errorf("unexpectedly did not receive an event") 160 | } 161 | t := uint32(reply.Type) & eventTypeMask 162 | switch eventReplyType(t) { 163 | case eventReplyTypeWorkspace: 164 | var e WorkspaceEvent 165 | return &e, json.Unmarshal(reply.Payload, &e) 166 | 167 | case eventReplyTypeOutput: 168 | var e OutputEvent 169 | return &e, json.Unmarshal(reply.Payload, &e) 170 | 171 | case eventReplyTypeMode: 172 | var e ModeEvent 173 | return &e, json.Unmarshal(reply.Payload, &e) 174 | 175 | case eventReplyTypeWindow: 176 | var e WindowEvent 177 | return &e, json.Unmarshal(reply.Payload, &e) 178 | 179 | case eventReplyTypeBarconfigUpdate: 180 | var e BarconfigUpdateEvent 181 | return &e, json.Unmarshal(reply.Payload, &e) 182 | 183 | case eventReplyTypeBinding: 184 | var e BindingEvent 185 | return &e, json.Unmarshal(reply.Payload, &e) 186 | 187 | case eventReplyTypeShutdown: 188 | var e ShutdownEvent 189 | return &e, json.Unmarshal(reply.Payload, &e) 190 | 191 | case eventReplyTypeTick: 192 | var e TickEvent 193 | return &e, json.Unmarshal(reply.Payload, &e) 194 | } 195 | return nil, fmt.Errorf("BUG: event reply type %d not implemented yet", t) 196 | } 197 | 198 | // Next advances the EventReceiver to the next event, which will then be 199 | // available through the Event method. It returns false when reaching an 200 | // error. After Next returns false, the Close method will return the first 201 | // error. 202 | // 203 | // Until you call Close, you must call Next in a loop for every EventReceiver 204 | // (usually in a separate goroutine), otherwise i3 will deadlock as soon as the 205 | // UNIX socket buffer is full of unprocessed events. 206 | func (r *EventReceiver) Next() bool { 207 | Outer: 208 | for r.err == nil { 209 | r.ev, r.err = r.next() 210 | if r.err == nil { 211 | return true // happy path 212 | } 213 | 214 | if r.closed { 215 | return false 216 | } 217 | 218 | // reconnect 219 | start := time.Now() 220 | for time.Since(start) < reconnectTimeout && (r.sock == nil || i3Running()) { 221 | if err := r.subscribe(); err == nil { 222 | continue Outer 223 | } else { 224 | r.err = err 225 | } 226 | 227 | // Reconnect within [10, 20) ms to prevent CPU-starving i3. 228 | time.Sleep(time.Duration(10+rand.Int63n(10)) * time.Millisecond) 229 | } 230 | } 231 | return r.err == nil 232 | } 233 | 234 | // Close closes the connection to i3. If you don’t ever call Close, you must 235 | // consume events via Next to prevent i3 from deadlocking. 236 | func (r *EventReceiver) Close() error { 237 | r.closed = true 238 | if r.conn != nil { 239 | if r.err == nil { 240 | r.err = r.conn.Close() 241 | } else { 242 | // Retain the original error. 243 | r.conn.Close() 244 | } 245 | r.conn = nil 246 | r.sock = nil 247 | } 248 | return r.err 249 | } 250 | 251 | // EventType indicates the specific kind of event to subscribe to. 252 | type EventType string 253 | 254 | // i3 currently implements the following event types: 255 | const ( 256 | WorkspaceEventType EventType = "workspace" // since 4.0 257 | OutputEventType EventType = "output" // since 4.0 258 | ModeEventType EventType = "mode" // since 4.4 259 | WindowEventType EventType = "window" // since 4.5 260 | BarconfigUpdateEventType EventType = "barconfig_update" // since 4.6 261 | BindingEventType EventType = "binding" // since 4.9 262 | ShutdownEventType EventType = "shutdown" // since 4.14 263 | TickEventType EventType = "tick" // since 4.15 264 | ) 265 | 266 | type majorMinor struct { 267 | major int64 268 | minor int64 269 | } 270 | 271 | var eventAtLeast = map[EventType]majorMinor{ 272 | WorkspaceEventType: {4, 0}, 273 | OutputEventType: {4, 0}, 274 | ModeEventType: {4, 4}, 275 | WindowEventType: {4, 5}, 276 | BarconfigUpdateEventType: {4, 6}, 277 | BindingEventType: {4, 9}, 278 | ShutdownEventType: {4, 14}, 279 | TickEventType: {4, 15}, 280 | } 281 | 282 | // Subscribe returns an EventReceiver for receiving events of the specified 283 | // types from i3. 284 | // 285 | // Unless the ordering of events matters to your use-case, you are encouraged to 286 | // call Subscribe once per event type, so that you can use type assertions 287 | // instead of type switches. 288 | // 289 | // Subscribe is supported in i3 ≥ v4.0 (2011-07-31). 290 | func Subscribe(eventTypes ...EventType) *EventReceiver { 291 | // Error out early in case any requested event type is not yet supported by 292 | // the running i3 version. 293 | for _, t := range eventTypes { 294 | if err := AtLeast(eventAtLeast[t].major, eventAtLeast[t].minor); err != nil { 295 | return &EventReceiver{err: err} 296 | } 297 | } 298 | return &EventReceiver{types: eventTypes} 299 | } 300 | 301 | // restart runs the restart i3 command without entering an infinite loop: as 302 | // RUN_COMMAND with payload "restart" does not result in a reply, we subscribe 303 | // to the shutdown event beforehand (on a dedicated connection), which we can 304 | // receive instead of a reply. 305 | func restart(firstAttempt bool) error { 306 | sock, conn, err := getIPCSocket(!firstAttempt) 307 | if err != nil { 308 | return err 309 | } 310 | defer conn.Close() 311 | payload, err := json.Marshal([]EventType{ShutdownEventType}) 312 | if err != nil { 313 | return err 314 | } 315 | b, err := sock.roundTrip(messageTypeSubscribe, payload) 316 | if err != nil { 317 | return err 318 | } 319 | var sreply struct { 320 | Success bool `json:"success"` 321 | } 322 | if err := json.Unmarshal(b.Payload, &sreply); err != nil { 323 | return err 324 | } 325 | if !sreply.Success { 326 | return fmt.Errorf("could not subscribe, check the i3 log") 327 | } 328 | rreply, err := sock.roundTrip(messageTypeRunCommand, []byte("restart")) 329 | if err != nil { 330 | return err 331 | } 332 | if (uint32(rreply.Type) & eventFlagMask) == 0 { 333 | var crs []CommandResult 334 | err = json.Unmarshal(rreply.Payload, &crs) 335 | if err == nil { 336 | for _, cr := range crs { 337 | if !cr.Success { 338 | return &CommandUnsuccessfulError{ 339 | command: "restart", 340 | cr: cr, 341 | } 342 | } 343 | } 344 | } 345 | return nil // restart command successful 346 | } 347 | t := uint32(rreply.Type) & eventTypeMask 348 | if got, want := eventReplyType(t), eventReplyTypeShutdown; got != want { 349 | return fmt.Errorf("unexpected reply type: got %d, want %d", got, want) 350 | } 351 | return nil // shutdown event received 352 | } 353 | 354 | var wasRestart = false 355 | 356 | // Restart sends the restart command to i3. Sending restart via RunCommand will 357 | // result in a deadlock: since i3 restarts before it sends the reply to the 358 | // restart command, RunCommand will retry the command indefinitely. 359 | // 360 | // Restart is supported in i3 ≥ v4.14 (2017-09-04). 361 | func Restart() error { 362 | if err := AtLeast(eventAtLeast[ShutdownEventType].major, eventAtLeast[ShutdownEventType].minor); err != nil { 363 | return err 364 | } 365 | 366 | if AtLeast(4, 17) == nil { 367 | _, err := roundTrip(messageTypeRunCommand, []byte("restart")) 368 | return err 369 | } 370 | 371 | log.Println("preventing any further X11 connections to work around issue #3") 372 | wasRestart = true 373 | 374 | var ( 375 | firstAttempt = true 376 | start = time.Now() 377 | lastErr error 378 | ) 379 | for time.Since(start) < reconnectTimeout && (firstAttempt || i3Running()) { 380 | lastErr = restart(firstAttempt) 381 | if lastErr == nil { 382 | return nil // success 383 | } 384 | firstAttempt = false 385 | } 386 | return lastErr 387 | } 388 | -------------------------------------------------------------------------------- /subscribe_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "golang.org/x/sync/errgroup" 13 | ) 14 | 15 | // TestSubscribeSubprocess runs in a process which has been started with 16 | // DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. 17 | func TestSubscribeSubprocess(t *testing.T) { 18 | if os.Getenv("GO_WANT_XVFB") != "1" { 19 | t.Skip("parent process") 20 | } 21 | 22 | // TODO(https://github.com/i3/i3/issues/2988): as soon as we are targeting 23 | // i3 4.15, use SendTick to eliminate race conditions in this test. 24 | 25 | t.Run("subscribe", func(t *testing.T) { 26 | var eg errgroup.Group 27 | ws := Subscribe(WorkspaceEventType) 28 | received := make(chan Event) 29 | eg.Go(func() error { 30 | defer close(received) 31 | if ws.Next() { 32 | received <- ws.Event() 33 | } 34 | return ws.Close() 35 | }) 36 | // As we can’t know when EventReceiver.Next() actually subscribes, we 37 | // just continuously switch workspaces. 38 | ctx, canc := context.WithCancel(context.Background()) 39 | defer canc() 40 | go func() { 41 | cnt := 2 42 | for ctx.Err() == nil { 43 | RunCommand(fmt.Sprintf("workspace %d", cnt)) 44 | cnt++ 45 | time.Sleep(10 * time.Millisecond) 46 | } 47 | }() 48 | select { 49 | case <-received: 50 | case <-time.After(5 * time.Second): 51 | t.Fatalf("timeout waiting for an event from i3") 52 | } 53 | if err := eg.Wait(); err != nil { 54 | t.Fatal(err) 55 | } 56 | }) 57 | 58 | t.Run("subscribeParallel", func(t *testing.T) { 59 | var mu sync.Mutex 60 | received := make(map[string]int) 61 | 62 | recv1 := Subscribe(WorkspaceEventType) 63 | go func() { 64 | for recv1.Next() { 65 | ev := recv1.Event().(*WorkspaceEvent) 66 | if ev.Change == "init" { 67 | mu.Lock() 68 | received[ev.Current.Name]++ 69 | mu.Unlock() 70 | } 71 | } 72 | }() 73 | 74 | recv2 := Subscribe(WorkspaceEventType) 75 | go func() { 76 | for recv2.Next() { 77 | ev := recv2.Event().(*WorkspaceEvent) 78 | if ev.Change == "init" { 79 | mu.Lock() 80 | received[ev.Current.Name]++ 81 | mu.Unlock() 82 | } 83 | } 84 | }() 85 | 86 | cnt := 2 87 | start := time.Now() 88 | for time.Since(start) < 5*time.Second { 89 | mu.Lock() 90 | done := received[fmt.Sprintf("%d", cnt-1)] == 2 91 | mu.Unlock() 92 | if done { 93 | return // success 94 | } 95 | if _, err := RunCommand(fmt.Sprintf("workspace %d", cnt)); err != nil { 96 | t.Fatal(err) 97 | } 98 | cnt++ 99 | time.Sleep(10 * time.Millisecond) 100 | } 101 | }) 102 | 103 | t.Run("subscribeMultiple", func(t *testing.T) { 104 | var eg errgroup.Group 105 | ws := Subscribe(WorkspaceEventType, ModeEventType) 106 | received := make(chan struct{}) 107 | eg.Go(func() error { 108 | defer close(received) 109 | defer ws.Close() 110 | seen := map[EventType]bool{ 111 | WorkspaceEventType: false, 112 | ModeEventType: false, 113 | } 114 | Outer: 115 | for ws.Next() { 116 | switch ws.Event().(type) { 117 | case *WorkspaceEvent: 118 | seen[WorkspaceEventType] = true 119 | case *ModeEvent: 120 | seen[ModeEventType] = true 121 | } 122 | 123 | for _, seen := range seen { 124 | if !seen { 125 | continue Outer 126 | } 127 | } 128 | 129 | return nil 130 | } 131 | return ws.Close() 132 | }) 133 | // As we can’t know when EventReceiver.Next() actually subscribes, we 134 | // just continuously switch workspaces and modes. 135 | ctx, canc := context.WithCancel(context.Background()) 136 | defer canc() 137 | go func() { 138 | modes := []string{"default", "conf"} 139 | cnt := 2 140 | for ctx.Err() == nil { 141 | RunCommand(fmt.Sprintf("workspace %d", cnt)) 142 | cnt++ 143 | RunCommand(fmt.Sprintf("mode %s", modes[cnt%len(modes)])) 144 | time.Sleep(10 * time.Millisecond) 145 | } 146 | }() 147 | select { 148 | case <-received: 149 | case <-time.After(5 * time.Second): 150 | t.Fatalf("timeout waiting for an event from i3") 151 | } 152 | if err := eg.Wait(); err != nil { 153 | t.Fatal(err) 154 | } 155 | }) 156 | } 157 | 158 | func TestSubscribe(t *testing.T) { 159 | t.Parallel() 160 | 161 | ctx, canc := context.WithCancel(context.Background()) 162 | defer canc() 163 | 164 | _, DISPLAY, err := launchXvfb(ctx) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | 169 | cleanup, err := launchI3(ctx, DISPLAY, "") 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | defer cleanup() 174 | 175 | cmd := exec.Command(os.Args[0], "-test.run=TestSubscribeSubprocess", "-test.v") 176 | cmd.Env = []string{ 177 | "GO_WANT_XVFB=1", 178 | "DISPLAY=" + DISPLAY, 179 | "PATH=" + os.Getenv("PATH"), 180 | } 181 | cmd.Stdout = os.Stdout 182 | cmd.Stderr = os.Stderr 183 | if err := cmd.Run(); err != nil { 184 | t.Fatal(err.Error()) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /sync.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // SyncRequest represents the payload of a Sync request. 6 | type SyncRequest struct { 7 | Window uint32 `json:"window"` // X11 window id 8 | Rnd uint32 `json:"rnd"` // Random value for distinguishing requests 9 | } 10 | 11 | // SyncResult attests the sync command was successful. 12 | type SyncResult struct { 13 | Success bool `json:"success"` 14 | } 15 | 16 | // Sync sends a tick event with the provided payload. 17 | // 18 | // Sync is supported in i3 ≥ v4.16 (2018-11-04). 19 | func Sync(req SyncRequest) (SyncResult, error) { 20 | b, err := json.Marshal(req) 21 | if err != nil { 22 | return SyncResult{}, err 23 | } 24 | reply, err := roundTrip(messageTypeSync, b) 25 | if err != nil { 26 | return SyncResult{}, err 27 | } 28 | 29 | var tr SyncResult 30 | err = json.Unmarshal(reply.Payload, &tr) 31 | return tr, err 32 | } 33 | -------------------------------------------------------------------------------- /sync_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "os" 7 | "os/exec" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/BurntSushi/xgb/xproto" 12 | "github.com/BurntSushi/xgbutil" 13 | ) 14 | 15 | // TestSyncSubprocess runs in a process which has been started with 16 | // DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. 17 | func TestSyncSubprocess(t *testing.T) { 18 | if os.Getenv("GO_WANT_XVFB") != "1" { 19 | t.Skip("parent process") 20 | } 21 | 22 | xu, err := xgbutil.NewConn() 23 | if err != nil { 24 | t.Fatalf("NewConn: %v", err) 25 | } 26 | defer xu.Conn().Close() 27 | 28 | // Create an X11 window 29 | X := xu.Conn() 30 | wid, err := xproto.NewWindowId(X) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | screen := xproto.Setup(X).DefaultScreen(X) 35 | cookie := xproto.CreateWindowChecked( 36 | X, 37 | screen.RootDepth, 38 | wid, 39 | screen.Root, 40 | 0, // x 41 | 0, // y 42 | 1, // width 43 | 1, // height 44 | 0, // border width 45 | xproto.WindowClassInputOutput, 46 | screen.RootVisual, 47 | xproto.CwBackPixel|xproto.CwEventMask, 48 | []uint32{ // values must be in the order defined by the protocol 49 | 0xffffffff, 50 | xproto.EventMaskStructureNotify | 51 | xproto.EventMaskKeyPress | 52 | xproto.EventMaskKeyRelease}) 53 | if err := cookie.Check(); err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | // Synchronize i3 with that X11 window 58 | rnd := rand.Uint32() 59 | resp, err := Sync(SyncRequest{ 60 | Rnd: rnd, 61 | Window: uint32(wid), 62 | }) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | if got, want := resp.Success, true; got != want { 67 | t.Fatalf("SyncResult.Success: got %v, want %v", got, want) 68 | } 69 | 70 | for { 71 | ev, xerr := X.WaitForEvent() 72 | if xerr != nil { 73 | t.Fatalf("WaitEvent: got X11 error %v", xerr) 74 | } 75 | cm, ok := ev.(xproto.ClientMessageEvent) 76 | if !ok { 77 | t.Logf("ignoring non-ClientMessage %v", ev) 78 | continue 79 | } 80 | if got, want := cm.Window, wid; got != want { 81 | t.Errorf("sync ClientMessage.Window: got %v, want %v", got, want) 82 | } 83 | if got, want := cm.Data.Data32[:2], []uint32{uint32(wid), rnd}; !reflect.DeepEqual(got, want) { 84 | t.Errorf("sync ClientMessage.Data: got %x, want %x", got, want) 85 | } 86 | break 87 | } 88 | } 89 | 90 | func TestSync(t *testing.T) { 91 | t.Parallel() 92 | 93 | ctx, canc := context.WithCancel(context.Background()) 94 | defer canc() 95 | 96 | _, DISPLAY, err := launchXvfb(ctx) 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | cleanup, err := launchI3(ctx, DISPLAY, "") 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | defer cleanup() 106 | 107 | cmd := exec.Command(os.Args[0], "-test.run=TestSyncSubprocess", "-test.v") 108 | cmd.Env = []string{ 109 | "GO_WANT_XVFB=1", 110 | "DISPLAY=" + DISPLAY, 111 | "PATH=" + os.Getenv("PATH"), 112 | } 113 | cmd.Stdout = os.Stdout 114 | cmd.Stderr = os.Stderr 115 | if err := cmd.Run(); err != nil { 116 | t.Fatal(err.Error()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /testdata/i3.config: -------------------------------------------------------------------------------- 1 | # i3 config file (v4) 2 | 3 | # ISO 10646 = Unicode 4 | font -misc-fixed-medium-r-normal--13-120-75-75-C-70-iso10646-1 5 | 6 | mode "conf" { 7 | } 8 | 9 | bar { 10 | # disable i3bar 11 | i3bar_command : 12 | status_command i3status 13 | } 14 | -------------------------------------------------------------------------------- /tick.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // TickResult attests the tick command was successful. 6 | type TickResult struct { 7 | Success bool `json:"success"` 8 | } 9 | 10 | // SendTick sends a tick event with the provided payload. 11 | // 12 | // SendTick is supported in i3 ≥ v4.15 (2018-03-10). 13 | func SendTick(command string) (TickResult, error) { 14 | reply, err := roundTrip(messageTypeSendTick, []byte(command)) 15 | if err != nil { 16 | return TickResult{}, err 17 | } 18 | 19 | var tr TickResult 20 | err = json.Unmarshal(reply.Payload, &tr) 21 | return tr, err 22 | } 23 | -------------------------------------------------------------------------------- /travis/Dockerfile: -------------------------------------------------------------------------------- 1 | # vim:ft=Dockerfile 2 | FROM debian:sid 3 | 4 | RUN echo force-unsafe-io > /etc/dpkg/dpkg.cfg.d/docker-apt-speedup 5 | # Paper over occasional network flakiness of some mirrors. 6 | RUN echo 'APT::Acquire::Retries "5";' > /etc/apt/apt.conf.d/80retry 7 | 8 | # NOTE: I tried exclusively using gce_debian_mirror.storage.googleapis.com 9 | # instead of httpredir.debian.org, but the results (Fetched 123 MB in 36s (3357 10 | # kB/s)) are not any better than httpredir.debian.org (Fetched 123 MB in 34s 11 | # (3608 kB/s)). Hence, let’s stick with httpredir.debian.org (default) for now. 12 | 13 | RUN apt-get update && \ 14 | DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 15 | i3-wm=4.24-1 xvfb strace && \ 16 | rm -rf /var/lib/apt/lists/* 17 | 18 | WORKDIR /usr/src 19 | -------------------------------------------------------------------------------- /tree.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // NodeType indicates the specific kind of Node. 8 | type NodeType string 9 | 10 | // i3 currently implements the following node types: 11 | const ( 12 | Root NodeType = "root" 13 | OutputNode NodeType = "output" 14 | Con NodeType = "con" 15 | FloatingCon NodeType = "floating_con" 16 | WorkspaceNode NodeType = "workspace" 17 | DockareaNode NodeType = "dockarea" 18 | ) 19 | 20 | // Layout indicates the layout of a Node. 21 | type Layout string 22 | 23 | // i3 currently implements the following layouts: 24 | const ( 25 | SplitH Layout = "splith" 26 | SplitV Layout = "splitv" 27 | Stacked Layout = "stacked" 28 | Tabbed Layout = "tabbed" 29 | DockareaLayout Layout = "dockarea" 30 | OutputLayout Layout = "output" 31 | ) 32 | 33 | // BorderStyle indicates the border style of a node. 34 | type BorderStyle string 35 | 36 | // i3 currently implements the following border styles: 37 | const ( 38 | NormalBorder BorderStyle = "normal" 39 | NoBorder BorderStyle = "none" 40 | PixelBorder BorderStyle = "pixel" 41 | ) 42 | 43 | // Rect is a rectangle, used for various dimensions in Node, for example. 44 | type Rect struct { 45 | X int64 `json:"x"` 46 | Y int64 `json:"y"` 47 | Width int64 `json:"width"` 48 | Height int64 `json:"height"` 49 | } 50 | 51 | // WindowProperties correspond to X11 window properties 52 | // 53 | // See https://build.i3wm.org/docs/ipc.html#_tree_reply 54 | type WindowProperties struct { 55 | Title string `json:"title"` 56 | Instance string `json:"instance"` 57 | Class string `json:"class"` 58 | Role string `json:"window_role"` 59 | Transient NodeID `json:"transient_for"` 60 | } 61 | 62 | // NodeID is an i3-internal ID for the node, which can be used to identify 63 | // containers within the IPC interface. 64 | type NodeID int64 65 | 66 | // FullscreenMode indicates whether the container is fullscreened, and relative 67 | // to where (its output, or globally). Note that all workspaces are considered 68 | // fullscreened on their respective output. 69 | type FullscreenMode int64 70 | 71 | const ( 72 | FullscreenNone FullscreenMode = 0 73 | FullscreenOutput FullscreenMode = 1 74 | FullscreenGlobal FullscreenMode = 2 75 | ) 76 | 77 | // FloatingType indicates the floating type of Node. 78 | type FloatingType string 79 | 80 | // i3 currently implements the following node types: 81 | const ( 82 | AutoOff FloatingType = "auto_off" 83 | AutoOn FloatingType = "auto_on" 84 | UserOn FloatingType = "user_on" 85 | UserOff FloatingType = "user_off" 86 | ) 87 | 88 | // Node is a node in a Tree. 89 | // 90 | // See https://i3wm.org/docs/ipc.html#_tree_reply for more details. 91 | type Node struct { 92 | ID NodeID `json:"id"` 93 | Name string `json:"name"` // window: title, container: internal name 94 | Type NodeType `json:"type"` 95 | Border BorderStyle `json:"border"` 96 | CurrentBorderWidth int64 `json:"current_border_width"` 97 | Layout Layout `json:"layout"` 98 | Percent float64 `json:"percent"` 99 | Rect Rect `json:"rect"` // absolute (= relative to X11 display) 100 | WindowRect Rect `json:"window_rect"` // window, relative to Rect 101 | DecoRect Rect `json:"deco_rect"` // decoration, relative to Rect 102 | ActualDecoRect Rect `json:"actual_deco_rect"` // see https://github.com/i3/i3/issues/1966 103 | Geometry Rect `json:"geometry"` // original window geometry, absolute 104 | Window int64 `json:"window"` // X11 window ID of the client window 105 | WindowProperties WindowProperties `json:"window_properties"` 106 | Urgent bool `json:"urgent"` // urgency hint set 107 | Marks []string `json:"marks"` 108 | Focused bool `json:"focused"` 109 | WindowType string `json:"window_type"` 110 | FullscreenMode FullscreenMode `json:"fullscreen_mode"` 111 | Focus []NodeID `json:"focus"` 112 | Nodes []*Node `json:"nodes"` 113 | FloatingNodes []*Node `json:"floating_nodes"` 114 | Floating FloatingType `json:"floating"` 115 | ScratchpadState string `json:"scratchpad_state"` 116 | AppID string `json:"app_id"` // if talking to Sway: Wayland App ID 117 | Sticky bool `json:"sticky"` 118 | } 119 | 120 | // FindChild returns the first Node matching predicate, using pre-order 121 | // depth-first search. 122 | func (n *Node) FindChild(predicate func(*Node) bool) *Node { 123 | if predicate(n) { 124 | return n 125 | } 126 | for _, c := range n.Nodes { 127 | if con := c.FindChild(predicate); con != nil { 128 | return con 129 | } 130 | } 131 | for _, c := range n.FloatingNodes { 132 | if con := c.FindChild(predicate); con != nil { 133 | return con 134 | } 135 | } 136 | return nil 137 | } 138 | 139 | // FindFocused returns the first Node matching predicate from the sub-tree of 140 | // directly and indirectly focused containers. 141 | // 142 | // As an example, consider this layout tree (simplified): 143 | // 144 | // root 145 | // │ 146 | // HDMI2 147 | // ╱ ╲ 148 | // … workspace 1 149 | // ╱ ╲ 150 | // XTerm Firefox 151 | // 152 | // In this example, if Firefox is focused, FindFocused will return the first 153 | // container matching predicate of root, HDMI2, workspace 1, Firefox (in this 154 | // order). 155 | func (n *Node) FindFocused(predicate func(*Node) bool) *Node { 156 | if predicate(n) { 157 | return n 158 | } 159 | if len(n.Focus) == 0 { 160 | return nil 161 | } 162 | first := n.Focus[0] 163 | for _, c := range n.Nodes { 164 | if c.ID == first { 165 | return c.FindFocused(predicate) 166 | } 167 | } 168 | for _, c := range n.FloatingNodes { 169 | if c.ID == first { 170 | return c.FindFocused(predicate) 171 | } 172 | } 173 | return nil 174 | } 175 | 176 | // Tree is an i3 layout tree, starting with Root. 177 | type Tree struct { 178 | // Root is the root node of the layout tree. 179 | Root *Node 180 | } 181 | 182 | // GetTree returns i3’s layout tree. 183 | // 184 | // GetTree is supported in i3 ≥ v4.0 (2011-07-31). 185 | func GetTree() (Tree, error) { 186 | reply, err := roundTrip(messageTypeGetTree, nil) 187 | if err != nil { 188 | return Tree{}, err 189 | } 190 | 191 | var root Node 192 | err = json.Unmarshal(reply.Payload, &root) 193 | return Tree{Root: &root}, err 194 | } 195 | -------------------------------------------------------------------------------- /tree_utils.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "strings" 4 | 5 | // FindParent method returns the parent node of the current one 6 | // by requesting and traversing the tree 7 | func (child *Node) FindParent() *Node { 8 | tree, err := GetTree() 9 | if err != nil { 10 | return nil 11 | } 12 | parent := tree.Root.FindChild(func(n *Node) bool { 13 | for _, f := range n.Focus { 14 | if f == child.ID { 15 | return true 16 | } 17 | } 18 | return false 19 | }) 20 | 21 | return parent 22 | } 23 | 24 | // IsFloating method returns true if the current node is floating 25 | func (n *Node) IsFloating() bool { 26 | return strings.HasSuffix(string(n.Floating), "_on") 27 | } 28 | -------------------------------------------------------------------------------- /tree_utils_test.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | "testing" 8 | ) 9 | 10 | // TestTreeUtilsSubprocess runs in a process which has been started with 11 | // DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. 12 | func TestTreeUtilsSubprocess(t *testing.T) { 13 | if os.Getenv("GO_WANT_XVFB") != "1" { 14 | t.Skip("parent process") 15 | } 16 | 17 | mark_name := "foo" 18 | ws_name := "1:test_space" 19 | 20 | if _, err := RunCommand("rename workspace to " + ws_name); err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | if _, err := RunCommand("open; mark " + mark_name); err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | t.Run("FindParent", func(t *testing.T) { 29 | t.Parallel() 30 | got, err := GetTree() 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | node := got.Root.FindFocused(func(n *Node) bool { return n.Focused }) 36 | if node == nil { 37 | t.Fatal("unexpectedly could not find any focused node in GetTree reply") 38 | } 39 | 40 | // Exercise FindParent to locate parent for given node. 41 | parent := node.FindParent() 42 | 43 | if parent == nil { 44 | t.Fatal("no parent found") 45 | } 46 | if parent.Name != ws_name { 47 | t.Fatal("wrong parent found: " + parent.Name) 48 | } 49 | }) 50 | 51 | t.Run("IsFloating", func(t *testing.T) { 52 | // do not run in parallel because 'floating toggle' breaks other tests 53 | 54 | got, err := GetTree() 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | node := got.Root.FindFocused(func(n *Node) bool { return n.Focused }) 60 | if node == nil { 61 | t.Fatal("unexpectedly could not find any focused node in GetTree reply") 62 | } 63 | 64 | if node.IsFloating() == true { 65 | t.Fatal("node is floating") 66 | } 67 | 68 | if _, err := RunCommand("floating toggle"); err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | got, err = GetTree() 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | node = got.Root.FindFocused(func(n *Node) bool { return n.Focused }) 78 | if node == nil { 79 | t.Fatal("unexpectedly could not find any focused node in GetTree reply") 80 | } 81 | 82 | if node.IsFloating() == false { 83 | t.Fatal("node is not floating") 84 | } 85 | 86 | RunCommand("floating toggle") 87 | }) 88 | } 89 | 90 | func TestTreeUtils(t *testing.T) { 91 | t.Parallel() 92 | 93 | ctx, canc := context.WithCancel(context.Background()) 94 | defer canc() 95 | 96 | _, DISPLAY, err := launchXvfb(ctx) 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | cleanup, err := launchI3(ctx, DISPLAY, "") 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | defer cleanup() 106 | 107 | cmd := exec.Command(os.Args[0], "-test.run=TestTreeUtilsSubprocess", "-test.v") 108 | cmd.Env = []string{ 109 | "GO_WANT_XVFB=1", 110 | "DISPLAY=" + DISPLAY, 111 | "PATH=" + os.Getenv("PATH"), 112 | } 113 | cmd.Stdout = os.Stdout 114 | cmd.Stderr = os.Stderr 115 | if err := cmd.Run(); err != nil { 116 | t.Fatal(err.Error()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /validpid.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "syscall" 4 | 5 | func pidValid(pid int) bool { 6 | // As per kill(2) from POSIX.1-2008, sending signal 0 validates a pid. 7 | if err := syscall.Kill(pid, 0); err != nil { 8 | if err == syscall.EPERM { 9 | // Process still alive (but no permission to signal): 10 | return true 11 | } 12 | // errno is likely ESRCH (process not found). 13 | return false // Process not alive. 14 | } 15 | return true // Process still alive. 16 | } 17 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | ) 8 | 9 | // Version describes an i3 version. 10 | // 11 | // See https://i3wm.org/docs/ipc.html#_version_reply for more details. 12 | type Version struct { 13 | Major int64 `json:"major"` 14 | Minor int64 `json:"minor"` 15 | Patch int64 `json:"patch"` 16 | Variant string `json:"variant,omitempty"` 17 | HumanReadable string `json:"human_readable"` 18 | LoadedConfigFileName string `json:"loaded_config_file_name"` 19 | } 20 | 21 | // GetVersion returns i3’s version. 22 | // 23 | // GetVersion is supported in i3 ≥ v4.3 (2012-09-19). 24 | func GetVersion() (Version, error) { 25 | reply, err := roundTrip(messageTypeGetVersion, nil) 26 | if err != nil { 27 | return Version{}, err 28 | } 29 | 30 | var v Version 31 | err = json.Unmarshal(reply.Payload, &v) 32 | return v, err 33 | } 34 | 35 | // version is a lazily-initialized, possibly stale copy of i3’s GET_VERSION 36 | // reply. Access only values which don’t change, e.g. Major, Minor. 37 | var version Version 38 | 39 | // versionWarning is used to only warn a single time when unsupported versions are 40 | // detected. 41 | var versionWarning bool 42 | 43 | // AtLeast returns nil if i3’s major version matches major and i3’s minor 44 | // version is at least minor or newer. Otherwise, it returns an error message 45 | // stating i3 is too old. 46 | func AtLeast(major int64, minor int64) error { 47 | if major == 0 { 48 | return fmt.Errorf("BUG: major == 0 is non-sensical. Is a lookup table entry missing?") 49 | } 50 | if version.Major == 0 { 51 | var err error 52 | version, err = GetVersion() 53 | if err != nil { 54 | return err 55 | } 56 | } 57 | 58 | if version.Variant != "" { 59 | if !versionWarning { 60 | versionWarning = true 61 | log.Printf("non standard i3 payload variant '%s' detected. Ignoring version check. This is fully unsupported.", version.Variant) 62 | } 63 | return nil 64 | } 65 | 66 | if version.Major == major && version.Minor >= minor { 67 | return nil 68 | } 69 | 70 | return fmt.Errorf("i3 version too old: got %d.%d, want ≥ %d.%d", version.Major, version.Minor, major, minor) 71 | } 72 | -------------------------------------------------------------------------------- /workspaces.go: -------------------------------------------------------------------------------- 1 | package i3 2 | 3 | import "encoding/json" 4 | 5 | // WorkspaceID is an i3-internal ID for the node, which can be used to identify 6 | // workspaces within the IPC interface. 7 | type WorkspaceID int64 8 | 9 | // Workspace describes an i3 workspace. 10 | // 11 | // See https://i3wm.org/docs/ipc.html#_workspaces_reply for more details. 12 | type Workspace struct { 13 | ID WorkspaceID `json:"id"` 14 | Num int64 `json:"num"` 15 | Name string `json:"name"` 16 | Visible bool `json:"visible"` 17 | Focused bool `json:"focused"` 18 | Urgent bool `json:"urgent"` 19 | Rect Rect `json:"rect"` 20 | Output string `json:"output"` 21 | } 22 | 23 | // GetWorkspaces returns i3’s current workspaces. 24 | // 25 | // GetWorkspaces is supported in i3 ≥ v4.0 (2011-07-31). 26 | func GetWorkspaces() ([]Workspace, error) { 27 | reply, err := roundTrip(messageTypeGetWorkspaces, nil) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | var ws []Workspace 33 | err = json.Unmarshal(reply.Payload, &ws) 34 | return ws, err 35 | } 36 | --------------------------------------------------------------------------------