initial commit
This commit is contained in:
commit
4d73942300
56
.github/workflows/go.yml
vendored
Normal file
56
.github/workflows/go.yml
vendored
Normal 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
27
LICENSE
Normal 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
67
README.md
Normal 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 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
|
||||
```
|
111
barconfig.go
Normal file
111
barconfig.go
Normal 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
36
bindingmodes.go
Normal 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
63
byteorder.go
Normal 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
124
byteorder_test.go
Normal 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
62
close_test.go
Normal 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
64
command.go
Normal 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
128
common_test.go
Normal 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, 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
|
||||
}
|
37
config.go
Normal file
37
config.go
Normal 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 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
|
||||
}
|
22
doc.go
Normal file
22
doc.go
Normal file
@ -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
|
55
example_test.go
Normal file
55
example_test.go
Normal 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
44
getpid.go
Normal 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
12
go.mod
Normal 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
10
go.sum
Normal 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
299
golden_test.go
Normal 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
17
marks.go
Normal 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
28
outputs.go
Normal 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 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
|
||||
}
|
114
restart_test.go
Normal file
114
restart_test.go
Normal 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
200
socket.go
Normal 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
386
subscribe.go
Normal 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 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
|
||||
}
|
186
subscribe_test.go
Normal file
186
subscribe_test.go
Normal 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 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())
|
||||
}
|
||||
}
|
32
sync.go
Normal file
32
sync.go
Normal 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
118
sync_test.go
Normal 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
13
testdata/i3.config
vendored
Normal 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
22
tick.go
Normal 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
18
travis/Dockerfile
Normal 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, 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
|
194
tree.go
Normal file
194
tree.go
Normal 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 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
|
||||
}
|
27
tree_utils.go
Normal file
27
tree_utils.go
Normal 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
118
tree_utils_test.go
Normal 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
16
validpid.go
Normal 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
71
version.go
Normal 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 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)
|
||||
}
|
35
workspaces.go
Normal file
35
workspaces.go
Normal 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 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user