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