Add native i3 desktop announcements

This commit is contained in:
ak47 committed 2026-11-07 14:46:46 -05:00
1 parent 49eeae89f4
commit bf1f155018
22 files changed
+1835 -1

No files matched your search

+155
View File
@@ -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)
}
}
+178
View File
@@ -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")
}
}
+223
View File
@@ -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 &amp; mode</b>"`)
if e := expect(Mode, ""); !e.Markup || e.Name != "<b>marked &amp; 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)
}
}
+76
View File
@@ -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
}
+43
View File
@@ -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")
}
}
+241
View File
@@ -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
}
+156
View File
@@ -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 &amp; more</b>","pango_markup":true}`)
got, err := s.apply(message{modeEvent, raw}, nil)
want := []Event{{Kind: Mode, Name: "<b>personal &amp; 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)
}
}