Add native i3 desktop announcements
This commit is contained in:
1 parent
49eeae89f4
commit
bf1f155018
22 files changed
+1835
-1
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const queryTimeout = 2 * time.Second
|
||||
|
||||
type connection struct {
|
||||
net.Conn
|
||||
pending []message
|
||||
pendingBytes int
|
||||
}
|
||||
|
||||
// request preserves events interleaved with a reply on the subscribed socket.
|
||||
// One worker owns both reads and comparisons; overflow forces a fresh baseline.
|
||||
func (c *connection) request(kind uint32, body []byte, result any) error {
|
||||
if err := c.SetDeadline(time.Now().Add(queryTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.SetDeadline(time.Time{})
|
||||
if err := writeMessage(c, kind, body); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
m, err := readMessage(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.kind == kind {
|
||||
return json.Unmarshal(m.body, result)
|
||||
}
|
||||
if m.kind&eventBit == 0 {
|
||||
return errors.New("unexpected i3 reply")
|
||||
}
|
||||
if len(c.pending) >= maxPending || c.pendingBytes+len(m.body) > maxPayload {
|
||||
return errors.New("i3 event backlog exceeded")
|
||||
}
|
||||
c.pending = append(c.pending, m)
|
||||
c.pendingBytes += len(m.body)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) queryTree() (node, error) {
|
||||
var root node
|
||||
err := c.request(tree, nil, &root)
|
||||
if err == nil && (root.Type != "root" || root.ID == 0) {
|
||||
err = errors.New("invalid i3 tree")
|
||||
}
|
||||
return root, err
|
||||
}
|
||||
|
||||
func session(ctx context.Context, path string, emit func(Event)) error {
|
||||
socket, err := (&net.Dialer{Timeout: queryTimeout}).DialContext(ctx, "unix", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := &connection{Conn: socket}
|
||||
defer c.Close()
|
||||
// Closing the descriptor interrupts both idle reads and queries immediately.
|
||||
stop := context.AfterFunc(ctx, func() { c.Close() })
|
||||
defer stop()
|
||||
var subscribed struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := c.request(subscribe, []byte(`["workspace","mode","window"]`), &subscribed); err != nil {
|
||||
return err
|
||||
}
|
||||
if !subscribed.Success {
|
||||
return errors.New("i3 subscription rejected")
|
||||
}
|
||||
var spaces []workspace
|
||||
if err := c.request(workspaces, nil, &spaces); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := c.queryTree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var state state
|
||||
state.baseline(root, spaces)
|
||||
// Everything received through the baseline reply belongs to the snapshot.
|
||||
// This also suppresses reconnect snapshots and races during initialization.
|
||||
c.pending = nil
|
||||
c.pendingBytes = 0
|
||||
for ctx.Err() == nil {
|
||||
var m message
|
||||
if len(c.pending) != 0 {
|
||||
m = c.pending[0]
|
||||
c.pendingBytes -= len(m.body)
|
||||
c.pending[0] = message{}
|
||||
c.pending = c.pending[1:]
|
||||
} else {
|
||||
// Idle sessions have no timeout; a partial frame must finish promptly.
|
||||
// Read one byte first so an idle desktop doesn't reconnect periodically.
|
||||
var first [1]byte
|
||||
if _, err := c.Read(first[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.SetReadDeadline(time.Now().Add(queryTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err = readMessage(io.MultiReader(bytes.NewReader(first[:]), c))
|
||||
c.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
events, err := state.apply(m, c.queryTree)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range events {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
emit(event)
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Run rediscovers the active session after loss. All connection/cache state is
|
||||
// local to one session and is discarded on errors. emit must be nonblocking.
|
||||
// discover must verify the current window manager, not just an environment hint.
|
||||
func Run(ctx context.Context, discover func() string, emit func(Event)) {
|
||||
backoff := 250 * time.Millisecond
|
||||
for ctx.Err() == nil {
|
||||
started := time.Now()
|
||||
if path := discover(); path != "" {
|
||||
_ = session(ctx, path, emit)
|
||||
}
|
||||
if time.Since(started) > 10*time.Second {
|
||||
backoff = 250 * time.Millisecond
|
||||
}
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
backoff = min(5*time.Second, backoff*2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func listener(t *testing.T) net.Listener {
|
||||
t.Helper()
|
||||
l, err := net.Listen("unix", filepath.Join(t.TempDir(), "ipc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
return l
|
||||
}
|
||||
func handshake(c net.Conn, initial string) error {
|
||||
for _, response := range []string{`{"success":true}`, `[{"id":2,"name":"A","focused":true}]`, `{"id":1,"type":"root","nodes":[]}`} {
|
||||
m, err := readMessage(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initial != "" {
|
||||
if err := writeMessage(c, modeEvent, []byte(initial)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeMessage(c, m.kind, []byte(response)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestSessionBaselineCancellationAndReconnect(t *testing.T) {
|
||||
l := listener(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
events := make(chan Event, 8)
|
||||
done := make(chan struct{})
|
||||
server := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(done)
|
||||
Run(ctx, func() string { return l.Addr().String() }, func(e Event) { events <- e })
|
||||
}()
|
||||
go func() {
|
||||
for _, name := range []string{"personal", "default"} {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
server <- err
|
||||
return
|
||||
}
|
||||
if err = handshake(c, `{"change":"snapshot"}`); err == nil {
|
||||
err = writeMessage(c, modeEvent, []byte(`{"change":"`+name+`"}`))
|
||||
}
|
||||
if name == "default" && err == nil {
|
||||
var b [1]byte
|
||||
_, _ = c.Read(b[:])
|
||||
}
|
||||
c.Close()
|
||||
if err != nil {
|
||||
server <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
server <- nil
|
||||
}()
|
||||
for _, name := range []string{"personal", "default"} {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Name != name {
|
||||
t.Fatal(e)
|
||||
}
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatal("missing event/reconnect")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("idle read did not cancel")
|
||||
}
|
||||
if err := <-server; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case e := <-events:
|
||||
t.Fatalf("snapshot announced: %+v", e)
|
||||
default:
|
||||
}
|
||||
}
|
||||
func TestSubscriptionFailure(t *testing.T) {
|
||||
l := listener(t)
|
||||
go func() {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
m, err := readMessage(c)
|
||||
if err == nil {
|
||||
_ = writeMessage(c, m.kind, []byte(`{"success":false}`))
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := session(ctx, l.Addr().String(), func(Event) { t.Error("unexpected event") }); err == nil {
|
||||
t.Fatal("accepted rejection")
|
||||
}
|
||||
}
|
||||
func TestUnavailableAndCanceledQuery(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := session(ctx, "/nonexistent/i3", func(Event) {}); err == nil {
|
||||
t.Fatal("unavailable socket accepted")
|
||||
}
|
||||
l := listener(t)
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
close(accepted)
|
||||
var b [4096]byte
|
||||
for {
|
||||
if _, err := c.Read(b[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- session(ctx, l.Addr().String(), func(Event) {}) }()
|
||||
<-accepted
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("query did not cancel")
|
||||
}
|
||||
}
|
||||
func TestInterleavedEventsAndBacklog(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
c := connection{Conn: a}
|
||||
go func() {
|
||||
m, err := readMessage(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = writeMessage(b, modeEvent, []byte(`{"change":"panel"}`))
|
||||
_ = writeMessage(b, m.kind, []byte(`{"success":true}`))
|
||||
}()
|
||||
var result struct{ Success bool }
|
||||
if err := c.request(subscribe, nil, &result); err != nil || !result.Success || len(c.pending) != 1 {
|
||||
t.Fatalf("%+v %v", c.pending, err)
|
||||
}
|
||||
c.pending = make([]message, maxPending)
|
||||
go func() {
|
||||
_, err := readMessage(b)
|
||||
if err == nil {
|
||||
_ = writeMessage(b, modeEvent, []byte(`{}`))
|
||||
}
|
||||
}()
|
||||
if err := c.request(tree, nil, &result); err == nil {
|
||||
t.Fatal("unbounded pending events")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
)
|
||||
|
||||
func TestNativeI3Events(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_I3") != "1" {
|
||||
t.Skip("requires private i3 harness")
|
||||
}
|
||||
path := xorg.I3Socket()
|
||||
if path == "" {
|
||||
t.Fatal("active i3/socket not detected")
|
||||
}
|
||||
// A stale environment hint must not override the verified session property.
|
||||
t.Setenv("I3SOCK", "/nonexistent/stale-socket")
|
||||
if xorg.I3Socket() != path {
|
||||
t.Fatal("stale environment overrode discovery")
|
||||
}
|
||||
query := func(kind uint32, payload string, result any) {
|
||||
t.Helper()
|
||||
c, err := net.DialTimeout("unix", path, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if err = writeMessage(c, kind, []byte(payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := readMessage(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = json.Unmarshal(m.body, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
command := func(text string) {
|
||||
t.Helper()
|
||||
var replies []struct{ Success bool }
|
||||
query(0, text, &replies)
|
||||
for _, r := range replies {
|
||||
if !r.Success {
|
||||
t.Fatalf("i3 rejected %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
getTree := func() node { var root node; query(tree, "", &root); return root }
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
events := make(chan Event, 128)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- session(ctx, path, func(e Event) { events <- e }) }()
|
||||
// Synchronize baseline completion through an actual reported mode transition.
|
||||
timer := time.NewTicker(30 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
ready := false
|
||||
deadline := time.After(4 * time.Second)
|
||||
for !ready {
|
||||
select {
|
||||
case <-timer.C:
|
||||
command(`mode "personal"`)
|
||||
case e := <-events:
|
||||
ready = e.Kind == Mode && e.Name == "personal"
|
||||
case err := <-done:
|
||||
t.Fatal(err)
|
||||
case <-deadline:
|
||||
t.Fatal("backend did not become ready")
|
||||
}
|
||||
}
|
||||
expect := func(kind Kind, name string) Event {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Kind == kind && (name == "" || e.Name == name) {
|
||||
return e
|
||||
}
|
||||
t.Logf("additional event: %+v", e)
|
||||
case err := <-done:
|
||||
t.Fatalf("backend exited: %v", err)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("missing event %d %q", kind, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
quiet := func() {
|
||||
t.Helper()
|
||||
select {
|
||||
case e := <-events:
|
||||
t.Fatalf("unexpected announcement: %+v", e)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
for _, mode := range []string{"ratpoison", "panel", "default"} {
|
||||
command(`mode "` + mode + `"`)
|
||||
expect(Mode, mode)
|
||||
}
|
||||
command(`mode "<b>marked & mode</b>"`)
|
||||
if e := expect(Mode, ""); !e.Markup || e.Name != "<b>marked & mode</b>" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
command(`mode "default"`)
|
||||
expect(Mode, "default")
|
||||
command(`workspace "empty arbitrary & name"`)
|
||||
expect(Workspace, "empty arbitrary & name")
|
||||
command(`rename workspace to "renamed empty"`)
|
||||
expect(Workspace, "renamed empty")
|
||||
terminal := exec.Command("xterm", "-T", "i3 fixture", "-e", "sleep", "60")
|
||||
if err := terminal.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = terminal.Process.Kill(); _ = terminal.Wait() }()
|
||||
var window node
|
||||
var find func(node)
|
||||
find = func(n node) {
|
||||
if n.Name == "i3 fixture" && n.Window != 0 {
|
||||
window = n
|
||||
}
|
||||
for _, list := range [][]node{n.Nodes, n.FloatingNodes} {
|
||||
for _, child := range list {
|
||||
find(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
until := time.Now().Add(4 * time.Second)
|
||||
for window.ID == 0 && time.Now().Before(until) {
|
||||
find(getTree())
|
||||
if window.ID == 0 {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if window.ID == 0 {
|
||||
t.Fatal("fixture window missing")
|
||||
}
|
||||
// A marker event follows window creation/focus, proving those are silent.
|
||||
command(`mode "panel"`)
|
||||
expect(Mode, "panel")
|
||||
quiet()
|
||||
target := fmt.Sprintf("[con_id=%d] ", window.ID)
|
||||
for _, tc := range []struct {
|
||||
command string
|
||||
kind Kind
|
||||
enabled bool
|
||||
}{
|
||||
{"fullscreen enable", Fullscreen, true}, {"fullscreen disable", Fullscreen, false},
|
||||
{"fullscreen enable global", Fullscreen, true}, {"fullscreen disable", Fullscreen, false},
|
||||
{"floating enable", Floating, true}, {"floating disable", Floating, false},
|
||||
} {
|
||||
command(target + tc.command)
|
||||
e := expect(tc.kind, "")
|
||||
if e.Enabled != tc.enabled {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
command(target + `mark "a & mark"`)
|
||||
expect(MarkAdded, "a & mark")
|
||||
command(target + `unmark "a & mark"`)
|
||||
expect(MarkRemoved, "a & mark")
|
||||
command(target + `floating enable`)
|
||||
expect(Floating, "")
|
||||
command(target + `move position center`)
|
||||
quiet()
|
||||
command(target + `floating disable`)
|
||||
expect(Floating, "")
|
||||
command(target + `move container to workspace "destination"`)
|
||||
if e := expect(WorkspaceMove, ""); e.Workspace != "destination" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
// It is now a background window; urgency must identify both contexts once.
|
||||
out, err := exec.Command("xdotool", "set_window", "--urgency", "1", strconv.FormatUint(window.Window, 10)).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("urgency: %v %s", err, out)
|
||||
}
|
||||
if e := expect(Urgent, ""); e.Window != "i3 fixture" || e.Workspace != "destination" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
quiet()
|
||||
command(target + `fullscreen enable`)
|
||||
if e := expect(Fullscreen, ""); e.Focused || e.Window != "i3 fixture" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
command(target + `fullscreen disable`)
|
||||
expect(Fullscreen, "")
|
||||
command(target + `move scratchpad`)
|
||||
quiet()
|
||||
command(target + `scratchpad show`)
|
||||
quiet()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && !strings.Contains(err.Error(), "closed") && ctx.Err() == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backend did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeI3Unavailable(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_I3_UNAVAILABLE") != "1" {
|
||||
t.Skip("requires empty private X display")
|
||||
}
|
||||
t.Setenv("I3SOCK", "/tmp/stale-i3")
|
||||
if path := xorg.I3Socket(); path != "" {
|
||||
t.Fatalf("activated without i3: %q", path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package i3ipc observes i3 without issuing window-management commands.
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
workspaces = 1
|
||||
subscribe = 2
|
||||
tree = 4
|
||||
eventBit uint32 = 1 << 31
|
||||
workspaceEvent = eventBit
|
||||
modeEvent = eventBit | 2
|
||||
windowEvent = eventBit | 3
|
||||
// A corrupt peer cannot cause an unbounded allocation or event backlog.
|
||||
maxPayload = 16 << 20
|
||||
maxPending = 256
|
||||
)
|
||||
|
||||
type message struct {
|
||||
kind uint32
|
||||
body []byte
|
||||
}
|
||||
|
||||
func readMessage(r io.Reader) (message, error) {
|
||||
var header [14]byte
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if string(header[:6]) != "i3-ipc" {
|
||||
return message{}, errors.New("invalid i3 magic")
|
||||
}
|
||||
size := binary.NativeEndian.Uint32(header[6:10])
|
||||
if size > maxPayload {
|
||||
return message{}, errors.New("i3 payload too large")
|
||||
}
|
||||
body := make([]byte, int(size))
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if len(body) != 0 && !json.Valid(body) {
|
||||
return message{}, errors.New("invalid i3 JSON")
|
||||
}
|
||||
return message{binary.NativeEndian.Uint32(header[10:]), body}, nil
|
||||
}
|
||||
|
||||
func writeMessage(w io.Writer, kind uint32, body []byte) error {
|
||||
if len(body) > maxPayload {
|
||||
return errors.New("i3 payload too large")
|
||||
}
|
||||
var header [14]byte
|
||||
copy(header[:], "i3-ipc")
|
||||
binary.NativeEndian.PutUint32(header[6:10], uint32(len(body)))
|
||||
binary.NativeEndian.PutUint32(header[10:], kind)
|
||||
for _, part := range [][]byte{header[:], body} {
|
||||
for len(part) > 0 {
|
||||
n, err := w.Write(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
part = part[n:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fragments struct{ io.Reader }
|
||||
|
||||
func (r fragments) Read(b []byte) (int, error) { return r.Reader.Read(b[:min(1, len(b))]) }
|
||||
func TestFraming(t *testing.T) {
|
||||
var wire bytes.Buffer
|
||||
body := []byte(`{"change":"résumé"}`)
|
||||
if err := writeMessage(&wire, modeEvent, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame := bytes.Clone(wire.Bytes())
|
||||
got, err := readMessage(fragments{&wire})
|
||||
if err != nil || got.kind != modeEvent || !bytes.Equal(got.body, body) {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
for name, bad := range map[string][]byte{
|
||||
"header": frame[:12], "body": frame[:len(frame)-1],
|
||||
"magic": append([]byte("no-ipc"), frame[6:]...),
|
||||
"json": append(bytes.Clone(frame[:14]), bytes.Repeat([]byte("x"), len(body))...),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := readMessage(bytes.NewReader(bad)); err == nil {
|
||||
t.Fatal("accepted malformed frame")
|
||||
}
|
||||
})
|
||||
}
|
||||
huge := bytes.Clone(frame[:14])
|
||||
binary.NativeEndian.PutUint32(huge[6:10], maxPayload+1)
|
||||
if _, err := readMessage(bytes.NewReader(huge)); err == nil {
|
||||
t.Fatal("accepted oversized frame")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
Mode Kind = iota
|
||||
Workspace
|
||||
Fullscreen
|
||||
Floating
|
||||
Urgent
|
||||
MarkAdded
|
||||
MarkRemoved
|
||||
WorkspaceMove
|
||||
)
|
||||
|
||||
// Event carries confirmed state, leaving wording and preferences to the app.
|
||||
type Event struct {
|
||||
Kind Kind
|
||||
Name, Window, Workspace string
|
||||
Focused, Enabled, Markup bool
|
||||
FullscreenMode int
|
||||
}
|
||||
|
||||
type node struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Window uint64 `json:"window"`
|
||||
Focused bool `json:"focused"`
|
||||
Urgent *bool `json:"urgent"`
|
||||
Fullscreen *int `json:"fullscreen_mode"`
|
||||
Scratchpad string `json:"scratchpad_state"`
|
||||
Floating string `json:"floating"`
|
||||
Marks []string `json:"marks"`
|
||||
Properties struct {
|
||||
Class string `json:"class"`
|
||||
} `json:"window_properties"`
|
||||
Nodes []node `json:"nodes"`
|
||||
FloatingNodes []node `json:"floating_nodes"`
|
||||
}
|
||||
|
||||
type workspace struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Focused bool `json:"focused"`
|
||||
}
|
||||
|
||||
type location struct {
|
||||
id uint64
|
||||
name string
|
||||
}
|
||||
type nodeState struct {
|
||||
Marks []string
|
||||
Urgent *bool
|
||||
}
|
||||
|
||||
type state struct {
|
||||
nodes map[uint64]nodeState
|
||||
locations map[uint64]location
|
||||
focusedWorkspace uint64
|
||||
}
|
||||
|
||||
func (s *state) baseline(root node, spaces []workspace) {
|
||||
s.nodes = make(map[uint64]nodeState)
|
||||
s.locations = make(map[uint64]location)
|
||||
s.walk(root, location{}, true)
|
||||
for _, ws := range spaces {
|
||||
if ws.Focused {
|
||||
s.focusedWorkspace = ws.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *state) walk(n node, ws location, baseline bool) {
|
||||
if n.Type == "workspace" {
|
||||
ws = location{n.ID, n.Name}
|
||||
}
|
||||
if n.ID != 0 {
|
||||
if baseline {
|
||||
s.nodes[n.ID] = nodeState{Marks: n.Marks, Urgent: n.Urgent}
|
||||
}
|
||||
// i3's private scratch workspace is deliberately outside this feature.
|
||||
if ws.id != 0 && ws.name != "__i3_scratch" {
|
||||
s.locations[n.ID] = ws
|
||||
}
|
||||
}
|
||||
for _, children := range [][]node{n.Nodes, n.FloatingNodes} {
|
||||
for _, child := range children {
|
||||
s.walk(child, ws, baseline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *state) refreshLocations(root node) {
|
||||
s.locations = make(map[uint64]location)
|
||||
s.walk(root, location{}, false)
|
||||
}
|
||||
|
||||
func windowName(n node) string {
|
||||
if n.Name != "" {
|
||||
return n.Name
|
||||
}
|
||||
return n.Properties.Class
|
||||
}
|
||||
|
||||
func (s *state) apply(m message, queryTree func() (node, error)) ([]Event, error) {
|
||||
var payload struct {
|
||||
Change string `json:"change"`
|
||||
Markup bool `json:"pango_markup"`
|
||||
Current *node `json:"current"`
|
||||
Container *node `json:"container"`
|
||||
}
|
||||
if err := json.Unmarshal(m.body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.kind == modeEvent {
|
||||
if payload.Change == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []Event{{Kind: Mode, Name: payload.Change, Markup: payload.Markup}}, nil
|
||||
}
|
||||
if m.kind == workspaceEvent {
|
||||
n := payload.Current
|
||||
if n == nil || n.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
switch payload.Change {
|
||||
case "focus":
|
||||
s.focusedWorkspace = n.ID
|
||||
return []Event{{Kind: Workspace, Name: n.Name}}, nil
|
||||
case "rename":
|
||||
for id, ws := range s.locations {
|
||||
if ws.id == n.ID {
|
||||
ws.name = n.Name
|
||||
s.locations[id] = ws
|
||||
}
|
||||
}
|
||||
if n.ID == s.focusedWorkspace {
|
||||
return []Event{{Kind: Workspace, Name: n.Name}}, nil
|
||||
}
|
||||
}
|
||||
// Workspace urgency is aggregate state, already handled at the window.
|
||||
return nil, nil
|
||||
}
|
||||
if m.kind != windowEvent || payload.Container == nil || payload.Container.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
n := *payload.Container
|
||||
old, known := s.nodes[n.ID]
|
||||
previous := s.locations[n.ID]
|
||||
if payload.Change == "close" {
|
||||
delete(s.nodes, n.ID)
|
||||
delete(s.locations, n.ID)
|
||||
return nil, nil
|
||||
}
|
||||
s.nodes[n.ID] = nodeState{Marks: n.Marks, Urgent: n.Urgent}
|
||||
if len(s.nodes) > 65536 {
|
||||
return nil, errors.New("i3 comparison cache exceeded")
|
||||
}
|
||||
if payload.Change == "new" || payload.Change == "move" || payload.Change == "urgent" || payload.Change == "floating" {
|
||||
root, err := queryTree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.refreshLocations(root)
|
||||
|
||||
}
|
||||
base := Event{Window: windowName(n), Focused: n.Focused, Workspace: s.locations[n.ID].name}
|
||||
switch payload.Change {
|
||||
case "fullscreen_mode":
|
||||
if n.Fullscreen != nil && *n.Fullscreen >= 0 && *n.Fullscreen <= 2 {
|
||||
base.Kind, base.Enabled, base.FullscreenMode = Fullscreen, *n.Fullscreen != 0, *n.Fullscreen
|
||||
return []Event{base}, nil
|
||||
}
|
||||
case "floating":
|
||||
// Sending a tiled window to the scratchpad also enables floating.
|
||||
// That implicit transition belongs to the excluded scratchpad feature.
|
||||
// Its event can precede assignment, so require a confirmed public
|
||||
// workspace in the resulting tree as well as the reported state.
|
||||
if s.locations[n.ID].id == 0 || n.Scratchpad != "" && n.Scratchpad != "none" {
|
||||
return nil, nil
|
||||
}
|
||||
switch n.Floating {
|
||||
case "auto_on", "user_on", "auto_off", "user_off":
|
||||
base.Kind, base.Enabled = Floating, strings.HasSuffix(n.Floating, "_on")
|
||||
return []Event{base}, nil
|
||||
}
|
||||
case "urgent":
|
||||
if n.Urgent != nil && *n.Urgent && (!known || old.Urgent == nil || !*old.Urgent) {
|
||||
base.Kind = Urgent
|
||||
return []Event{base}, nil
|
||||
}
|
||||
case "mark":
|
||||
if !known || old.Marks == nil || n.Marks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var events []Event
|
||||
// Stable order even if i3 reorders an unchanged set.
|
||||
for _, diff := range []struct {
|
||||
before, after []string
|
||||
kind Kind
|
||||
}{{n.Marks, old.Marks, MarkRemoved}, {old.Marks, n.Marks, MarkAdded}} {
|
||||
before := make(map[string]struct{}, len(diff.before))
|
||||
for _, name := range diff.before {
|
||||
before[name] = struct{}{}
|
||||
}
|
||||
names := slices.Clone(diff.after)
|
||||
slices.Sort(names)
|
||||
names = slices.Compact(names)
|
||||
for _, name := range names {
|
||||
if _, exists := before[name]; !exists {
|
||||
e := base
|
||||
e.Kind, e.Name = diff.kind, name
|
||||
if len(events) >= maxPending {
|
||||
return nil, errors.New("i3 mark changes exceeded")
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
case "move":
|
||||
current := s.locations[n.ID]
|
||||
if n.Window != 0 && previous.id != 0 && current.id != 0 && previous.id != current.id {
|
||||
base.Kind = WorkspaceMove
|
||||
return []Event{base}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package i3ipc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
func testWindow() node {
|
||||
return node{ID: 10, Window: 100, Name: "Editor", Focused: true, Marks: []string{}, Urgent: ptr(false), Fullscreen: ptr(0), Floating: "auto_off"}
|
||||
}
|
||||
func testRoot(n node, ws uint64, name string) node {
|
||||
return node{ID: 1, Type: "root", Nodes: []node{{ID: ws, Type: "workspace", Name: name, Nodes: []node{n}}}}
|
||||
}
|
||||
func applyTest(t *testing.T, s *state, kind uint32, change string, n node, root node) []Event {
|
||||
t.Helper()
|
||||
data := map[string]any{"change": change, "container": n, "current": n}
|
||||
raw, _ := json.Marshal(data)
|
||||
got, err := s.apply(message{kind, raw}, func() (node, error) { return root, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
func TestConfirmedStateChanges(t *testing.T) {
|
||||
n := testWindow()
|
||||
var s state
|
||||
root := testRoot(n, 2, "A")
|
||||
s.baseline(root, []workspace{{ID: 2, Name: "A", Focused: true}})
|
||||
if got := applyTest(t, &s, windowEvent, "focus", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Fullscreen = ptr(1)
|
||||
if got := applyTest(t, &s, windowEvent, "fullscreen_mode", n, root); len(got) != 1 || got[0].Kind != Fullscreen || !got[0].Enabled {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Fullscreen = ptr(0)
|
||||
n.Focused = false
|
||||
if got := applyTest(t, &s, windowEvent, "fullscreen_mode", n, root); len(got) != 1 || got[0].Enabled || got[0].Window != "Editor" || got[0].Focused {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Floating = "user_on"
|
||||
if got := applyTest(t, &s, windowEvent, "floating", n, root); len(got) != 1 || got[0].Kind != Floating || !got[0].Enabled {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Floating = "user_off"
|
||||
if got := applyTest(t, &s, windowEvent, "floating", n, root); len(got) != 1 || got[0].Enabled {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Urgent = ptr(true)
|
||||
root = testRoot(n, 2, "A")
|
||||
if got := applyTest(t, &s, windowEvent, "urgent", n, root); len(got) != 1 || got[0].Kind != Urgent || got[0].Workspace != "A" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := applyTest(t, &s, windowEvent, "urgent", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := applyTest(t, &s, workspaceEvent, "urgent", root.Nodes[0], root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Urgent = ptr(false)
|
||||
if got := applyTest(t, &s, windowEvent, "urgent", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Marks = []string{"z", "a"}
|
||||
got := applyTest(t, &s, windowEvent, "mark", n, root)
|
||||
if len(got) != 2 || got[0].Name != "a" || got[1].Name != "z" || got[0].Kind != MarkAdded {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Marks = []string{"a", "z"}
|
||||
if got := applyTest(t, &s, windowEvent, "mark", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Marks = []string{}
|
||||
if got := applyTest(t, &s, windowEvent, "mark", n, root); len(got) != 2 || got[0].Kind != MarkRemoved {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
root = testRoot(n, 3, "résumé & 99")
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 1 || got[0].Kind != WorkspaceMove || got[0].Workspace != "résumé & 99" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
root = testRoot(n, 4, "__i3_scratch")
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
root = testRoot(n, 3, "B")
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
func TestWorkspacesAndModes(t *testing.T) {
|
||||
n := testWindow()
|
||||
var s state
|
||||
s.baseline(testRoot(n, 2, "A"), []workspace{{ID: 2, Focused: true}})
|
||||
for _, change := range []string{"focus", "rename"} {
|
||||
ws := node{ID: 2, Name: "empty & arbitrary", Type: "workspace"}
|
||||
if got := applyTest(t, &s, workspaceEvent, change, ws, node{}); len(got) != 1 || got[0].Kind != Workspace || got[0].Name != ws.Name {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
if got := applyTest(t, &s, workspaceEvent, "rename", node{ID: 3, Name: "background"}, node{}); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
raw := []byte(`{"change":"<b>personal & more</b>","pango_markup":true}`)
|
||||
got, err := s.apply(message{modeEvent, raw}, nil)
|
||||
want := []Event{{Kind: Mode, Name: "<b>personal & more</b>", Markup: true}}
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
}
|
||||
func TestUnknownContextAndMissingEvidence(t *testing.T) {
|
||||
n := testWindow()
|
||||
var s state
|
||||
s.baseline(node{}, nil)
|
||||
if got := applyTest(t, &s, windowEvent, "mark", n, node{}); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, testRoot(n, 3, "B")); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
n.Fullscreen = nil
|
||||
n.Floating = "unknown"
|
||||
for _, change := range []string{"fullscreen_mode", "floating", "new", "close", "title"} {
|
||||
if got := applyTest(t, &s, windowEvent, change, n, node{}); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
n = testWindow()
|
||||
n.Urgent = ptr(true)
|
||||
if got := applyTest(t, &s, windowEvent, "urgent", n, node{Type: "root"}); len(got) != 1 || got[0].Workspace != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchpadImplicitFloatingSuppressed(t *testing.T) {
|
||||
n := testWindow()
|
||||
var s state
|
||||
s.baseline(testRoot(n, 2, "A"), nil)
|
||||
n.Floating = "user_on" // i3 reports this before assigning the scratchpad.
|
||||
if got := applyTest(t, &s, windowEvent, "floating", n, testRoot(n, 4, "__i3_scratch")); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, testRoot(n, 4, "__i3_scratch")); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := applyTest(t, &s, windowEvent, "move", n, testRoot(n, 2, "A")); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user