commit 4d7394230048d7fb74b7870d48b99709672ce56f Author: milarin Date: Sun Oct 22 16:19:10 2023 +0200 initial commit diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..e1ddf87 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,56 @@ +name: GitHub Actions CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + build: + name: CI + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + # Run on the latest minor release of Go 1.19: + go-version: ^1.19 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Ensure all files were formatted as per gofmt + run: | + [ "$(gofmt -l $(find . -name '*.go') 2>&1)" = "" ] + + - name: Get dependencies + run: | + go get -v -t -d ./... + + - name: Go Vet + run: | + go vet + + - name: Build + run: | + go build -v . + + - name: Test + run: | + go test -c + + - name: Build Docker container with i3 + run: | + docker build --pull --no-cache --rm -t=goi3 -f travis/Dockerfile . + + - name: Run tests in Docker container + # The --init flag is load-bearing! Xserver(1) (including the Xvfb variant) + # will not send SIGUSR1 for readiness notification to pid 1, so we need to + # ensure that the i3.test process is not pid 1: + # https://gitlab.freedesktop.org/xorg/xserver/-/blob/4195e8035645007be313ade79032b8d561ceec6c/os/connection.c#L207 + run: | + docker run --init -v $PWD:/usr/src goi3 ./i3.test -test.v diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1fb9f59 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright © 2017, Michael Stapelberg and contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of Michael Stapelberg nor the + names of contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY Michael Stapelberg ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL Michael Stapelberg BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..55fb931 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +![GitHub Actions CI](https://github.com/i3/go-i3/workflows/GitHub%20Actions%20CI/badge.svg) +[![Go Report Card](https://goreportcard.com/badge/go.i3wm.org/i3)](https://goreportcard.com/report/go.i3wm.org/i3) +[![GoDoc](https://godoc.org/go.i3wm.org/i3?status.svg)](https://godoc.org/go.i3wm.org/i3) + +Package i3 provides a convenient interface to the i3 window manager via [its IPC +interface](https://i3wm.org/docs/ipc.html). + +See [its documentation](https://godoc.org/go.i3wm.org/i3) for more details. + +## Start using it + +In [module mode](https://github.com/golang/go/wiki/Modules), use import path +`go.i3wm.org/i3/v4`. + +In non-module mode, use import path `go.i3wm.org/i3`. + +## Advantages over other i3 IPC packages + +Here comes a grab bag of features to which we paid attention. At the time of +writing, most other i3 IPC packages lack at least a good number of these +features: + +* Retries are transparently handled: programs using this package will recover + automatically from in-place i3 restarts. Additionally, programs can be started + from xsession or user sessions before i3 is even running. + +* Version checks are transparently handled: if your program uses features which + are not supported by the running i3 version, helpful error messages will be + returned at run time. + +* Comprehensive: the entire documented IPC interface of the latest stable i3 + version is covered by this package. Tagged releases match i3’s major and minor + version. + +* Consistent and familiar: once familiar with the i3 IPC protocol’s features, + you should have no trouble matching the documentation to API and vice-versa. + +* Good test coverage (hard to display in a badge, as our multi-process setup + breaks `go test`’s `-coverprofile` flag). + +* Implemented in pure Go, without resorting to the `unsafe` package. + +* Works on little and big endian architectures. + +## Scope + +i3’s entire documented IPC interface is available in this package. + +In addition, helper functions which are useful for a broad range of programs +(and only those!) are provided, e.g. Node’s FindChild and FindFocused. + +Packages which introduce higher-level abstractions should feel free to use this +package as a building block. + +## Assumptions + +* The `i3(1)` binary must be in `$PATH` so that the IPC socket path can be retrieved. +* For transparent version checks to work, the running i3 version must be ≥ 4.3 (released 2012-09-19). + +## Testing + +Be sure to include the target i3 version (the most recent stable release) in +`$PATH` and use `go test` as usual: + +```shell +PATH=~/i3/build/i3:$PATH go test -v go.i3wm.org/i3 +``` diff --git a/barconfig.go b/barconfig.go new file mode 100644 index 0000000..ac89f12 --- /dev/null +++ b/barconfig.go @@ -0,0 +1,111 @@ +package i3 + +import "encoding/json" + +// BarConfigColors describes a serialized bar colors configuration block. +// +// See https://i3wm.org/docs/ipc.html#_bar_config_reply for more details. +type BarConfigColors struct { + Background string `json:"background"` + Statusline string `json:"statusline"` + Separator string `json:"separator"` + + FocusedBackground string `json:"focused_background"` + FocusedStatusline string `json:"focused_statusline"` + FocusedSeparator string `json:"focused_separator"` + + FocusedWorkspaceText string `json:"focused_workspace_text"` + FocusedWorkspaceBackground string `json:"focused_workspace_bg"` + FocusedWorkspaceBorder string `json:"focused_workspace_border"` + + ActiveWorkspaceText string `json:"active_workspace_text"` + ActiveWorkspaceBackground string `json:"active_workspace_bg"` + ActiveWorkspaceBorder string `json:"active_workspace_border"` + + InactiveWorkspaceText string `json:"inactive_workspace_text"` + InactiveWorkspaceBackground string `json:"inactive_workspace_bg"` + InactiveWorkspaceBorder string `json:"inactive_workspace_border"` + + UrgentWorkspaceText string `json:"urgent_workspace_text"` + UrgentWorkspaceBackground string `json:"urgent_workspace_bg"` + UrgentWorkspaceBorder string `json:"urgent_workspace_border"` + + BindingModeText string `json:"binding_mode_text"` + BindingModeBackground string `json:"binding_mode_bg"` + BindingModeBorder string `json:"binding_mode_border"` +} + +// BarConfig describes a serialized bar configuration block. +// +// See https://i3wm.org/docs/ipc.html#_bar_config_reply for more details. +type BarConfig struct { + ID string `json:"id"` + Mode string `json:"mode"` + Position string `json:"position"` + StatusCommand string `json:"status_command"` + Font string `json:"font"` + WorkspaceButtons bool `json:"workspace_buttons"` + BindingModeIndicator bool `json:"binding_mode_indicator"` + Verbose bool `json:"verbose"` + Colors BarConfigColors `json:"colors"` +} + +// GetBarIDs returns an array of configured bar IDs. +// +// GetBarIDs is supported in i3 ≥ v4.1 (2011-11-11). +func GetBarIDs() ([]string, error) { + reply, err := roundTrip(messageTypeGetBarConfig, nil) + if err != nil { + return nil, err + } + + var ids []string + err = json.Unmarshal(reply.Payload, &ids) + return ids, err +} + +// GetBarConfig returns the configuration for the bar with the specified barID. +// +// Obtain the barID from GetBarIDs. +// +// GetBarConfig is supported in i3 ≥ v4.1 (2011-11-11). +func GetBarConfig(barID string) (BarConfig, error) { + reply, err := roundTrip(messageTypeGetBarConfig, []byte(barID)) + if err != nil { + return BarConfig{}, err + } + + cfg := BarConfig{ + Colors: BarConfigColors{ + Background: "#000000", + Statusline: "#ffffff", + Separator: "#666666", + + FocusedBackground: "#000000", + FocusedStatusline: "#ffffff", + FocusedSeparator: "#666666", + + FocusedWorkspaceText: "#4c7899", + FocusedWorkspaceBackground: "#285577", + FocusedWorkspaceBorder: "#ffffff", + + ActiveWorkspaceText: "#333333", + ActiveWorkspaceBackground: "#5f676a", + ActiveWorkspaceBorder: "#ffffff", + + InactiveWorkspaceText: "#333333", + InactiveWorkspaceBackground: "#222222", + InactiveWorkspaceBorder: "#888888", + + UrgentWorkspaceText: "#2f343a", + UrgentWorkspaceBackground: "#900000", + UrgentWorkspaceBorder: "#ffffff", + + BindingModeText: "#2f343a", + BindingModeBackground: "#900000", + BindingModeBorder: "#ffffff", + }, + } + err = json.Unmarshal(reply.Payload, &cfg) + return cfg, err +} diff --git a/bindingmodes.go b/bindingmodes.go new file mode 100644 index 0000000..42de658 --- /dev/null +++ b/bindingmodes.go @@ -0,0 +1,36 @@ +package i3 + +import "encoding/json" + +// GetBindingModes returns the names of all currently configured binding modes. +// +// GetBindingModes is supported in i3 ≥ v4.13 (2016-11-08). +func GetBindingModes() ([]string, error) { + reply, err := roundTrip(messageTypeGetBindingModes, nil) + if err != nil { + return nil, err + } + + var bm []string + err = json.Unmarshal(reply.Payload, &bm) + return bm, err +} + +// BindingState indicates which binding mode is currently active. +type BindingState struct { + Name string `json:"name"` +} + +// GetBindingState returns the currently active binding mode. +// +// GetBindingState is supported in i3 ≥ 4.19 (2020-11-15). +func GetBindingState() (BindingState, error) { + reply, err := roundTrip(messageTypeGetBindingState, nil) + if err != nil { + return BindingState{}, err + } + + var bm BindingState + err = json.Unmarshal(reply.Payload, &bm) + return bm, err +} diff --git a/byteorder.go b/byteorder.go new file mode 100644 index 0000000..51b2d01 --- /dev/null +++ b/byteorder.go @@ -0,0 +1,63 @@ +package i3 + +import ( + "encoding/binary" + "io" + "strings" +) + +// detectByteOrder sends messages to i3 to determine the byte order it uses. +// For details on this technique, see: +// https://build.i3wm.org/docs/ipc.html#_appendix_a_detecting_byte_order_in_memory_safe_languages +func detectByteOrder(conn io.ReadWriter) (binary.ByteOrder, error) { + const ( + // targetLen is 0x00 01 01 00 in both, big and little endian + targetLen = 65536 + 256 + + // SUBSCRIBE was introduced in 3.e (2010-03-30) + prefixSubscribe = "[]" + + // RUN_COMMAND was always present + prefixCmd = "nop byte-order detection. padding: " + ) + + // 2. Send a big endian encoded message of type SUBSCRIBE: + payload := []byte(prefixSubscribe + strings.Repeat(" ", targetLen-len(prefixSubscribe))) + if err := binary.Write(conn, binary.BigEndian, &header{magic, uint32(len(payload)), messageTypeSubscribe}); err != nil { + return nil, err + } + if _, err := conn.Write(payload); err != nil { + return nil, err + } + + // 3. Send a byte order independent RUN_COMMAND message: + payload = []byte(prefixCmd + strings.Repeat("a", targetLen-len(prefixCmd))) + if err := binary.Write(conn, binary.BigEndian, &header{magic, uint32(len(payload)), messageTypeRunCommand}); err != nil { + return nil, err + } + if _, err := conn.Write(payload); err != nil { + return nil, err + } + + // 4. Receive a message header, decode the message type as big endian: + var header [14]byte + if _, err := io.ReadFull(conn, header[:]); err != nil { + return nil, err + } + if messageType(binary.BigEndian.Uint32(header[10:14])) == messageReplyTypeCommand { + order := binary.LittleEndian // our big endian message was not answered + // Read remaining payload + _, err := io.ReadFull(conn, make([]byte, order.Uint32(header[6:10]))) + return order, err + } + order := binary.BigEndian // our big endian message was answered + // Read remaining payload + if _, err := io.ReadFull(conn, make([]byte, order.Uint32(header[6:10]))); err != nil { + return order, err + } + + // Slurp the pending RUN_COMMAND reply. + sock := &socket{conn: conn, order: order} + _, err := sock.recvMsg() + return binary.BigEndian, err +} diff --git a/byteorder_test.go b/byteorder_test.go new file mode 100644 index 0000000..3ae9fde --- /dev/null +++ b/byteorder_test.go @@ -0,0 +1,124 @@ +package i3 + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "io/ioutil" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sync/errgroup" +) + +func msgBytes(order binary.ByteOrder, t messageType, payload string) []byte { + var buf bytes.Buffer + if err := binary.Write(&buf, order, &header{magic, uint32(len(payload)), t}); err != nil { + panic(err) + } + _, err := buf.WriteString(payload) + if err != nil { + panic(err) + } + return buf.Bytes() +} + +func TestDetectByteOrder(t *testing.T) { + t.Parallel() + + for _, i3order := range []binary.ByteOrder{binary.BigEndian, binary.LittleEndian} { + i3order := i3order // copy + t.Run(fmt.Sprintf("%T", i3order), func(t *testing.T) { + t.Parallel() + + var ( + subscribeRequest = msgBytes(i3order, messageTypeSubscribe, "[]"+strings.Repeat(" ", 65536+256-2)) + subscribeReply = msgBytes(i3order, messageReplyTypeSubscribe, `{"success": true}`) + + nopPrefix = "nop byte-order detection. padding: " + runCommandRequest = msgBytes(i3order, messageTypeRunCommand, nopPrefix+strings.Repeat("a", 65536+256-len(nopPrefix))) + runCommandReply = msgBytes(i3order, messageReplyTypeCommand, `[{"success": true}]`) + + protocol = map[string][]byte{ + string(subscribeRequest): subscribeReply, + string(runCommandRequest): runCommandReply, + } + ) + + // Abstract socket addresses are a linux-only feature, so we must + // use file system paths for listening/dialing: + dir, err := ioutil.TempDir("", "i3test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, fmt.Sprintf("i3test-%T.sock", i3order)) + i3addr, err := net.ResolveUnixAddr("unix", path) + if err != nil { + t.Fatal(err) + } + i3ln, err := net.ListenUnix("unix", i3addr) + if err != nil { + t.Fatal(err) + } + + var ( + eg errgroup.Group + order binary.ByteOrder + orderErr error + ) + eg.Go(func() error { + addr, err := net.ResolveUnixAddr("unix", path) + if err != nil { + return err + } + conn, err := net.DialUnix("unix", nil, addr) + if err != nil { + return err + } + order, orderErr = detectByteOrder(conn) + conn.Close() + i3ln.Close() // unblock Accept and return an error + return orderErr + }) + eg.Go(func() error { + for { + conn, err := i3ln.Accept() + if err != nil { + return err + } + eg.Go(func() error { + defer conn.Close() + for { + var request [14 + 65536 + 256]byte + if _, err := io.ReadFull(conn, request[:]); err != nil { + return err + } + if reply := protocol[string(request[:])]; reply != nil { + if _, err := io.Copy(conn, bytes.NewReader(reply)); err != nil { + return err + } + continue + } + // silently drop unexpected messages like i3 + } + }) + } + }) + if err := eg.Wait(); err != nil { + // If order != nil && orderErr == nil, the test succeeded and any + // returned errors are from teardown. + if order == nil || orderErr != nil { + t.Fatal(err) + } + } + if got, want := order, i3order; got != want { + t.Fatalf("unexpected byte order: got %v, want %v", got, want) + } + }) + } +} diff --git a/close_test.go b/close_test.go new file mode 100644 index 0000000..14115c7 --- /dev/null +++ b/close_test.go @@ -0,0 +1,62 @@ +package i3 + +import ( + "context" + "os" + "os/exec" + "testing" + "time" +) + +// TestCloseSubprocess runs in a process which has been started with +// DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. +func TestCloseSubprocess(t *testing.T) { + if os.Getenv("GO_WANT_XVFB") != "1" { + t.Skip("parent process") + } + + ws := Subscribe(WorkspaceEventType) + received := make(chan Event) + go func() { + defer close(received) + for ws.Next() { + } + received <- nil + }() + ws.Close() + select { + case <-received: + case <-time.After(5 * time.Second): + t.Fatalf("timeout waiting for a Close()") + } +} + +func TestClose(t *testing.T) { + t.Parallel() + + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + _, DISPLAY, err := launchXvfb(ctx) + if err != nil { + t.Fatal(err) + } + + cleanup, err := launchI3(ctx, DISPLAY, "") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cmd := exec.Command(os.Args[0], "-test.run=TestCloseSubprocess", "-test.v") + cmd.Env = []string{ + "GO_WANT_XVFB=1", + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err.Error()) + } +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..5093e00 --- /dev/null +++ b/command.go @@ -0,0 +1,64 @@ +package i3 + +import ( + "encoding/json" + "fmt" +) + +// CommandResult always contains Success, and command-specific fields where +// appropriate. +type CommandResult struct { + // Success indicates whether the command was run without any errors. + Success bool `json:"success"` + + // Error is a human-readable error message, non-empty for unsuccessful + // commands. + Error string `json:"error"` +} + +// IsUnsuccessful is a convenience function which can be used to check if an +// error is a CommandUnsuccessfulError. +func IsUnsuccessful(err error) bool { + _, ok := err.(*CommandUnsuccessfulError) + return ok +} + +// CommandUnsuccessfulError is returned by RunCommand for unsuccessful +// commands. This type is exported so that you can ignore this error if you +// expect your command(s) to fail. +type CommandUnsuccessfulError struct { + command string + cr CommandResult +} + +// Error implements error. +func (e *CommandUnsuccessfulError) Error() string { + return fmt.Sprintf("command %q unsuccessful: %v", e.command, e.cr.Error) +} + +// RunCommand makes i3 run the specified command. +// +// Error is non-nil if any CommandResult.Success is not true. See IsUnsuccessful +// if you send commands which are expected to fail. +// +// RunCommand is supported in i3 ≥ v4.0 (2011-07-31). +func RunCommand(command string) ([]CommandResult, error) { + reply, err := roundTrip(messageTypeRunCommand, []byte(command)) + if err != nil { + return []CommandResult{}, err + } + + var crs []CommandResult + err = json.Unmarshal(reply.Payload, &crs) + if err == nil { + for _, cr := range crs { + if !cr.Success { + return crs, &CommandUnsuccessfulError{ + command: command, + cr: cr, + } + } + } + } + return crs, err +} diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..bb506a5 --- /dev/null +++ b/common_test.go @@ -0,0 +1,128 @@ +package i3 + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" +) + +func displayLikelyAvailable(display int) bool { + // The path to this lock is hard-coded to /tmp in the Xorg source code, at + // least in xorg-server-1.19.3. If the path ever changes, that’s no big + // deal. We’ll fall through to starting Xvfb and having Xvfb fail, which is + // only a performance hit, no failure. + b, err := ioutil.ReadFile(fmt.Sprintf("/tmp/.X%d-lock", display)) + if err != nil { + if os.IsNotExist(err) { + return true + } + // Maybe a starting process is just replacing the file? The display + // is likely not available. + return false + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil { + // No pid inside the lock file, so Xvfb will remove the file. + return true + } + + return !pidValid(pid) +} + +func launchI3(ctx context.Context, DISPLAY, I3SOCK string) (cleanup func(), _ error) { + abs, err := filepath.Abs("testdata/i3.config") + if err != nil { + return nil, err + } + wm := exec.CommandContext(ctx, "i3", "-c", abs, "-d", "all", fmt.Sprintf("--shmlog-size=%d", 5*1024*1024)) + wm.Env = []string{ + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + if I3SOCK != "" { + wm.Env = append(wm.Env, "I3SOCK="+I3SOCK) + } + wm.Stderr = os.Stderr + if err := wm.Start(); err != nil { + return nil, err + } + return func() { wm.Process.Kill() }, nil +} + +var signalMu sync.Mutex + +func launchXvfb(ctx context.Context) (xvfb *exec.Cmd, DISPLAY string, _ error) { + // Only one goroutine can wait for Xvfb to start at any point in time, as + // signal handlers are global (per-process, not per-goroutine). + signalMu.Lock() + defer signalMu.Unlock() + + var lastErr error + display := 0 // :0 is usually an active session + for attempt := 0; attempt < 100; attempt++ { + display++ + if !displayLikelyAvailable(display) { + continue + } + // display likely available, try to start Xvfb + DISPLAY := fmt.Sprintf(":%d", display) + // Indicate we implement Xvfb’s readiness notification mechanism. + // + // We ignore SIGUSR1 in a shell wrapper process as there is currently no + // way to ignore signals in a child process, other than ignoring it in + // the parent (using signal.Ignore), which is prone to race conditions + // for this particular use-case: + // https://github.com/golang/go/issues/20479#issuecomment-303791827 + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGUSR1) + xvfb := exec.CommandContext(ctx, + "sh", + "-c", + "trap '' USR1 && exec Xvfb "+DISPLAY+" -screen 0 1280x800x24") + if attempt == 99 { // last attempt + xvfb.Stderr = os.Stderr + } + if lastErr = xvfb.Start(); lastErr != nil { + continue + } + + // The buffer of 1 allows the Wait() goroutine to return. + status := make(chan error, 1) + go func() { + defer signal.Stop(ch) + for range ch { + status <- nil // success + return + } + }() + go func() { + defer func() { + signal.Stop(ch) + close(ch) // avoid leaking the other goroutine + }() + ps, err := xvfb.Process.Wait() + if err != nil { + status <- err + return + } + if ps.Exited() { + status <- fmt.Errorf("Xvfb exited: %v", ps) + return + } + status <- fmt.Errorf("BUG: Wait returned, but !ps.Exited()") + }() + if lastErr = <-status; lastErr == nil { + return xvfb, DISPLAY, nil // Xvfb ready + } + } + return nil, "", lastErr +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..732d7f3 --- /dev/null +++ b/config.go @@ -0,0 +1,37 @@ +package i3 + +import "encoding/json" + +// IncludedConfig represents a single file that i3 has read, either because the +// file is the main config file, or because the file is included. +// +// IncludedConfig is supported in i3 ≥ v4.20 (2021-10-19). +type IncludedConfig struct { + Path string `json:"path"` + RawContents string `json:"raw_contents"` + VariableReplacedContents string `json:"variable_replaced_contents"` +} + +// Config contains details about the configuration file. +// +// See https://i3wm.org/docs/ipc.html#_config_reply for more details. +type Config struct { + Config string `json:"config"` + + // The IncludedConfigs field was added in i3 v4.20 (2021-10-19). + IncludedConfigs []IncludedConfig `json:"included_configs"` +} + +// GetConfig returns i3’s in-memory copy of the configuration file contents. +// +// GetConfig is supported in i3 ≥ v4.14 (2017-09-04). +func GetConfig() (Config, error) { + reply, err := roundTrip(messageTypeGetConfig, nil) + if err != nil { + return Config{}, err + } + + var cfg Config + err = json.Unmarshal(reply.Payload, &cfg) + return cfg, err +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ba77fc2 --- /dev/null +++ b/doc.go @@ -0,0 +1,22 @@ +// Package i3 provides a convenient interface to the i3 window manager. +// +// Its function and type names don’t stutter, and all functions and methods are +// safe for concurrent use (except where otherwise noted). The package does not +// import "unsafe" and hence should be widely applicable. +// +// UNIX socket connections to i3 are transparently managed by the package. Upon +// any read/write errors on a UNIX socket, the package transparently retries for +// up to 10 seconds, but only as long as the i3 process keeps running. +// +// The package is published in versioned releases, where the major and minor +// version are identical to the i3 release the package is compatible with +// (e.g. 4.14 implements the entire documented IPC interface of i3 4.14). +// +// This package will only ever receive additions, so versioning should only be +// relevant to you if you are interested in a recently-introduced IPC feature. +// +// Message type functions and event types are annotated with the i3 version in +// which they were introduced. Under the covers, they use AtLeast, so they +// return a helpful error message at runtime if the running i3 version is too +// old. +package i3 diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..6d332ad --- /dev/null +++ b/example_test.go @@ -0,0 +1,55 @@ +package i3_test + +import ( + "fmt" + "log" + "strings" + + "go.i3wm.org/i3/v4" +) + +func ExampleIsUnsuccessful() { + cr, err := i3.RunCommand("norp") + // “norp” is not implemented, so this command is expected to fail. + if err != nil && !i3.IsUnsuccessful(err) { + log.Fatal(err) + } + log.Printf("error for norp: %v", cr[0].Error) +} + +func ExampleSubscribe() { + recv := i3.Subscribe(i3.WindowEventType) + for recv.Next() { + ev := recv.Event().(*i3.WindowEvent) + log.Printf("change: %s", ev.Change) + } + log.Fatal(recv.Close()) +} + +func ExampleGetTree() { + // Focus or start Google Chrome on the focused workspace. + + tree, err := i3.GetTree() + if err != nil { + log.Fatal(err) + } + + ws := tree.Root.FindFocused(func(n *i3.Node) bool { + return n.Type == i3.WorkspaceNode + }) + if ws == nil { + log.Fatalf("could not locate workspace") + } + + chrome := ws.FindChild(func(n *i3.Node) bool { + return strings.HasSuffix(n.Name, "- Google Chrome") + }) + if chrome != nil { + _, err = i3.RunCommand(fmt.Sprintf(`[con_id="%d"] focus`, chrome.ID)) + } else { + _, err = i3.RunCommand(`exec google-chrome`) + } + if err != nil { + log.Fatal(err) + } +} diff --git a/getpid.go b/getpid.go new file mode 100644 index 0000000..4aa1974 --- /dev/null +++ b/getpid.go @@ -0,0 +1,44 @@ +package i3 + +import ( + "sync" + + "github.com/BurntSushi/xgbutil" + "github.com/BurntSushi/xgbutil/xprop" +) + +func i3Pid() int { + xu, err := xgbutil.NewConn() + if err != nil { + return -1 // X session terminated + } + defer xu.Conn().Close() + reply, err := xprop.GetProperty(xu, xu.RootWin(), "I3_PID") + if err != nil { + return -1 // I3_PID no longer present (X session replaced?) + } + num, err := xprop.PropValNum(reply, err) + if err != nil { + return -1 + } + return int(num) +} + +var lastPid struct { + sync.Mutex + pid int +} + +// IsRunningHook provides a method to override the method which detects if i3 is running or not +var IsRunningHook = func() bool { + lastPid.Lock() + defer lastPid.Unlock() + if !wasRestart || lastPid.pid == 0 { + lastPid.pid = i3Pid() + } + return pidValid(lastPid.pid) +} + +func i3Running() bool { + return IsRunningHook() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..03844ac --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module git.milar.in/milarin/go-i3 + +require ( + github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc + github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 + github.com/google/go-cmp v0.2.0 + golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f +) + +require golang.org/x/net v0.17.0 // indirect + +go 1.19 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b782bdb --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/golden_test.go b/golden_test.go new file mode 100644 index 0000000..2dddc1c --- /dev/null +++ b/golden_test.go @@ -0,0 +1,299 @@ +package i3 + +import ( + "context" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestGoldensSubprocess runs in a process which has been started with +// DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. +func TestGoldensSubprocess(t *testing.T) { + if os.Getenv("GO_WANT_XVFB") != "1" { + t.Skip("parent process") + } + + if _, err := RunCommand("open; mark foo"); err != nil { + t.Fatal(err) + } + + t.Run("GetVersion", func(t *testing.T) { + t.Parallel() + got, err := GetVersion() + if err != nil { + t.Fatal(err) + } + got.HumanReadable = "" // too brittle to compare + got.Patch = 0 // the IPC interface does not change across patch releases + abs, err := filepath.Abs("testdata/i3.config") + if err != nil { + t.Fatal(err) + } + want := Version{ + Major: 4, + Minor: 22, + Patch: 0, + LoadedConfigFileName: abs, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetVersion reply: (-want +got)\n%s", diff) + } + }) + + t.Run("AtLeast", func(t *testing.T) { + t.Parallel() + if err := AtLeast(4, 14); err != nil { + t.Errorf("AtLeast(4, 14) unexpectedly returned an error: %v", err) + } + if err := AtLeast(4, 0); err != nil { + t.Errorf("AtLeast(4, 0) unexpectedly returned an error: %v", err) + } + if err := AtLeast(4, 999); err == nil { + t.Errorf("AtLeast(4, 999) unexpectedly did not return an error") + } + }) + + t.Run("GetBarIDs", func(t *testing.T) { + t.Parallel() + got, err := GetBarIDs() + if err != nil { + t.Fatal(err) + } + want := []string{"bar-0"} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetBarIDs reply: (-want +got)\n%s", diff) + } + }) + + t.Run("GetBarConfig", func(t *testing.T) { + t.Parallel() + got, err := GetBarConfig("bar-0") + if err != nil { + t.Fatal(err) + } + want := BarConfig{ + ID: "bar-0", + Mode: "dock", + Position: "bottom", + StatusCommand: "i3status", + Font: "fixed", + WorkspaceButtons: true, + BindingModeIndicator: true, + Colors: BarConfigColors{ + Background: "#000000", + Statusline: "#ffffff", + Separator: "#666666", + FocusedBackground: "#000000", + FocusedStatusline: "#ffffff", + FocusedSeparator: "#666666", + FocusedWorkspaceText: "#4c7899", + FocusedWorkspaceBackground: "#285577", + FocusedWorkspaceBorder: "#ffffff", + ActiveWorkspaceText: "#333333", + ActiveWorkspaceBackground: "#5f676a", + ActiveWorkspaceBorder: "#ffffff", + InactiveWorkspaceText: "#333333", + InactiveWorkspaceBackground: "#222222", + InactiveWorkspaceBorder: "#888888", + UrgentWorkspaceText: "#2f343a", + UrgentWorkspaceBackground: "#900000", + UrgentWorkspaceBorder: "#ffffff", + BindingModeText: "#2f343a", + BindingModeBackground: "#900000", + BindingModeBorder: "#ffffff", + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetBarConfig reply: (-want +got)\n%s", diff) + } + }) + + t.Run("GetBindingModes", func(t *testing.T) { + t.Parallel() + got, err := GetBindingModes() + if err != nil { + t.Fatal(err) + } + want := []string{"default"} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetBindingModes reply: (-want +got)\n%s", diff) + } + }) + + t.Run("GetMarks", func(t *testing.T) { + t.Parallel() + got, err := GetMarks() + if err != nil { + t.Fatal(err) + } + want := []string{"foo"} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetMarks reply: (-want +got)\n%s", diff) + } + }) + + t.Run("GetOutputs", func(t *testing.T) { + t.Parallel() + got, err := GetOutputs() + if err != nil { + t.Fatal(err) + } + want := []Output{ + { + Name: "xroot-0", + Rect: Rect{Width: 1280, Height: 800}, + }, + { + Name: "screen", + Active: true, + CurrentWorkspace: "1", + Rect: Rect{Width: 1280, Height: 800}, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetOutputs reply: (-want +got)\n%s", diff) + } + }) + + t.Run("GetWorkspaces", func(t *testing.T) { + t.Parallel() + got, err := GetWorkspaces() + if err != nil { + t.Fatal(err) + } + want := []Workspace{ + { + Num: 1, + Name: "1", + Visible: true, + Focused: true, + Rect: Rect{Width: 1280, Height: 800}, + Output: "screen", + }, + } + cmpopts := []cmp.Option{ + cmp.FilterPath( + func(p cmp.Path) bool { + return p.Last().String() == ".ID" + }, + cmp.Ignore()), + } + if diff := cmp.Diff(want, got, cmpopts...); diff != "" { + t.Fatalf("unexpected GetWorkspaces reply: (-want +got)\n%s", diff) + } + }) + + t.Run("RunCommand", func(t *testing.T) { + t.Parallel() + got, err := RunCommand("norp") + if err != nil && !IsUnsuccessful(err) { + t.Fatal(err) + } + if !IsUnsuccessful(err) { + t.Fatalf("command unexpectedly succeeded") + } + if len(got) != 1 { + t.Fatalf("expected precisely one reply, got %+v", got) + } + if got, want := got[0].Success, false; got != want { + t.Errorf("CommandResult.Success: got %v, want %v", got, want) + } + if want := "Expected one of these tokens:"; !strings.HasPrefix(got[0].Error, want) { + t.Errorf("CommandResult.Error: unexpected error: got %q, want prefix %q", got[0].Error, want) + } + }) + + t.Run("GetConfig", func(t *testing.T) { + t.Parallel() + got, err := GetConfig() + if err != nil { + t.Fatal(err) + } + configBytes, err := ioutil.ReadFile("testdata/i3.config") + if err != nil { + t.Fatal(err) + } + configPath, err := filepath.Abs("testdata/i3.config") + if err != nil { + t.Fatal(err) + } + want := Config{ + Config: string(configBytes), + IncludedConfigs: []IncludedConfig{ + { + Path: configPath, + RawContents: string(configBytes), + // Our testdata configuration contains no variables, + // so this field contains configBytes as-is. + VariableReplacedContents: string(configBytes), + }, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected GetConfig reply: (-want +got)\n%s", diff) + } + }) + + t.Run("GetTree", func(t *testing.T) { + t.Parallel() + got, err := GetTree() + if err != nil { + t.Fatal(err) + } + + // Basic sanity checks: + if got.Root == nil { + t.Fatalf("tree.Root unexpectedly is nil") + } + + if got, want := got.Root.Name, "root"; got != want { + t.Fatalf("unexpected tree root name: got %q, want %q", got, want) + } + + // Exercise FindFocused to locate at least one workspace. + if node := got.Root.FindFocused(func(n *Node) bool { return n.Type == WorkspaceNode }); node == nil { + t.Fatalf("unexpectedly could not find any workspace node in GetTree reply") + } + + // Exercise FindChild to locate at least one workspace. + if node := got.Root.FindChild(func(n *Node) bool { return n.Type == WorkspaceNode }); node == nil { + t.Fatalf("unexpectedly could not find any workspace node in GetTree reply") + } + }) +} + +func TestGoldens(t *testing.T) { + t.Parallel() + + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + _, DISPLAY, err := launchXvfb(ctx) + if err != nil { + t.Fatal(err) + } + + cleanup, err := launchI3(ctx, DISPLAY, "") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cmd := exec.Command(os.Args[0], "-test.run=TestGoldensSubprocess", "-test.v") + cmd.Env = []string{ + "GO_WANT_XVFB=1", + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err.Error()) + } +} diff --git a/marks.go b/marks.go new file mode 100644 index 0000000..8b38f66 --- /dev/null +++ b/marks.go @@ -0,0 +1,17 @@ +package i3 + +import "encoding/json" + +// GetMarks returns the names of all currently set marks. +// +// GetMarks is supported in i3 ≥ v4.1 (2011-11-11). +func GetMarks() ([]string, error) { + reply, err := roundTrip(messageTypeGetMarks, nil) + if err != nil { + return nil, err + } + + var marks []string + err = json.Unmarshal(reply.Payload, &marks) + return marks, err +} diff --git a/outputs.go b/outputs.go new file mode 100644 index 0000000..0875a06 --- /dev/null +++ b/outputs.go @@ -0,0 +1,28 @@ +package i3 + +import "encoding/json" + +// Output describes an i3 output. +// +// See https://i3wm.org/docs/ipc.html#_outputs_reply for more details. +type Output struct { + Name string `json:"name"` + Active bool `json:"active"` + Primary bool `json:"primary"` + CurrentWorkspace string `json:"current_workspace"` + Rect Rect `json:"rect"` +} + +// GetOutputs returns i3’s current outputs. +// +// GetOutputs is supported in i3 ≥ v4.0 (2011-07-31). +func GetOutputs() ([]Output, error) { + reply, err := roundTrip(messageTypeGetOutputs, nil) + if err != nil { + return nil, err + } + + var outputs []Output + err = json.Unmarshal(reply.Payload, &outputs) + return outputs, err +} diff --git a/restart_test.go b/restart_test.go new file mode 100644 index 0000000..113c9b5 --- /dev/null +++ b/restart_test.go @@ -0,0 +1,114 @@ +package i3 + +import ( + "context" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestRestartSubprocess runs in a process which has been started with +// DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. +func TestRestartSubprocess(t *testing.T) { + if os.Getenv("GO_WANT_XVFB") != "1" { + t.Skip("parent process") + } + + // received is buffered so that we can blockingly read on tick. + received := make(chan *ShutdownEvent, 1) + tick := make(chan *TickEvent) + fatal := make(chan bool) + go func() { + defer close(tick) + defer close(received) + defer close(fatal) + recv := Subscribe(ShutdownEventType, TickEventType) + defer recv.Close() + log.Printf("reading events") + for recv.Next() { + log.Printf("received: %#v", recv.Event()) + switch ev := recv.Event().(type) { + case *ShutdownEvent: + received <- ev + case *TickEvent: + tick <- ev + } + } + log.Printf("done reading events") + fatal <- true + }() + + log.Printf("read initial tick") + <-tick // Wait until the subscription is ready + log.Printf("restart") + if err := Restart(); err != nil { + t.Fatal(err) + } + + // Restarting i3 triggered a close of the connection, i.e. also a new + // subscribe and initial tick event: + log.Printf("read next initial tick") + ev := <-tick + if !ev.First { + t.Fatalf("expected first tick after restart, got %#v instead", ev) + } + + if _, err := SendTick(""); err != nil { + t.Fatal(err) + } + log.Printf("read tick") + <-tick // Wait until tick was received + log.Printf("read received") + <-received // Verify shutdown event was received + log.Printf("getversion") + if _, err := GetVersion(); err != nil { + t.Fatal(err) + } + + select { + case _ = <-fatal: + t.Fatal("Subscribe has been canceled by restart") + default: + + } +} + +func TestRestart(t *testing.T) { + t.Parallel() + + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + _, DISPLAY, err := launchXvfb(ctx) + if err != nil { + t.Fatal(err) + } + + dir, err := ioutil.TempDir("", "i3restart") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + I3SOCK := filepath.Join(dir, "i3.sock") + + cleanup, err := launchI3(ctx, DISPLAY, I3SOCK) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cmd := exec.Command(os.Args[0], "-test.run=TestRestartSubprocess", "-test.v") + cmd.Env = []string{ + "GO_WANT_XVFB=1", + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err.Error()) + } +} diff --git a/socket.go b/socket.go new file mode 100644 index 0000000..3a76c40 --- /dev/null +++ b/socket.go @@ -0,0 +1,200 @@ +package i3 + +import ( + "encoding/binary" + "fmt" + "io" + "math/rand" + "net" + "os/exec" + "strings" + "sync" + "time" +) + +// If your computer takes more than 10s to restart i3, it must be seriously +// overloaded, in which case we are probably doing you a favor by erroring out. +const reconnectTimeout = 10 * time.Second + +// remote is a singleton containing the socket path and auto-detected byte order +// which i3 is using. It is lazily initialized by getIPCSocket. +var remote struct { + path string + order binary.ByteOrder + mu sync.Mutex +} + +// SocketPathHook Provides a way to override the default socket path lookup mechanism. Overriding this is unsupported. +var SocketPathHook = func() (string, error) { + out, err := exec.Command("i3", "--get-socketpath").CombinedOutput() + if err != nil { + return "", fmt.Errorf("getting i3 socketpath: %v (output: %s)", err, out) + } + return string(out), nil +} + +func getIPCSocket(updateSocketPath bool) (*socket, net.Conn, error) { + remote.mu.Lock() + defer remote.mu.Unlock() + path := remote.path + if (!wasRestart && updateSocketPath) || remote.path == "" { + out, err := SocketPathHook() + if err != nil { + return nil, nil, err + } + path = strings.TrimSpace(string(out)) + } + conn, err := net.Dial("unix", path) + if err != nil { + return nil, nil, err + } + remote.path = path + if remote.order == nil { + remote.order, err = detectByteOrder(conn) + if err != nil { + conn.Close() + return nil, nil, err + } + } + + return &socket{conn: conn, order: remote.order}, conn, err +} + +type messageType uint32 + +const ( + messageTypeRunCommand messageType = iota + messageTypeGetWorkspaces + messageTypeSubscribe + messageTypeGetOutputs + messageTypeGetTree + messageTypeGetMarks + messageTypeGetBarConfig + messageTypeGetVersion + messageTypeGetBindingModes + messageTypeGetConfig + messageTypeSendTick + messageTypeSync + messageTypeGetBindingState +) + +var messageAtLeast = map[messageType]majorMinor{ + messageTypeRunCommand: {4, 0}, + messageTypeGetWorkspaces: {4, 0}, + messageTypeSubscribe: {4, 0}, + messageTypeGetOutputs: {4, 0}, + messageTypeGetTree: {4, 0}, + messageTypeGetMarks: {4, 1}, + messageTypeGetBarConfig: {4, 1}, + messageTypeGetVersion: {4, 3}, + messageTypeGetBindingModes: {4, 13}, + messageTypeGetConfig: {4, 14}, + messageTypeSendTick: {4, 15}, + messageTypeSync: {4, 16}, + messageTypeGetBindingState: {4, 19}, +} + +const ( + messageReplyTypeCommand messageType = iota + messageReplyTypeWorkspaces + messageReplyTypeSubscribe +) + +var magic = [6]byte{'i', '3', '-', 'i', 'p', 'c'} + +type header struct { + Magic [6]byte + Length uint32 + Type messageType +} + +type message struct { + Type messageType + Payload []byte +} + +type socket struct { + conn io.ReadWriter + order binary.ByteOrder +} + +func (s *socket) recvMsg() (message, error) { + if s == nil { + return message{}, fmt.Errorf("not connected") + } + var h header + if err := binary.Read(s.conn, s.order, &h); err != nil { + return message{}, err + } + msg := message{ + Type: h.Type, + Payload: make([]byte, h.Length), + } + _, err := io.ReadFull(s.conn, msg.Payload) + return msg, err +} + +func (s *socket) roundTrip(t messageType, payload []byte) (message, error) { + if s == nil { + return message{}, fmt.Errorf("not connected") + } + + if err := binary.Write(s.conn, s.order, &header{magic, uint32(len(payload)), t}); err != nil { + return message{}, err + } + if len(payload) > 0 { // skip empty Write()s for net.Pipe + _, err := s.conn.Write(payload) + if err != nil { + return message{}, err + } + } + return s.recvMsg() +} + +// defaultSock is a singleton, lazily initialized by roundTrip. All +// request/response messages are sent to i3 via this socket, whereas +// subscriptions use their own connection. +var defaultSock struct { + sock *socket + conn net.Conn + mu sync.Mutex +} + +// roundTrip sends a message to i3 and returns the received result in a +// concurrency-safe fashion. +func roundTrip(t messageType, payload []byte) (message, error) { + // Error out early in case the message type is not yet supported by the + // running i3 version. + if t != messageTypeGetVersion { + if err := AtLeast(messageAtLeast[t].major, messageAtLeast[t].minor); err != nil { + return message{}, err + } + } + + defaultSock.mu.Lock() + defer defaultSock.mu.Unlock() + +Outer: + for { + msg, err := defaultSock.sock.roundTrip(t, payload) + if err == nil { + return msg, nil // happy path: success + } + + // reconnect + start := time.Now() + for time.Since(start) < reconnectTimeout && (defaultSock.sock == nil || i3Running()) { + if defaultSock.sock != nil { + defaultSock.conn.Close() + } + defaultSock.sock, defaultSock.conn, err = getIPCSocket(defaultSock.sock != nil) + if err == nil { + continue Outer + } + + // Reconnect within [10, 20) ms to prevent CPU-starving i3. + time.Sleep(time.Duration(10+rand.Int63n(10)) * time.Millisecond) + } + return msg, err + } +} diff --git a/subscribe.go b/subscribe.go new file mode 100644 index 0000000..970ba4b --- /dev/null +++ b/subscribe.go @@ -0,0 +1,386 @@ +package i3 + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net" + "time" +) + +// Event is an event received from i3. +// +// Type-assert or type-switch on Event to obtain a more specific type. +type Event interface{} + +// WorkspaceEvent contains details about various workspace-related changes. +// +// See https://i3wm.org/docs/ipc.html#_workspace_event for more details. +type WorkspaceEvent struct { + Change string `json:"change"` + Current Node `json:"current"` + Old Node `json:"old"` +} + +// OutputEvent contains details about various output-related changes. +// +// See https://i3wm.org/docs/ipc.html#_output_event for more details. +type OutputEvent struct { + Change string `json:"change"` +} + +// ModeEvent contains details about various mode-related changes. +// +// See https://i3wm.org/docs/ipc.html#_mode_event for more details. +type ModeEvent struct { + Change string `json:"change"` + PangoMarkup bool `json:"pango_markup"` +} + +// WindowEvent contains details about various window-related changes. +// +// See https://i3wm.org/docs/ipc.html#_window_event for more details. +type WindowEvent struct { + Change string `json:"change"` + Container Node `json:"container"` +} + +// BarconfigUpdateEvent contains details about various bar config-related changes. +// +// See https://i3wm.org/docs/ipc.html#_barconfig_update_event for more details. +type BarconfigUpdateEvent BarConfig + +// BindingEvent contains details about various binding-related changes. +// +// See https://i3wm.org/docs/ipc.html#_binding_event for more details. +type BindingEvent struct { + Change string `json:"change"` + Binding struct { + Command string `json:"command"` + EventStateMask []string `json:"event_state_mask"` + InputCode int64 `json:"input_code"` + Symbol string `json:"symbol"` + InputType string `json:"input_type"` + } `json:"binding"` +} + +// ShutdownEvent contains the reason for which the IPC connection is about to be +// shut down. +// +// See https://i3wm.org/docs/ipc.html#_shutdown_event for more details. +type ShutdownEvent struct { + Change string `json:"change"` +} + +// TickEvent contains the payload of the last tick command. +// +// See https://i3wm.org/docs/ipc.html#_tick_event for more details. +type TickEvent struct { + First bool `json:"first"` + Payload string `json:"payload"` +} + +type eventReplyType int + +const ( + eventReplyTypeWorkspace eventReplyType = iota + eventReplyTypeOutput + eventReplyTypeMode + eventReplyTypeWindow + eventReplyTypeBarconfigUpdate + eventReplyTypeBinding + eventReplyTypeShutdown + eventReplyTypeTick +) + +const ( + eventFlagMask = uint32(0x80000000) + eventTypeMask = ^eventFlagMask +) + +// EventReceiver is not safe for concurrent use. +type EventReceiver struct { + types []EventType // for re-subscribing on io.EOF + sock *socket + conn net.Conn + ev Event + err error + reconnect bool + closed bool +} + +// Event returns the most recent event received from i3 by a call to Next. +func (r *EventReceiver) Event() Event { + return r.ev +} + +func (r *EventReceiver) subscribe() error { + var err error + if r.conn != nil { + r.conn.Close() + } + if wasRestart { + r.reconnect = false + } + r.sock, r.conn, err = getIPCSocket(r.reconnect) + r.reconnect = true + if err != nil { + return err + } + payload, err := json.Marshal(r.types) + if err != nil { + return err + } + b, err := r.sock.roundTrip(messageTypeSubscribe, payload) + if err != nil { + return err + } + var reply struct { + Success bool `json:"success"` + } + if err := json.Unmarshal(b.Payload, &reply); err != nil { + return err + } + if !reply.Success { + return fmt.Errorf("could not subscribe, check the i3 log") + } + r.err = nil + return nil +} + +func (r *EventReceiver) next() (Event, error) { + reply, err := r.sock.recvMsg() + if err != nil { + return nil, err + } + if (uint32(reply.Type) & eventFlagMask) == 0 { + return nil, fmt.Errorf("unexpectedly did not receive an event") + } + t := uint32(reply.Type) & eventTypeMask + switch eventReplyType(t) { + case eventReplyTypeWorkspace: + var e WorkspaceEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeOutput: + var e OutputEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeMode: + var e ModeEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeWindow: + var e WindowEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeBarconfigUpdate: + var e BarconfigUpdateEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeBinding: + var e BindingEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeShutdown: + var e ShutdownEvent + return &e, json.Unmarshal(reply.Payload, &e) + + case eventReplyTypeTick: + var e TickEvent + return &e, json.Unmarshal(reply.Payload, &e) + } + return nil, fmt.Errorf("BUG: event reply type %d not implemented yet", t) +} + +// Next advances the EventReceiver to the next event, which will then be +// available through the Event method. It returns false when reaching an +// error. After Next returns false, the Close method will return the first +// error. +// +// Until you call Close, you must call Next in a loop for every EventReceiver +// (usually in a separate goroutine), otherwise i3 will deadlock as soon as the +// UNIX socket buffer is full of unprocessed events. +func (r *EventReceiver) Next() bool { +Outer: + for r.err == nil { + r.ev, r.err = r.next() + if r.err == nil { + return true // happy path + } + + if r.closed { + return false + } + + // reconnect + start := time.Now() + for time.Since(start) < reconnectTimeout && (r.sock == nil || i3Running()) { + if err := r.subscribe(); err == nil { + continue Outer + } else { + r.err = err + } + + // Reconnect within [10, 20) ms to prevent CPU-starving i3. + time.Sleep(time.Duration(10+rand.Int63n(10)) * time.Millisecond) + } + } + return r.err == nil +} + +// Close closes the connection to i3. If you don’t ever call Close, you must +// consume events via Next to prevent i3 from deadlocking. +func (r *EventReceiver) Close() error { + r.closed = true + if r.conn != nil { + if r.err == nil { + r.err = r.conn.Close() + } else { + // Retain the original error. + r.conn.Close() + } + r.conn = nil + r.sock = nil + } + return r.err +} + +// EventType indicates the specific kind of event to subscribe to. +type EventType string + +// i3 currently implements the following event types: +const ( + WorkspaceEventType EventType = "workspace" // since 4.0 + OutputEventType EventType = "output" // since 4.0 + ModeEventType EventType = "mode" // since 4.4 + WindowEventType EventType = "window" // since 4.5 + BarconfigUpdateEventType EventType = "barconfig_update" // since 4.6 + BindingEventType EventType = "binding" // since 4.9 + ShutdownEventType EventType = "shutdown" // since 4.14 + TickEventType EventType = "tick" // since 4.15 +) + +type majorMinor struct { + major int64 + minor int64 +} + +var eventAtLeast = map[EventType]majorMinor{ + WorkspaceEventType: {4, 0}, + OutputEventType: {4, 0}, + ModeEventType: {4, 4}, + WindowEventType: {4, 5}, + BarconfigUpdateEventType: {4, 6}, + BindingEventType: {4, 9}, + ShutdownEventType: {4, 14}, + TickEventType: {4, 15}, +} + +// Subscribe returns an EventReceiver for receiving events of the specified +// types from i3. +// +// Unless the ordering of events matters to your use-case, you are encouraged to +// call Subscribe once per event type, so that you can use type assertions +// instead of type switches. +// +// Subscribe is supported in i3 ≥ v4.0 (2011-07-31). +func Subscribe(eventTypes ...EventType) *EventReceiver { + // Error out early in case any requested event type is not yet supported by + // the running i3 version. + for _, t := range eventTypes { + if err := AtLeast(eventAtLeast[t].major, eventAtLeast[t].minor); err != nil { + return &EventReceiver{err: err} + } + } + return &EventReceiver{types: eventTypes} +} + +// restart runs the restart i3 command without entering an infinite loop: as +// RUN_COMMAND with payload "restart" does not result in a reply, we subscribe +// to the shutdown event beforehand (on a dedicated connection), which we can +// receive instead of a reply. +func restart(firstAttempt bool) error { + sock, conn, err := getIPCSocket(!firstAttempt) + if err != nil { + return err + } + defer conn.Close() + payload, err := json.Marshal([]EventType{ShutdownEventType}) + if err != nil { + return err + } + b, err := sock.roundTrip(messageTypeSubscribe, payload) + if err != nil { + return err + } + var sreply struct { + Success bool `json:"success"` + } + if err := json.Unmarshal(b.Payload, &sreply); err != nil { + return err + } + if !sreply.Success { + return fmt.Errorf("could not subscribe, check the i3 log") + } + rreply, err := sock.roundTrip(messageTypeRunCommand, []byte("restart")) + if err != nil { + return err + } + if (uint32(rreply.Type) & eventFlagMask) == 0 { + var crs []CommandResult + err = json.Unmarshal(rreply.Payload, &crs) + if err == nil { + for _, cr := range crs { + if !cr.Success { + return &CommandUnsuccessfulError{ + command: "restart", + cr: cr, + } + } + } + } + return nil // restart command successful + } + t := uint32(rreply.Type) & eventTypeMask + if got, want := eventReplyType(t), eventReplyTypeShutdown; got != want { + return fmt.Errorf("unexpected reply type: got %d, want %d", got, want) + } + return nil // shutdown event received +} + +var wasRestart = false + +// Restart sends the restart command to i3. Sending restart via RunCommand will +// result in a deadlock: since i3 restarts before it sends the reply to the +// restart command, RunCommand will retry the command indefinitely. +// +// Restart is supported in i3 ≥ v4.14 (2017-09-04). +func Restart() error { + if err := AtLeast(eventAtLeast[ShutdownEventType].major, eventAtLeast[ShutdownEventType].minor); err != nil { + return err + } + + if AtLeast(4, 17) == nil { + _, err := roundTrip(messageTypeRunCommand, []byte("restart")) + return err + } + + log.Println("preventing any further X11 connections to work around issue #3") + wasRestart = true + + var ( + firstAttempt = true + start = time.Now() + lastErr error + ) + for time.Since(start) < reconnectTimeout && (firstAttempt || i3Running()) { + lastErr = restart(firstAttempt) + if lastErr == nil { + return nil // success + } + firstAttempt = false + } + return lastErr +} diff --git a/subscribe_test.go b/subscribe_test.go new file mode 100644 index 0000000..cb2adc7 --- /dev/null +++ b/subscribe_test.go @@ -0,0 +1,186 @@ +package i3 + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "testing" + "time" + + "golang.org/x/sync/errgroup" +) + +// TestSubscribeSubprocess runs in a process which has been started with +// DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. +func TestSubscribeSubprocess(t *testing.T) { + if os.Getenv("GO_WANT_XVFB") != "1" { + t.Skip("parent process") + } + + // TODO(https://github.com/i3/i3/issues/2988): as soon as we are targeting + // i3 4.15, use SendTick to eliminate race conditions in this test. + + t.Run("subscribe", func(t *testing.T) { + var eg errgroup.Group + ws := Subscribe(WorkspaceEventType) + received := make(chan Event) + eg.Go(func() error { + defer close(received) + if ws.Next() { + received <- ws.Event() + } + return ws.Close() + }) + // As we can’t know when EventReceiver.Next() actually subscribes, we + // just continuously switch workspaces. + ctx, canc := context.WithCancel(context.Background()) + defer canc() + go func() { + cnt := 2 + for ctx.Err() == nil { + RunCommand(fmt.Sprintf("workspace %d", cnt)) + cnt++ + time.Sleep(10 * time.Millisecond) + } + }() + select { + case <-received: + case <-time.After(5 * time.Second): + t.Fatalf("timeout waiting for an event from i3") + } + if err := eg.Wait(); err != nil { + t.Fatal(err) + } + }) + + t.Run("subscribeParallel", func(t *testing.T) { + var mu sync.Mutex + received := make(map[string]int) + + recv1 := Subscribe(WorkspaceEventType) + go func() { + for recv1.Next() { + ev := recv1.Event().(*WorkspaceEvent) + if ev.Change == "init" { + mu.Lock() + received[ev.Current.Name]++ + mu.Unlock() + } + } + }() + + recv2 := Subscribe(WorkspaceEventType) + go func() { + for recv2.Next() { + ev := recv2.Event().(*WorkspaceEvent) + if ev.Change == "init" { + mu.Lock() + received[ev.Current.Name]++ + mu.Unlock() + } + } + }() + + cnt := 2 + start := time.Now() + for time.Since(start) < 5*time.Second { + mu.Lock() + done := received[fmt.Sprintf("%d", cnt-1)] == 2 + mu.Unlock() + if done { + return // success + } + if _, err := RunCommand(fmt.Sprintf("workspace %d", cnt)); err != nil { + t.Fatal(err) + } + cnt++ + time.Sleep(10 * time.Millisecond) + } + }) + + t.Run("subscribeMultiple", func(t *testing.T) { + var eg errgroup.Group + ws := Subscribe(WorkspaceEventType, ModeEventType) + received := make(chan struct{}) + eg.Go(func() error { + defer close(received) + defer ws.Close() + seen := map[EventType]bool{ + WorkspaceEventType: false, + ModeEventType: false, + } + Outer: + for ws.Next() { + switch ws.Event().(type) { + case *WorkspaceEvent: + seen[WorkspaceEventType] = true + case *ModeEvent: + seen[ModeEventType] = true + } + + for _, seen := range seen { + if !seen { + continue Outer + } + } + + return nil + } + return ws.Close() + }) + // As we can’t know when EventReceiver.Next() actually subscribes, we + // just continuously switch workspaces and modes. + ctx, canc := context.WithCancel(context.Background()) + defer canc() + go func() { + modes := []string{"default", "conf"} + cnt := 2 + for ctx.Err() == nil { + RunCommand(fmt.Sprintf("workspace %d", cnt)) + cnt++ + RunCommand(fmt.Sprintf("mode %s", modes[cnt%len(modes)])) + time.Sleep(10 * time.Millisecond) + } + }() + select { + case <-received: + case <-time.After(5 * time.Second): + t.Fatalf("timeout waiting for an event from i3") + } + if err := eg.Wait(); err != nil { + t.Fatal(err) + } + }) +} + +func TestSubscribe(t *testing.T) { + t.Parallel() + + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + _, DISPLAY, err := launchXvfb(ctx) + if err != nil { + t.Fatal(err) + } + + cleanup, err := launchI3(ctx, DISPLAY, "") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cmd := exec.Command(os.Args[0], "-test.run=TestSubscribeSubprocess", "-test.v") + cmd.Env = []string{ + "GO_WANT_XVFB=1", + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err.Error()) + } +} diff --git a/sync.go b/sync.go new file mode 100644 index 0000000..c3b3e6f --- /dev/null +++ b/sync.go @@ -0,0 +1,32 @@ +package i3 + +import "encoding/json" + +// SyncRequest represents the payload of a Sync request. +type SyncRequest struct { + Window uint32 `json:"window"` // X11 window id + Rnd uint32 `json:"rnd"` // Random value for distinguishing requests +} + +// SyncResult attests the sync command was successful. +type SyncResult struct { + Success bool `json:"success"` +} + +// Sync sends a tick event with the provided payload. +// +// Sync is supported in i3 ≥ v4.16 (2018-11-04). +func Sync(req SyncRequest) (SyncResult, error) { + b, err := json.Marshal(req) + if err != nil { + return SyncResult{}, err + } + reply, err := roundTrip(messageTypeSync, b) + if err != nil { + return SyncResult{}, err + } + + var tr SyncResult + err = json.Unmarshal(reply.Payload, &tr) + return tr, err +} diff --git a/sync_test.go b/sync_test.go new file mode 100644 index 0000000..5439ee7 --- /dev/null +++ b/sync_test.go @@ -0,0 +1,118 @@ +package i3 + +import ( + "context" + "math/rand" + "os" + "os/exec" + "reflect" + "testing" + + "github.com/BurntSushi/xgb/xproto" + "github.com/BurntSushi/xgbutil" +) + +// TestSyncSubprocess runs in a process which has been started with +// DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. +func TestSyncSubprocess(t *testing.T) { + if os.Getenv("GO_WANT_XVFB") != "1" { + t.Skip("parent process") + } + + xu, err := xgbutil.NewConn() + if err != nil { + t.Fatalf("NewConn: %v", err) + } + defer xu.Conn().Close() + + // Create an X11 window + X := xu.Conn() + wid, err := xproto.NewWindowId(X) + if err != nil { + t.Fatal(err) + } + screen := xproto.Setup(X).DefaultScreen(X) + cookie := xproto.CreateWindowChecked( + X, + screen.RootDepth, + wid, + screen.Root, + 0, // x + 0, // y + 1, // width + 1, // height + 0, // border width + xproto.WindowClassInputOutput, + screen.RootVisual, + xproto.CwBackPixel|xproto.CwEventMask, + []uint32{ // values must be in the order defined by the protocol + 0xffffffff, + xproto.EventMaskStructureNotify | + xproto.EventMaskKeyPress | + xproto.EventMaskKeyRelease}) + if err := cookie.Check(); err != nil { + t.Fatal(err) + } + + // Synchronize i3 with that X11 window + rnd := rand.Uint32() + resp, err := Sync(SyncRequest{ + Rnd: rnd, + Window: uint32(wid), + }) + if err != nil { + t.Fatal(err) + } + if got, want := resp.Success, true; got != want { + t.Fatalf("SyncResult.Success: got %v, want %v", got, want) + } + + for { + ev, xerr := X.WaitForEvent() + if xerr != nil { + t.Fatalf("WaitEvent: got X11 error %v", xerr) + } + cm, ok := ev.(xproto.ClientMessageEvent) + if !ok { + t.Logf("ignoring non-ClientMessage %v", ev) + continue + } + if got, want := cm.Window, wid; got != want { + t.Errorf("sync ClientMessage.Window: got %v, want %v", got, want) + } + if got, want := cm.Data.Data32[:2], []uint32{uint32(wid), rnd}; !reflect.DeepEqual(got, want) { + t.Errorf("sync ClientMessage.Data: got %x, want %x", got, want) + } + break + } +} + +func TestSync(t *testing.T) { + t.Parallel() + + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + _, DISPLAY, err := launchXvfb(ctx) + if err != nil { + t.Fatal(err) + } + + cleanup, err := launchI3(ctx, DISPLAY, "") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cmd := exec.Command(os.Args[0], "-test.run=TestSyncSubprocess", "-test.v") + cmd.Env = []string{ + "GO_WANT_XVFB=1", + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err.Error()) + } +} diff --git a/testdata/i3.config b/testdata/i3.config new file mode 100644 index 0000000..b2a6d45 --- /dev/null +++ b/testdata/i3.config @@ -0,0 +1,13 @@ +# i3 config file (v4) + +# ISO 10646 = Unicode +font -misc-fixed-medium-r-normal--13-120-75-75-C-70-iso10646-1 + +mode "conf" { +} + +bar { + # disable i3bar + i3bar_command : + status_command i3status +} diff --git a/tick.go b/tick.go new file mode 100644 index 0000000..334f6ce --- /dev/null +++ b/tick.go @@ -0,0 +1,22 @@ +package i3 + +import "encoding/json" + +// TickResult attests the tick command was successful. +type TickResult struct { + Success bool `json:"success"` +} + +// SendTick sends a tick event with the provided payload. +// +// SendTick is supported in i3 ≥ v4.15 (2018-03-10). +func SendTick(command string) (TickResult, error) { + reply, err := roundTrip(messageTypeSendTick, []byte(command)) + if err != nil { + return TickResult{}, err + } + + var tr TickResult + err = json.Unmarshal(reply.Payload, &tr) + return tr, err +} diff --git a/travis/Dockerfile b/travis/Dockerfile new file mode 100644 index 0000000..60c4643 --- /dev/null +++ b/travis/Dockerfile @@ -0,0 +1,18 @@ +# vim:ft=Dockerfile +FROM debian:sid + +RUN echo force-unsafe-io > /etc/dpkg/dpkg.cfg.d/docker-apt-speedup +# Paper over occasional network flakiness of some mirrors. +RUN echo 'APT::Acquire::Retries "5";' > /etc/apt/apt.conf.d/80retry + +# NOTE: I tried exclusively using gce_debian_mirror.storage.googleapis.com +# instead of httpredir.debian.org, but the results (Fetched 123 MB in 36s (3357 +# kB/s)) are not any better than httpredir.debian.org (Fetched 123 MB in 34s +# (3608 kB/s)). Hence, let’s stick with httpredir.debian.org (default) for now. + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + i3-wm=4.22-2 xvfb strace && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src diff --git a/tree.go b/tree.go new file mode 100644 index 0000000..e513f05 --- /dev/null +++ b/tree.go @@ -0,0 +1,194 @@ +package i3 + +import ( + "encoding/json" +) + +// NodeType indicates the specific kind of Node. +type NodeType string + +// i3 currently implements the following node types: +const ( + Root NodeType = "root" + OutputNode NodeType = "output" + Con NodeType = "con" + FloatingCon NodeType = "floating_con" + WorkspaceNode NodeType = "workspace" + DockareaNode NodeType = "dockarea" +) + +// Layout indicates the layout of a Node. +type Layout string + +// i3 currently implements the following layouts: +const ( + SplitH Layout = "splith" + SplitV Layout = "splitv" + Stacked Layout = "stacked" + Tabbed Layout = "tabbed" + DockareaLayout Layout = "dockarea" + OutputLayout Layout = "output" +) + +// BorderStyle indicates the border style of a node. +type BorderStyle string + +// i3 currently implements the following border styles: +const ( + NormalBorder BorderStyle = "normal" + NoBorder BorderStyle = "none" + PixelBorder BorderStyle = "pixel" +) + +// Rect is a rectangle, used for various dimensions in Node, for example. +type Rect struct { + X int64 `json:"x"` + Y int64 `json:"y"` + Width int64 `json:"width"` + Height int64 `json:"height"` +} + +// WindowProperties correspond to X11 window properties +// +// See https://build.i3wm.org/docs/ipc.html#_tree_reply +type WindowProperties struct { + Title string `json:"title"` + Instance string `json:"instance"` + Class string `json:"class"` + Role string `json:"window_role"` + Transient NodeID `json:"transient_for"` +} + +// NodeID is an i3-internal ID for the node, which can be used to identify +// containers within the IPC interface. +type NodeID int64 + +// FullscreenMode indicates whether the container is fullscreened, and relative +// to where (its output, or globally). Note that all workspaces are considered +// fullscreened on their respective output. +type FullscreenMode int64 + +const ( + FullscreenNone FullscreenMode = 0 + FullscreenOutput FullscreenMode = 1 + FullscreenGlobal FullscreenMode = 2 +) + +// FloatingType indicates the floating type of Node. +type FloatingType string + +// i3 currently implements the following node types: +const ( + AutoOff FloatingType = "auto_off" + AutoOn FloatingType = "auto_on" + UserOn FloatingType = "user_on" + UserOff FloatingType = "user_off" +) + +// Node is a node in a Tree. +// +// See https://i3wm.org/docs/ipc.html#_tree_reply for more details. +type Node struct { + ID NodeID `json:"id"` + Name string `json:"name"` // window: title, container: internal 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"` // absolute (= relative to X11 display) + WindowRect Rect `json:"window_rect"` // window, relative to Rect + DecoRect Rect `json:"deco_rect"` // decoration, relative to Rect + Geometry Rect `json:"geometry"` // original window geometry, absolute + Window int64 `json:"window"` // X11 window ID of the client window + WindowProperties WindowProperties `json:"window_properties"` + Urgent bool `json:"urgent"` // urgency hint set + Marks []string `json:"marks"` + Focused bool `json:"focused"` + WindowType string `json:"window_type"` + FullscreenMode FullscreenMode `json:"fullscreen_mode"` + Focus []NodeID `json:"focus"` + Nodes []*Node `json:"nodes"` + FloatingNodes []*Node `json:"floating_nodes"` + Floating FloatingType `json:"floating"` + ScratchpadState string `json:"scratchpad_state"` + AppID string `json:"app_id"` // if talking to Sway: Wayland App ID + Sticky bool `json:"sticky"` + Output string `json:"output"` +} + +// FindChild returns the first Node matching predicate, using pre-order +// depth-first search. +func (n *Node) FindChild(predicate func(*Node) bool) *Node { + if predicate(n) { + return n + } + for _, c := range n.Nodes { + if con := c.FindChild(predicate); con != nil { + return con + } + } + for _, c := range n.FloatingNodes { + if con := c.FindChild(predicate); con != nil { + return con + } + } + return nil +} + +// FindFocused returns the first Node matching predicate from the sub-tree of +// directly and indirectly focused containers. +// +// As an example, consider this layout tree (simplified): +// +// root +// │ +// HDMI2 +// ╱ ╲ +// … workspace 1 +// ╱ ╲ +// XTerm Firefox +// +// In this example, if Firefox is focused, FindFocused will return the first +// container matching predicate of root, HDMI2, workspace 1, Firefox (in this +// order). +func (n *Node) FindFocused(predicate func(*Node) bool) *Node { + if predicate(n) { + return n + } + if len(n.Focus) == 0 { + return nil + } + first := n.Focus[0] + for _, c := range n.Nodes { + if c.ID == first { + return c.FindFocused(predicate) + } + } + for _, c := range n.FloatingNodes { + if c.ID == first { + return c.FindFocused(predicate) + } + } + return nil +} + +// Tree is an i3 layout tree, starting with Root. +type Tree struct { + // Root is the root node of the layout tree. + Root *Node +} + +// GetTree returns i3’s layout tree. +// +// GetTree is supported in i3 ≥ v4.0 (2011-07-31). +func GetTree() (Tree, error) { + reply, err := roundTrip(messageTypeGetTree, nil) + if err != nil { + return Tree{}, err + } + + var root Node + err = json.Unmarshal(reply.Payload, &root) + return Tree{Root: &root}, err +} diff --git a/tree_utils.go b/tree_utils.go new file mode 100644 index 0000000..8debf82 --- /dev/null +++ b/tree_utils.go @@ -0,0 +1,27 @@ +package i3 + +import "strings" + +// FindParent method returns the parent node of the current one +// by requesting and traversing the tree +func (child *Node) FindParent() *Node { + tree, err := GetTree() + if err != nil { + return nil + } + parent := tree.Root.FindChild(func(n *Node) bool { + for _, f := range n.Focus { + if f == child.ID { + return true + } + } + return false + }) + + return parent +} + +// IsFloating method returns true if the current node is floating +func (n *Node) IsFloating() bool { + return strings.HasSuffix(string(n.Floating), "_on") +} diff --git a/tree_utils_test.go b/tree_utils_test.go new file mode 100644 index 0000000..c0036e7 --- /dev/null +++ b/tree_utils_test.go @@ -0,0 +1,118 @@ +package i3 + +import ( + "context" + "os" + "os/exec" + "testing" +) + +// TestTreeUtilsSubprocess runs in a process which has been started with +// DISPLAY= pointing to an Xvfb instance with i3 -c testdata/i3.config running. +func TestTreeUtilsSubprocess(t *testing.T) { + if os.Getenv("GO_WANT_XVFB") != "1" { + t.Skip("parent process") + } + + mark_name := "foo" + ws_name := "1:test_space" + + if _, err := RunCommand("rename workspace to " + ws_name); err != nil { + t.Fatal(err) + } + + if _, err := RunCommand("open; mark " + mark_name); err != nil { + t.Fatal(err) + } + + t.Run("FindParent", func(t *testing.T) { + t.Parallel() + got, err := GetTree() + if err != nil { + t.Fatal(err) + } + + node := got.Root.FindFocused(func(n *Node) bool { return n.Focused }) + if node == nil { + t.Fatal("unexpectedly could not find any focused node in GetTree reply") + } + + // Exercise FindParent to locate parent for given node. + parent := node.FindParent() + + if parent == nil { + t.Fatal("no parent found") + } + if parent.Name != ws_name { + t.Fatal("wrong parent found: " + parent.Name) + } + }) + + t.Run("IsFloating", func(t *testing.T) { + // do not run in parallel because 'floating toggle' breaks other tests + + got, err := GetTree() + if err != nil { + t.Fatal(err) + } + + node := got.Root.FindFocused(func(n *Node) bool { return n.Focused }) + if node == nil { + t.Fatal("unexpectedly could not find any focused node in GetTree reply") + } + + if node.IsFloating() == true { + t.Fatal("node is floating") + } + + if _, err := RunCommand("floating toggle"); err != nil { + t.Fatal(err) + } + + got, err = GetTree() + if err != nil { + t.Fatal(err) + } + + node = got.Root.FindFocused(func(n *Node) bool { return n.Focused }) + if node == nil { + t.Fatal("unexpectedly could not find any focused node in GetTree reply") + } + + if node.IsFloating() == false { + t.Fatal("node is not floating") + } + + RunCommand("floating toggle") + }) +} + +func TestTreeUtils(t *testing.T) { + t.Parallel() + + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + _, DISPLAY, err := launchXvfb(ctx) + if err != nil { + t.Fatal(err) + } + + cleanup, err := launchI3(ctx, DISPLAY, "") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cmd := exec.Command(os.Args[0], "-test.run=TestTreeUtilsSubprocess", "-test.v") + cmd.Env = []string{ + "GO_WANT_XVFB=1", + "DISPLAY=" + DISPLAY, + "PATH=" + os.Getenv("PATH"), + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err.Error()) + } +} diff --git a/validpid.go b/validpid.go new file mode 100644 index 0000000..d1611b2 --- /dev/null +++ b/validpid.go @@ -0,0 +1,16 @@ +package i3 + +import "syscall" + +func pidValid(pid int) bool { + // As per kill(2) from POSIX.1-2008, sending signal 0 validates a pid. + if err := syscall.Kill(pid, 0); err != nil { + if err == syscall.EPERM { + // Process still alive (but no permission to signal): + return true + } + // errno is likely ESRCH (process not found). + return false // Process not alive. + } + return true // Process still alive. +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..76aa3a6 --- /dev/null +++ b/version.go @@ -0,0 +1,71 @@ +package i3 + +import ( + "encoding/json" + "fmt" + "log" +) + +// Version describes an i3 version. +// +// See https://i3wm.org/docs/ipc.html#_version_reply for more details. +type Version struct { + Major int64 `json:"major"` + Minor int64 `json:"minor"` + Patch int64 `json:"patch"` + Variant string `json:"variant,omitempty"` + HumanReadable string `json:"human_readable"` + LoadedConfigFileName string `json:"loaded_config_file_name"` +} + +// GetVersion returns i3’s version. +// +// GetVersion is supported in i3 ≥ v4.3 (2012-09-19). +func GetVersion() (Version, error) { + reply, err := roundTrip(messageTypeGetVersion, nil) + if err != nil { + return Version{}, err + } + + var v Version + err = json.Unmarshal(reply.Payload, &v) + return v, err +} + +// version is a lazily-initialized, possibly stale copy of i3’s GET_VERSION +// reply. Access only values which don’t change, e.g. Major, Minor. +var version Version + +// versionWarning is used to only warn a single time when unsupported versions are +// detected. +var versionWarning bool + +// AtLeast returns nil if i3’s major version matches major and i3’s minor +// version is at least minor or newer. Otherwise, it returns an error message +// stating i3 is too old. +func AtLeast(major int64, minor int64) error { + if major == 0 { + return fmt.Errorf("BUG: major == 0 is non-sensical. Is a lookup table entry missing?") + } + if version.Major == 0 { + var err error + version, err = GetVersion() + if err != nil { + return err + } + } + + if version.Variant != "" { + if !versionWarning { + versionWarning = true + log.Printf("non standard i3 payload variant '%s' detected. Ignoring version check. This is fully unsupported.", version.Variant) + } + return nil + } + + if version.Major == major && version.Minor >= minor { + return nil + } + + return fmt.Errorf("i3 version too old: got %d.%d, want ≥ %d.%d", version.Major, version.Minor, major, minor) +} diff --git a/workspaces.go b/workspaces.go new file mode 100644 index 0000000..cbd64cd --- /dev/null +++ b/workspaces.go @@ -0,0 +1,35 @@ +package i3 + +import "encoding/json" + +// WorkspaceID is an i3-internal ID for the node, which can be used to identify +// workspaces within the IPC interface. +type WorkspaceID int64 + +// Workspace describes an i3 workspace. +// +// See https://i3wm.org/docs/ipc.html#_workspaces_reply for more details. +type Workspace struct { + ID WorkspaceID `json:"id"` + Num int64 `json:"num"` + Name string `json:"name"` + Visible bool `json:"visible"` + Focused bool `json:"focused"` + Urgent bool `json:"urgent"` + Rect Rect `json:"rect"` + Output string `json:"output"` +} + +// GetWorkspaces returns i3’s current workspaces. +// +// GetWorkspaces is supported in i3 ≥ v4.0 (2011-07-31). +func GetWorkspaces() ([]Workspace, error) { + reply, err := roundTrip(messageTypeGetWorkspaces, nil) + if err != nil { + return nil, err + } + + var ws []Workspace + err = json.Unmarshal(reply.Payload, &ws) + return ws, err +}