initial commit

This commit is contained in:
milarin 2023-10-22 16:19:10 +02:00
commit 4d73942300
34 changed files with 2812 additions and 0 deletions

56
.github/workflows/go.yml vendored Normal file
View File

@ -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

27
LICENSE Normal file
View File

@ -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.

67
README.md Normal file
View File

@ -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 i3s major and minor
version.
* Consistent and familiar: once familiar with the i3 IPC protocols 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
i3s 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. Nodes 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
```

111
barconfig.go Normal file
View File

@ -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
}

36
bindingmodes.go Normal file
View File

@ -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
}

63
byteorder.go Normal file
View File

@ -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
}

124
byteorder_test.go Normal file
View File

@ -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)
}
})
}
}

62
close_test.go Normal file
View File

@ -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())
}
}

64
command.go Normal file
View File

@ -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
}

128
common_test.go Normal file
View File

@ -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, thats no big
// deal. Well 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 Xvfbs 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
}

37
config.go Normal file
View File

@ -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 i3s 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
}

22
doc.go Normal file
View File

@ -0,0 +1,22 @@
// Package i3 provides a convenient interface to the i3 window manager.
//
// Its function and type names dont 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

55
example_test.go Normal file
View File

@ -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)
}
}

44
getpid.go Normal file
View File

@ -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()
}

12
go.mod Normal file
View File

@ -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

10
go.sum Normal file
View File

@ -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=

299
golden_test.go Normal file
View File

@ -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())
}
}

17
marks.go Normal file
View File

@ -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
}

28
outputs.go Normal file
View File

@ -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 i3s 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
}

114
restart_test.go Normal file
View File

@ -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())
}
}

200
socket.go Normal file
View File

@ -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
}
}

386
subscribe.go Normal file
View File

@ -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 dont 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
}

186
subscribe_test.go Normal file
View File

@ -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 cant 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 cant 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())
}
}

32
sync.go Normal file
View File

@ -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
}

118
sync_test.go Normal file
View File

@ -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())
}
}

13
testdata/i3.config vendored Normal file
View File

@ -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
}

22
tick.go Normal file
View File

@ -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
}

18
travis/Dockerfile Normal file
View File

@ -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, lets 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

194
tree.go Normal file
View File

@ -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 i3s 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
}

27
tree_utils.go Normal file
View File

@ -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")
}

118
tree_utils_test.go Normal file
View File

@ -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())
}
}

16
validpid.go Normal file
View File

@ -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.
}

71
version.go Normal file
View File

@ -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 i3s 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 i3s GET_VERSION
// reply. Access only values which dont 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 i3s major version matches major and i3s 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)
}

35
workspaces.go Normal file
View File

@ -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 i3s 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
}