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
@@ -13,6 +13,7 @@ Cthulhu is a Linux screen reader written in Go.
|
||||
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
|
||||
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
|
||||
- [Browser Find](docs/testing/browser-find.md): native Ctrl+F focus, typing, window ownership and verification.
|
||||
- [Native i3 announcements](docs/testing/i3-announcements.md): modes, workspaces, window states, urgency, marks, workspace moves, and live verification.
|
||||
- [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification.
|
||||
- [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification.
|
||||
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Native i3 announcements
|
||||
|
||||
Cthulhu observes the active i3 X11 session directly over its Unix IPC socket,
|
||||
using the [i3 IPC specification](https://i3wm.org/docs/ipc.html).
|
||||
There is no Python runtime helper, new binding, sound, or window-management
|
||||
command. Other desktops keep their existing support. The installed i3 executable
|
||||
and an inherited `I3SOCK` variable do not enable support: the active window
|
||||
manager must identify itself as i3, and its root window must advertise its socket.
|
||||
|
||||
The Reading settings table provides six choices, all enabled by default:
|
||||
|
||||
- Announce i3 binding modes: the configured name followed by “mode”; returning
|
||||
to the default binding mode says “Default mode”. Pango markup is converted
|
||||
to text when i3 flags it as markup.
|
||||
- Announce i3 workspaces: “Workspace {name}” on focus, including empty workspaces
|
||||
and focused-workspace renames.
|
||||
- Announce i3 window states: fullscreen on/off, global fullscreen on, and
|
||||
floating on/off. Background changes include the window title or class.
|
||||
- Announce i3 urgent windows: the window and workspace when available. Aggregate
|
||||
workspace urgency does not produce a second message.
|
||||
- Announce i3 marks: additions and removals compared with the previously
|
||||
reported mark set.
|
||||
- Announce i3 window workspace moves: the destination, only when previous and
|
||||
current workspace identities are known. Rearrangement within a workspace stays
|
||||
silent. Rapid events may be consolidated to the confirmed destination.
|
||||
|
||||
These choices persist in the versioned TOML profile under
|
||||
`presentation.reading` as `i3-modes`, `i3-workspaces`, `i3-window-states`,
|
||||
`i3-urgency`, `i3-marks`, and `i3-workspace-moves`. Apply/reload and application
|
||||
profiles use the existing settings path. Global preferences are usually the most
|
||||
convenient place to set desktop announcement choices.
|
||||
|
||||
Announcements queue with other incoming speech; normal user-input interruption
|
||||
still applies. They can speak during Fenrir terminal handoff. Screen lock,
|
||||
internal reader UI, speech disable, speech mode, and braille flash preferences
|
||||
retain their existing policies. Suppressed events are discarded. Braille uses
|
||||
the existing flash duration and restores the current application frame without
|
||||
moving focus. Existing persistent-flash settings still apply.
|
||||
|
||||
I38 may announce some of the same actions independently. Cthulhu does not edit
|
||||
I38 configuration. If both announce a workspace or mode, choose which source you
|
||||
want to hear and disable the corresponding announcements in that source.
|
||||
|
||||
Scratchpad visibility/cycling, automatic layout changes, window creation and
|
||||
closure, and extra window introductions are excluded. Sending a tiled window to
|
||||
the scratchpad implicitly enables floating; that scratchpad state change is
|
||||
suppressed too. Binding commands are never used to guess state. Startup and
|
||||
reconnection establish silent baselines. Missing context skips comparisons;
|
||||
malformed messages, timeouts, or excessive pending events discard the connection
|
||||
and retry discovery with bounded backoff.
|
||||
|
||||
## Automated verification
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/i3ipc ./internal/config ./internal/preferences -count=1
|
||||
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/app -run 'TestI3|TestAnnouncement|TestBrailleFlash|TestProcessInputEventsUsesLiveInterruption|TestTitlePreference' -count=1 -timeout=60s
|
||||
python3 tests/native_i3.py
|
||||
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go build -o /tmp/cthulhu-i3-reader ./cmd/cthulhu
|
||||
CTHULHU_BINARY=/tmp/cthulhu-i3-reader CTHULHU_PREFS_READING_ONLY=1 python3 tests/native_preferences.py
|
||||
python3 tests/check_speech.py
|
||||
```
|
||||
|
||||
The native harness uses a private 24-bit Xvfb display and the shared disposable
|
||||
D-Bus session launcher. It checks actual mode, markup, empty workspace, rename,
|
||||
fullscreen, floating, mark, same-workspace move, cross-workspace move, and urgency
|
||||
events, plus background window identity and scratchpad silence. It also verifies
|
||||
that a private X display without i3 does not activate support. Tests do not
|
||||
replace or control the running reader or desktop WM.
|
||||
|
||||
## Live speech, keyboard, and braille acceptance
|
||||
|
||||
Human diff review and live acceptance are required before submission. Automated
|
||||
checks establish queue requests and braille frames, not audible output or a
|
||||
physical braille display. Preserve diagnostic logs if investigating a failure.
|
||||
Use an expendable window and named workspaces for move and mark checks.
|
||||
|
||||
1. Quit the current reader with Cthulhu+Q. Press Ctrl+Escape to enter i3 personal
|
||||
mode, release those keys, then press Shift+G. Ctrl+Escape and Shift+G are
|
||||
separate key presses. This runs `~/devel/cthulhu-go.sh`, rebuilding the reader
|
||||
and braille helper, starting the debug log, and opening the test terminal.
|
||||
Expect no announcement of the existing mode/workspace merely from startup.
|
||||
2. In global Preferences, find the six “Announce i3” rows in Reading. Check
|
||||
their setting names, descriptions, and checked states. Use arrows between
|
||||
columns and rows, Space to toggle, and Tab/Shift+Tab into and out of the
|
||||
table. Apply, close, and reopen. Expect saved values and usable keyboard
|
||||
focus. Restore all six to enabled for the following checks. For braille
|
||||
restoration checks, enable flash messages with a finite duration and turn
|
||||
persistent flashes off.
|
||||
3. Enter each configured personal, ratpoison, and panel mode using your existing
|
||||
bindings, returning to default between them. Expect the reported name plus
|
||||
“mode”, and “Default mode” on return. A configured markup mode should read
|
||||
its text without tags. Expect the same text briefly on braille, followed by
|
||||
current application content. Focus and ordinary typing must remain usable.
|
||||
4. Focus an empty named workspace, for example using
|
||||
`i3-msg 'workspace "cthulhu test empty"'`. Expect “Workspace cthulhu test
|
||||
empty”. Rename it with `i3-msg 'rename workspace to "cthulhu test renamed"'`;
|
||||
expect “Workspace cthulhu test renamed”. Switching back should announce the
|
||||
destination workspace. Existing application-focus and title speech should
|
||||
still occur normally, with no new i3 window introduction.
|
||||
5. In the expendable window, use existing bindings to enable/disable fullscreen
|
||||
and floating. Expect “Fullscreen on/off” and “Floating on/off”, with braille
|
||||
flashes and no extra focus change. Add and remove a temporary mark with
|
||||
`i3-msg 'mark "cthulhu test"'` and `i3-msg 'unmark "cthulhu test"'`.
|
||||
Expect “Mark added: cthulhu test” and “Mark removed: cthulhu test”.
|
||||
6. Move the expendable window to a different workspace using your usual binding.
|
||||
Expect “Moved to workspace {destination}”, with a title prefix if the event
|
||||
reports a background window. Rearrange it within that workspace; expect no
|
||||
workspace-move announcement. An actual urgent background window should
|
||||
produce one “Urgent: {window}, workspace {name}” announcement. Record any
|
||||
unavailable urgency case as untested. Scratchpad show/hide/cycling should
|
||||
add no scratchpad announcement.
|
||||
7. Return to the Fenrir test terminal. Enter and leave an i3 mode and switch
|
||||
workspaces. Expect Cthulhu's desktop announcements while Fenrir continues
|
||||
handling terminal content and keys. Leave the terminal for an accessible
|
||||
application; expect normal Cthulhu application focus, reading keys, title
|
||||
changes, and braille to resume.
|
||||
8. Disable one i3 Reading choice, Apply, and exercise its action. Expect that
|
||||
category silent in Cthulhu; re-enable it and verify new events speak without
|
||||
replaying the suppressed events. Check speech disable/mode behavior using
|
||||
existing reader settings. While the screen is locked or internal reader UI
|
||||
is active, background i3 changes should not speak or flash, and should not
|
||||
replay after leaving that state. Use only transitions reachable in those
|
||||
states; do not bypass the lock to create a test event.
|
||||
9. Trigger several desktop changes while other speech is queued. Expect incoming
|
||||
messages to queue without cutting off incoming speech. Fresh user input
|
||||
should still interrupt according to your configured interruption keys.
|
||||
Distinguish a key's normal interruption from a new announcement arriving.
|
||||
10. Report the exact speech, braille text and restoration, focused control/window,
|
||||
keys used, and any independent I38 duplicate. If anything differs, inspect
|
||||
`~/devel/cthulhu-go.log` before restarting again.
|
||||
@@ -252,6 +252,9 @@ func Run(ctx context.Context, options Options) error {
|
||||
live.editFieldFocused = source.EditFieldFocused
|
||||
}
|
||||
live.Store(settings)
|
||||
services = append(services, i3Service(i3Announcements{live: live, output: gatedSpeech{
|
||||
gate: focusGate, speech: outputQueue{speech: speechOutput, braille: brailleOutput, live: live},
|
||||
}}))
|
||||
if browser != nil {
|
||||
browser.settings = func() review.ModeSettings {
|
||||
settings := review.DefaultModeSettings()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/i3ipc"
|
||||
"git.stormux.org/storm/cthulhu/internal/lifecycle"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
)
|
||||
|
||||
// i3Announcements owns presentation policy, never focus or accessibility state.
|
||||
// The backend owns its connection, baseline, caches and reconnect lifetime.
|
||||
type i3Announcements struct {
|
||||
live *liveSettings
|
||||
output speechEnqueuer
|
||||
}
|
||||
|
||||
func i3ModeName(name string, markup bool) string {
|
||||
if !markup {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
decoder := xml.NewDecoder(strings.NewReader("<mode>" + name + "</mode>"))
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if data, ok := token.(xml.CharData); ok {
|
||||
text.Write(data)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(text.String())
|
||||
}
|
||||
|
||||
func i3Message(event i3ipc.Event, settings config.ReadingSettings) string {
|
||||
var text string
|
||||
switch event.Kind {
|
||||
case i3ipc.Mode:
|
||||
if !settings.I3Modes {
|
||||
return ""
|
||||
}
|
||||
name := i3ModeName(event.Name, event.Markup)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if name == "default" {
|
||||
return "Default mode"
|
||||
}
|
||||
return name + " mode"
|
||||
case i3ipc.Workspace:
|
||||
if settings.I3Workspaces && event.Name != "" {
|
||||
return "Workspace " + event.Name
|
||||
}
|
||||
return ""
|
||||
case i3ipc.Fullscreen:
|
||||
if !settings.I3WindowStates {
|
||||
return ""
|
||||
}
|
||||
text = "Fullscreen off"
|
||||
if event.Enabled {
|
||||
text = "Fullscreen on"
|
||||
if event.FullscreenMode == 2 {
|
||||
text = "Global fullscreen on"
|
||||
}
|
||||
}
|
||||
case i3ipc.Floating:
|
||||
if !settings.I3WindowStates {
|
||||
return ""
|
||||
}
|
||||
text = "Floating off"
|
||||
if event.Enabled {
|
||||
text = "Floating on"
|
||||
}
|
||||
case i3ipc.Urgent:
|
||||
if !settings.I3Urgency {
|
||||
return ""
|
||||
}
|
||||
text = "Urgent"
|
||||
if event.Window != "" {
|
||||
text += ": " + event.Window
|
||||
}
|
||||
if event.Workspace != "" {
|
||||
text += ", workspace " + event.Workspace
|
||||
}
|
||||
return text
|
||||
case i3ipc.MarkAdded, i3ipc.MarkRemoved:
|
||||
if !settings.I3Marks {
|
||||
return ""
|
||||
}
|
||||
text = "Mark added: " + event.Name
|
||||
if event.Kind == i3ipc.MarkRemoved {
|
||||
text = "Mark removed: " + event.Name
|
||||
}
|
||||
case i3ipc.WorkspaceMove:
|
||||
if !settings.I3WorkspaceMoves || event.Workspace == "" {
|
||||
return ""
|
||||
}
|
||||
text = "Moved to workspace " + event.Workspace
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if !event.Focused {
|
||||
name := event.Window
|
||||
if name == "" {
|
||||
name = "Window"
|
||||
}
|
||||
text = name + ": " + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (controller i3Announcements) present(event i3ipc.Event) {
|
||||
settings := controller.live.Load()
|
||||
text := i3Message(event, settings.Reading)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
controller.output.Enqueue(speechRequest{Text: text, Role: "system", Cause: "i3-desktop", Append: true, BypassFocusGate: true})
|
||||
}
|
||||
|
||||
func i3Service(controller i3Announcements) lifecycle.Service {
|
||||
var cancel context.CancelFunc
|
||||
var done chan struct{}
|
||||
return lifecycle.Service{Name: "i3 desktop", Start: func(ctx context.Context) error {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
done = make(chan struct{})
|
||||
go func() { defer close(done); i3ipc.Run(ctx, xorg.I3Socket, controller.present) }()
|
||||
return nil
|
||||
}, Stop: func(ctx context.Context) error {
|
||||
if cancel == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/i3ipc"
|
||||
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||
)
|
||||
|
||||
func TestI3MessageWording(t *testing.T) {
|
||||
settings := config.DefaultSettings().Reading
|
||||
for _, tc := range []struct {
|
||||
event i3ipc.Event
|
||||
want string
|
||||
}{
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "default"}, "Default mode"},
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"}, "personal mode"},
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "ratpoison"}, "ratpoison mode"},
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"}, "panel mode"},
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<b>résumé & keys</b>", Markup: true}, "résumé & keys mode"},
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<raw & name>"}, "<raw & name> mode"},
|
||||
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<broken>", Markup: true}, ""},
|
||||
{i3ipc.Event{Kind: i3ipc.Workspace, Name: "99: arbitrary & empty"}, "Workspace 99: arbitrary & empty"},
|
||||
{i3ipc.Event{Kind: i3ipc.Fullscreen, Focused: true, Enabled: true}, "Fullscreen on"},
|
||||
{i3ipc.Event{Kind: i3ipc.Fullscreen, Window: "Browser"}, "Browser: Fullscreen off"},
|
||||
{i3ipc.Event{Kind: i3ipc.Fullscreen, Focused: true, Enabled: true, FullscreenMode: 2}, "Global fullscreen on"},
|
||||
{i3ipc.Event{Kind: i3ipc.Floating, Focused: true, Enabled: true}, "Floating on"},
|
||||
{i3ipc.Event{Kind: i3ipc.Floating, Window: "Editor"}, "Editor: Floating off"},
|
||||
{i3ipc.Event{Kind: i3ipc.Urgent, Window: "Mail", Workspace: "communications"}, "Urgent: Mail, workspace communications"},
|
||||
{i3ipc.Event{Kind: i3ipc.Urgent}, "Urgent"},
|
||||
{i3ipc.Event{Kind: i3ipc.MarkAdded, Name: "bookmark", Focused: true}, "Mark added: bookmark"},
|
||||
{i3ipc.Event{Kind: i3ipc.MarkRemoved, Name: "bookmark", Window: "Editor"}, "Editor: Mark removed: bookmark"},
|
||||
{i3ipc.Event{Kind: i3ipc.WorkspaceMove, Workspace: "Notes", Focused: true}, "Moved to workspace Notes"},
|
||||
} {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
if got := i3Message(tc.event, settings); got != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestI3QueueHandoffAndSuppression(t *testing.T) {
|
||||
live := &liveSettings{}
|
||||
settings := config.DefaultSettings()
|
||||
live.Store(settings)
|
||||
queue := &remoteSpeechQueue{}
|
||||
display := &flashRecorder{}
|
||||
gate := newFocusGate(nil)
|
||||
controller := i3Announcements{live: live, output: gatedSpeech{gate: gate, speech: outputQueue{speech: queue, braille: display, live: live}}}
|
||||
gate.suspended.Store(true)
|
||||
for _, name := range []string{"personal", "panel", "default"} {
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: name})
|
||||
}
|
||||
if len(queue.requests) != 3 || len(display.messages) != 3 {
|
||||
t.Fatalf("handoff: %+v %+v", queue.requests, display.messages)
|
||||
}
|
||||
for i, request := range queue.requests {
|
||||
if !request.Append || !request.BypassFocusGate || request.Role != "system" || request.Text != display.messages[i] {
|
||||
t.Fatal(request)
|
||||
}
|
||||
}
|
||||
gate.secure.Store(true)
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "locked"})
|
||||
gate.secure.Store(false)
|
||||
ui := &fakeInternalUIState{active: true}
|
||||
gate.internalUI = ui
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "internal"})
|
||||
ui.active = false
|
||||
settings.Reading.I3Modes = false
|
||||
live.Store(settings)
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "disabled"})
|
||||
settings.Reading.I3Modes = true
|
||||
live.Store(settings)
|
||||
if len(queue.requests) != 3 || len(display.messages) != 3 {
|
||||
t.Fatal("suppressed output was presented")
|
||||
}
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "resumed"})
|
||||
if len(queue.requests) != 4 || queue.requests[3].Text != "resumed mode" {
|
||||
t.Fatal(queue.requests)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeInternalUIState struct{ active bool }
|
||||
|
||||
func (ui *fakeInternalUIState) InternalUIActive() bool { return ui.active }
|
||||
|
||||
func TestI3PreferencesRuntimeAndPersistence(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
||||
store, err := config.NewStore(settings.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Save(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
|
||||
worker.live = &liveSettings{}
|
||||
worker.live.Store(settings)
|
||||
worker.beginEdit(context.Background(), "")
|
||||
values := preferences.FromSettings(worker.editing)
|
||||
values.Reading.I3Modes = false
|
||||
values.Reading.I3Workspaces = false
|
||||
values.Reading.I3WindowStates = false
|
||||
values.Reading.I3Urgency = false
|
||||
values.Reading.I3Marks = false
|
||||
values.Reading.I3WorkspaceMoves = false
|
||||
if response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}); !response.Accepted {
|
||||
t.Fatal(response.Message)
|
||||
}
|
||||
loaded, err := config.Load(settings.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reading := range []config.ReadingSettings{loaded.Reading, worker.live.Load().Reading} {
|
||||
for _, kind := range []i3ipc.Kind{i3ipc.Mode, i3ipc.Workspace, i3ipc.Fullscreen, i3ipc.Floating, i3ipc.Urgent, i3ipc.MarkAdded, i3ipc.MarkRemoved, i3ipc.WorkspaceMove} {
|
||||
if got := i3Message(i3ipc.Event{Kind: kind, Name: "test", Workspace: "B"}, reading); got != "" {
|
||||
t.Fatalf("disabled preference presented %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestI3FlashRestoresApplicationContent(t *testing.T) {
|
||||
worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
|
||||
live := &liveSettings{}
|
||||
settings := config.DefaultSettings()
|
||||
settings.Braille.FlashTime = 20
|
||||
settings.Braille.FlashPersistent = false
|
||||
live.Store(settings)
|
||||
worker.EnqueueFrame(braille.Frame{Text: "Application content", Cursor: 2})
|
||||
controller := i3Announcements{live: live, output: outputQueue{speech: &remoteSpeechQueue{}, braille: worker, live: live}}
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"})
|
||||
if got := worker.current(); got.Text != "panel mode" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for worker.current().Text != "Application content" && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if got := worker.current(); got.Text != "Application content" || got.Cursor != 2 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestI3UsesSpeechFIFOInterruptionAndDisablePolicy(t *testing.T) {
|
||||
live := &liveSettings{}
|
||||
live.Store(config.DefaultSettings())
|
||||
speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
||||
// Hold the delivery worker so admission order and cancellation are observable.
|
||||
speech.running = true
|
||||
controller := i3Announcements{live: live, output: outputQueue{speech: speech, live: live}}
|
||||
speech.Enqueue(speechRequest{Text: "Application output"})
|
||||
generation := speech.generation
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"})
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Workspace, Name: "Notes"})
|
||||
if len(speech.pending) != 3 || speech.pending[0].Text != "Application output" || speech.pending[1].Text != "personal mode" || speech.pending[2].Text != "Workspace Notes" || speech.generation != generation {
|
||||
t.Fatalf("incoming events changed FIFO or interrupted: %+v", speech.pending)
|
||||
}
|
||||
speech.InterruptForUser()
|
||||
if len(speech.pending) != 0 {
|
||||
t.Fatal("user interruption did not clear desktop messages")
|
||||
}
|
||||
speech.SetEnabled(false)
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "suppressed"})
|
||||
speech.SetEnabled(true)
|
||||
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "new"})
|
||||
if len(speech.pending) != 1 || speech.pending[0].Text != "new mode" {
|
||||
t.Fatalf("disabled events replayed: %+v", speech.pending)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ func DefaultSettings() Settings {
|
||||
SayAllStyle: "line",
|
||||
TextAttributes: DefaultTextAttributes(),
|
||||
Reading: ReadingSettings{
|
||||
I3Modes: true,
|
||||
I3Workspaces: true,
|
||||
I3WindowStates: true,
|
||||
I3Urgency: true,
|
||||
I3Marks: true,
|
||||
I3WorkspaceMoves: true,
|
||||
AutomaticallyReadTitleChanges: true,
|
||||
EnablePauseBreaks: true,
|
||||
SpeakNumbersAsDigits: false,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func i3Flags(r ReadingSettings) []bool {
|
||||
return []bool{r.I3Modes, r.I3Workspaces, r.I3WindowStates, r.I3Urgency, r.I3Marks, r.I3WorkspaceMoves}
|
||||
}
|
||||
func TestI3DefaultsPersistenceAndInheritance(t *testing.T) {
|
||||
old, err := Load(filepath.Join("testdata", "settings-v2.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range i3Flags(old.Reading) {
|
||||
if !v {
|
||||
t.Fatal("existing configuration defaults i3 announcements off")
|
||||
}
|
||||
}
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
global := DefaultSettings()
|
||||
if err = store.Save(global); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := global
|
||||
app.Reading.I3Modes = false
|
||||
app.Reading.I3Workspaces = false
|
||||
app.Reading.I3WindowStates = false
|
||||
app.Reading.I3Urgency = false
|
||||
app.Reading.I3Marks = false
|
||||
app.Reading.I3WorkspaceMoves = false
|
||||
if err = store.SaveForApplication("Quiet App", app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, found, err := store.LoadForApplication("Quiet App")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("%t %v", found, err)
|
||||
}
|
||||
for _, v := range i3Flags(loaded.Reading) {
|
||||
if v {
|
||||
t.Fatal("override lost")
|
||||
}
|
||||
}
|
||||
inherited, _, err := store.LoadForApplication("Other App")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range i3Flags(inherited.Reading) {
|
||||
if !v {
|
||||
t.Fatal("override leaked")
|
||||
}
|
||||
}
|
||||
global.Reading = app.Reading
|
||||
if err = store.Save(global); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inherited, _, err = store.LoadForApplication("Other App")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range i3Flags(inherited.Reading) {
|
||||
if v {
|
||||
t.Fatal("global update not inherited")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,12 @@ func profileFromSettings(settings Settings) writableProfile {
|
||||
SayAllStyle: stringPointer(settings.SayAllStyle),
|
||||
Reading: writableReading{
|
||||
AutomaticallyReadClipboardChanges: boolPointer(settings.Reading.AutomaticallyReadClipboardChanges),
|
||||
I3Modes: boolPointer(settings.Reading.I3Modes),
|
||||
I3Workspaces: boolPointer(settings.Reading.I3Workspaces),
|
||||
I3WindowStates: boolPointer(settings.Reading.I3WindowStates),
|
||||
I3Urgency: boolPointer(settings.Reading.I3Urgency),
|
||||
I3Marks: boolPointer(settings.Reading.I3Marks),
|
||||
I3WorkspaceMoves: boolPointer(settings.Reading.I3WorkspaceMoves),
|
||||
AutomaticallyReadTitleChanges: boolPointer(settings.Reading.AutomaticallyReadTitleChanges),
|
||||
PhoneticCharacterHints: boolPointer(settings.Reading.PhoneticCharacterHints),
|
||||
EnablePauseBreaks: boolPointer(settings.Reading.EnablePauseBreaks),
|
||||
@@ -300,6 +306,24 @@ func apply_profile_reading(settings *Settings, profile writableProfile) {
|
||||
if value := profile.Presentation.Reading.AutomaticallyReadClipboardChanges; value != nil {
|
||||
settings.Reading.AutomaticallyReadClipboardChanges = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.I3Modes; value != nil {
|
||||
settings.Reading.I3Modes = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.I3Workspaces; value != nil {
|
||||
settings.Reading.I3Workspaces = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.I3WindowStates; value != nil {
|
||||
settings.Reading.I3WindowStates = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.I3Urgency; value != nil {
|
||||
settings.Reading.I3Urgency = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.I3Marks; value != nil {
|
||||
settings.Reading.I3Marks = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.I3WorkspaceMoves; value != nil {
|
||||
settings.Reading.I3WorkspaceMoves = *value
|
||||
}
|
||||
if value := profile.Presentation.Reading.AutomaticallyReadTitleChanges; value != nil {
|
||||
settings.Reading.AutomaticallyReadTitleChanges = *value
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ type writableTextAttributes struct {
|
||||
|
||||
type writableReading struct {
|
||||
AutomaticallyReadClipboardChanges *bool `toml:"automatically-read-clipboard-changes,omitempty"`
|
||||
I3Modes *bool `toml:"i3-modes,omitempty"`
|
||||
I3Workspaces *bool `toml:"i3-workspaces,omitempty"`
|
||||
I3WindowStates *bool `toml:"i3-window-states,omitempty"`
|
||||
I3Urgency *bool `toml:"i3-urgency,omitempty"`
|
||||
I3Marks *bool `toml:"i3-marks,omitempty"`
|
||||
I3WorkspaceMoves *bool `toml:"i3-workspace-moves,omitempty"`
|
||||
AutomaticallyReadTitleChanges *bool `toml:"automatically-read-title-changes,omitempty"`
|
||||
PhoneticCharacterHints *bool `toml:"phonetic-character-hints,omitempty"`
|
||||
EnablePauseBreaks *bool `toml:"enable-pause-breaks,omitempty"`
|
||||
|
||||
@@ -37,6 +37,12 @@ type Voice struct {
|
||||
|
||||
type ReadingSettings struct {
|
||||
AutomaticallyReadClipboardChanges bool
|
||||
I3Modes bool
|
||||
I3Workspaces bool
|
||||
I3WindowStates bool
|
||||
I3Urgency bool
|
||||
I3Marks bool
|
||||
I3WorkspaceMoves bool
|
||||
AutomaticallyReadTitleChanges bool
|
||||
PhoneticCharacterHints bool
|
||||
EnablePauseBreaks bool
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package xorg
|
||||
|
||||
/*
|
||||
#include "commands_native.h"
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static unsigned char *i3_property(Display *d, Window w, const char *name, Atom wanted, int bits) {
|
||||
Atom type; int format; unsigned long count, remaining;
|
||||
unsigned char *value = NULL;
|
||||
if (XGetWindowProperty(d, w, XInternAtom(d, name, False), 0, 1024, False,
|
||||
wanted, &type, &format, &count, &remaining, &value) != Success ||
|
||||
type != wanted || format != bits || remaining || !count) {
|
||||
if (value) XFree(value);
|
||||
return NULL;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static char *i3_socket(void) {
|
||||
Display *d = XOpenDisplay(NULL);
|
||||
if (!d) return NULL;
|
||||
cthulhu_x11_begin_read(d);
|
||||
char *socket = NULL;
|
||||
Window root = DefaultRootWindow(d);
|
||||
unsigned char *check = i3_property(d, root, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32);
|
||||
if (check) {
|
||||
Window wm = *(unsigned long *)check;
|
||||
XFree(check);
|
||||
unsigned char *self = i3_property(d, wm, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32);
|
||||
unsigned char *name = i3_property(d, wm, "_NET_WM_NAME", XInternAtom(d, "UTF8_STRING", False), 8);
|
||||
if (self && *(unsigned long *)self == wm && name && !strcmp((char *)name, "i3")) {
|
||||
unsigned char *path = i3_property(d, root, "I3_SOCKET_PATH", XInternAtom(d, "UTF8_STRING", False), 8);
|
||||
if (path) { socket = strdup((char *)path); XFree(path); }
|
||||
}
|
||||
if (self) XFree(self);
|
||||
if (name) XFree(name);
|
||||
}
|
||||
if (cthulhu_x11_end_read(d)) { free(socket); socket = NULL; }
|
||||
XCloseDisplay(d);
|
||||
return socket;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// I3Socket verifies the active X11 WM's self-referencing EWMH identity and
|
||||
// reads its root socket property. An installed executable or stale I3SOCK
|
||||
// alone cannot enable this backend. Called only by the desktop worker.
|
||||
func I3Socket() string {
|
||||
if os.Getenv("DISPLAY") == "" {
|
||||
return ""
|
||||
}
|
||||
path := C.i3_socket()
|
||||
if path == nil {
|
||||
return ""
|
||||
}
|
||||
defer C.free(unsafe.Pointer(path))
|
||||
return C.GoString(path)
|
||||
}
|
||||
@@ -7,6 +7,18 @@ static GtkListStore *reading_store;
|
||||
static const struct { const char *id; const char *label; const char *description; } reading_rows[] = {
|
||||
{"only_speak_displayed_text", "Only speak displayed text",
|
||||
"Limits reading to displayed names, text and values. Omits extra information such as control types, states and container announcements."},
|
||||
{"i3_modes", "Announce i3 binding modes",
|
||||
"Announces the configured i3 binding mode when it changes."},
|
||||
{"i3_workspaces", "Announce i3 workspaces",
|
||||
"Announces workspace focus and focused workspace renames in i3."},
|
||||
{"i3_window_states", "Announce i3 window states",
|
||||
"Announces fullscreen and floating changes in i3."},
|
||||
{"i3_urgency", "Announce i3 urgent windows",
|
||||
"Announces windows becoming urgent in i3."},
|
||||
{"i3_marks", "Announce i3 marks",
|
||||
"Announces confirmed additions and removals of i3 window marks."},
|
||||
{"i3_workspace_moves", "Announce i3 window workspace moves",
|
||||
"Announces confirmed moves of windows between i3 workspaces."},
|
||||
{"automatically_read_title_changes", "Automatically read title changes",
|
||||
"Announces changes to the active window's title, such as a new page title in a browser."},
|
||||
{"automatically_read_clipboard_changes", "Automatically read clipboard changes",
|
||||
|
||||
@@ -39,6 +39,12 @@ func readingChecks(values *Values) []struct {
|
||||
value *bool
|
||||
}{
|
||||
{"only_speak_displayed_text", &values.Reading.OnlySpeakDisplayedText},
|
||||
{"i3_modes", &values.Reading.I3Modes},
|
||||
{"i3_workspaces", &values.Reading.I3Workspaces},
|
||||
{"i3_window_states", &values.Reading.I3WindowStates},
|
||||
{"i3_urgency", &values.Reading.I3Urgency},
|
||||
{"i3_marks", &values.Reading.I3Marks},
|
||||
{"i3_workspace_moves", &values.Reading.I3WorkspaceMoves},
|
||||
{"automatically_read_title_changes", &values.Reading.AutomaticallyReadTitleChanges},
|
||||
{"automatically_read_clipboard_changes", &values.Reading.AutomaticallyReadClipboardChanges},
|
||||
{"phonetic_character_hints", &values.Reading.PhoneticCharacterHints},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise actual i3 IPC on a private Xvfb display; never touch the live WM."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
from native_test_session import run_private_session
|
||||
from native_x11 import wait_for
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def inside():
|
||||
env = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off")
|
||||
subprocess.run(["go", "test", "./internal/i3ipc", "-run", "^TestNativeI3Unavailable$", "-count=1", "-v"],
|
||||
cwd=ROOT, env=dict(env, CTHULHU_NATIVE_I3_UNAVAILABLE="1"), check=True, timeout=60)
|
||||
with tempfile.TemporaryDirectory(prefix="cthulhu-i3-") as directory:
|
||||
config = Path(directory) / "config"
|
||||
config.write_text('font pango:monospace 8\nfocus_follows_mouse no\n'
|
||||
'mode "personal" {\n bindsym Escape mode "default"\n}\n'
|
||||
'mode "ratpoison" {\n bindsym Escape mode "default"\n}\n'
|
||||
'mode "panel" {\n bindsym Escape mode "default"\n}\n'
|
||||
'mode --pango_markup "<b>marked & mode</b>" {\n bindsym Escape mode "default"\n}\n')
|
||||
with (Path(directory) / "i3.log").open("w+") as log:
|
||||
wm = subprocess.Popen(["i3", "-c", str(config)], env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
def ready():
|
||||
if wm.poll() is not None:
|
||||
log.seek(0)
|
||||
raise RuntimeError(log.read())
|
||||
result = subprocess.run(["i3", "--get-socketpath"], env=env, capture_output=True, text=True)
|
||||
return result.returncode == 0 and Path(result.stdout.strip()).is_socket()
|
||||
wait_for(ready, "private i3 did not start")
|
||||
subprocess.run(["go", "test", "./internal/i3ipc", "-run", "^TestNativeI3Events$", "-count=1", "-v", "-timeout=30s"],
|
||||
cwd=ROOT, env=dict(env, CTHULHU_NATIVE_I3="1"), check=True, timeout=60)
|
||||
finally:
|
||||
wm.terminate()
|
||||
wm.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
if "--inside" in sys.argv:
|
||||
inside()
|
||||
return
|
||||
read_fd, write_fd = os.pipe()
|
||||
server = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"],
|
||||
pass_fds=(write_fd,), stderr=subprocess.DEVNULL)
|
||||
os.close(write_fd)
|
||||
try:
|
||||
with os.fdopen(read_fd) as reader:
|
||||
display = reader.readline().strip()
|
||||
assert display, "Xvfb failed"
|
||||
env = dict(os.environ, DISPLAY=":" + display)
|
||||
for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "I3SOCK"):
|
||||
env.pop(key, None)
|
||||
run_private_session(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"],
|
||||
env=env, check=True, timeout=150)
|
||||
finally:
|
||||
server.terminate()
|
||||
server.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -95,7 +95,9 @@ def inside():
|
||||
responses = ResponseReader(process.stdout)
|
||||
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
|
||||
"SpeechHistoryLimit": 50,
|
||||
"Reading": {"AutomaticallyReadTitleChanges": True},
|
||||
"Reading": {"AutomaticallyReadTitleChanges": True,
|
||||
"I3Modes": True, "I3Workspaces": True, "I3WindowStates": True,
|
||||
"I3Urgency": True, "I3Marks": True, "I3WorkspaceMoves": True},
|
||||
"KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"],
|
||||
"Punctuation": 1, "RaisePitchForCapitals": True,
|
||||
"BeepForCapitals": False, "SayCapForCapitals": False,
|
||||
@@ -1067,6 +1069,23 @@ def inside():
|
||||
assert descriptionCell.name and descriptionCell.name != settingCell.name, settingCell.name
|
||||
checked = stateCell.getState().contains(pyatspi.STATE_CHECKED)
|
||||
assert stateCell.name == ("checked" if checked else "unchecked"), stateCell.name
|
||||
i3Choices = {
|
||||
"I3Modes": "Announce i3 binding modes",
|
||||
"I3Workspaces": "Announce i3 workspaces",
|
||||
"I3WindowStates": "Announce i3 window states",
|
||||
"I3Urgency": "Announce i3 urgent windows",
|
||||
"I3Marks": "Announce i3 marks",
|
||||
"I3WorkspaceMoves": "Announce i3 window workspace moves",
|
||||
}
|
||||
i3Rows = {}
|
||||
for field, label in i3Choices.items():
|
||||
row = next(row for row in range(readingTable.nRows)
|
||||
if readingTable.getAccessibleAt(row, 1).name == label)
|
||||
i3Rows[field] = row
|
||||
cell = readingTable.getAccessibleAt(row, 0)
|
||||
assert cell.name == "checked", (label, cell.name)
|
||||
focus(cell); key("space")
|
||||
wait_for(lambda cell=cell: cell.name == "unchecked", label + " did not toggle")
|
||||
titleRow = next(row for row in range(readingTable.nRows)
|
||||
if readingTable.getAccessibleAt(row, 1).name == "Automatically read title changes")
|
||||
titleCell = readingTable.getAccessibleAt(titleRow, 0)
|
||||
@@ -1133,6 +1152,8 @@ def inside():
|
||||
focus(named("Apply")); key("space")
|
||||
applied = request()
|
||||
assert applied["Values"]["Reading"]["OnlySpeakDisplayedText"] != wasChecked, applied
|
||||
for field in i3Choices:
|
||||
assert not applied["Values"]["Reading"][field], applied
|
||||
assert not applied["Values"]["Reading"]["AutomaticallyReadTitleChanges"], applied
|
||||
assert applied["Values"]["Reading"]["AutomaticallyReadClipboardChanges"], applied
|
||||
assert applied["Values"]["Reading"]["PhoneticCharacterHints"], applied
|
||||
@@ -1143,6 +1164,8 @@ def inside():
|
||||
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
||||
assert readingTable.getAccessibleAt(0, 0).name == ("unchecked" if wasChecked else "checked")
|
||||
assert readingTable.getAccessibleAt(titleRow, 0).name == "unchecked"
|
||||
for row in i3Rows.values():
|
||||
assert readingTable.getAccessibleAt(row, 0).name == "unchecked"
|
||||
assert readingTable.getAccessibleAt(clipboardRow, 0).name == "checked"
|
||||
assert readingTable.getAccessibleAt(hintRow, 0).name == "checked"
|
||||
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)
|
||||
|
||||
Reference in new issue
Block a user