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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user