Add global command bypass with Cthulhu+Z

This commit is contained in:
ak47 committed 2026-11-07 22:07:03 -05:00
1 parent d01965c00d
commit 4c95c9f278
26 files changed
+657 -18

No files matched your search

+16
View File
@@ -183,6 +183,22 @@ application choices override the global value. Sticky browse mode still keeps
browse mode, and sticky focus mode still keeps focus mode. Use Cthulhu+A to
switch back to browse mode when finished interacting.
## Pass command keys to an application
On Xorg, **Cthulhu+Z** toggles command bypass in either keyboard layout.
Cthulhu announces and displays **Cthulhu command keys off.** While bypass is
active, application shortcuts receive keys normally used for Cthulhu commands,
including the configured Cthulhu modifier keys. Automatic speech, key echo,
and Braille continue under their existing settings. Cthulhu+Z remains available
to restore commands and announces **Cthulhu command keys on.**
Switching applications or locking the screen also restores commands. Moving
between windows in the same application keeps bypass active. Bypass is temporary
and does not change your saved preferences or browse/focus mode. Existing XTerm
handoff still takes precedence. Wayland command bypass is not yet supported.
See [command bypass verification](docs/testing/command-bypass.md).
## Emoji menu
On Xorg, press **Cthulhu+Control+E** in either keyboard layout to open the
+65
View File
@@ -0,0 +1,65 @@
# Command bypass
Cthulhu+Z temporarily passes reader command keys to the current application on
Xorg. It retains the bypass shortcut and keeps speech and Braille running.
Switching applications, leaving the foreground client, or locking restores
commands. Same-process window changes and browser content changes preserve
bypass. Accessible application identity is the fallback when native process
identity is unavailable. Unknown foreground samples do not restore commands.
The shared focus gate owns bypass state; command dispatch changes it and the
accessibility event loop observes foreground/application departure. The X11
command owner applies requested routing and grabs after held keys release.
Unbound modifier chords use existing replay, with Caps Lock's native action
included while bypass is active. OCR review ends on entry. Secure input and
XTerm handoff retain their existing precedence. Reader shutdown discards state.
Wayland reports bypass unavailable; its current backend has no replay path.
## Automated checks
Run affected Go packages and build the native listener test executable:
```sh
go test ./internal/input ./internal/app ./internal/platform/xorg -count=1
go test -c -o /tmp/cthulhu-bypass-xorg.test ./internal/platform/xorg
CTHULHU_XRECORD_TEST=/tmp/cthulhu-bypass-xorg.test CTHULHU_NATIVE_LISTENER_ONLY=1 CTHULHU_NATIVE_X11_TESTS='^TestNativeCommandBypass$' python3 tests/native_x11.py
```
The native harness owns a private Xvfb display and D-Bus session. It checks both
layouts with Insert, KP_Insert, and Caps Lock; complete press/release delivery
to an xev client; browse, arrow and keypad pass-through; balanced toggle releases;
Caps Lock's actual lock state; and restoration of browse and global commands.
Go tests check stale-command suppression, continued speech/Braille output,
application departure without accessibility events, unknown foreground samples,
same-application windows, and recovery when bindings are changed during bypass.
These checks do not establish installed-binary behavior, audible speech,
physical keyboard input, or physical Braille output.
## Live acceptance
1. Quit the running reader with Cthulhu+Q. Press Ctrl+Escape to enter i3 personal
mode, then press Shift+G to rebuild and start the reader. Ctrl+Escape and
Shift+G are separate key presses.
2. In a graphical terminal other than XTerm, press Cthulhu+Z. Expect
"Cthulhu command keys off." in speech and a Braille status flash. Type and
use arrows: application input and configured echo should continue. Press
Cthulhu+T: it should reach the terminal and must not read the time.
3. Press Cthulhu+Z again. Expect "Cthulhu command keys on." Press Cthulhu+T:
expect the time, with focus remaining in the terminal.
4. In a browser in browse mode, enable bypass. Use a page with a known letter
shortcut, then press that key. Expect the page's action rather than structural
navigation. Arrows should follow the browser's native behavior. Tab should
still move focus with ordinary speech and Braille presentation.
5. Disable bypass and press H. Expect the next heading. Enable bypass again,
switch to a different application, then press Cthulhu+T. Expect the time
without first toggling bypass off. Return to the browser and verify that H
again navigates by heading when browse mode is active.
6. With bypass enabled, switch between windows of the same application. Expect
bypass to remain active. If Caps Lock is a configured modifier, press and
release it alone during bypass: expect normal lock behavior. Turn it off
again before restoring reader commands.
Report the exact speech, Braille text, focused control, and application key
behavior for any mismatch. Inspect ~/devel/cthulhu-go.log after an unexpected
result, preserving the fresh log before another restart.
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"sync"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
// commandBypass owns the temporary command policy shared by command dispatch
// and focus observation. It lasts until another toggle, application departure,
// screen lock, or reader shutdown. Output policy is independent of bypass.
type commandBypass struct {
mutex sync.Mutex
keys interface{ SetCommandBypass(bool) }
active bool
pid int
bus string
nativePID bool
}
func (bypass *commandBypass) enabled() bool {
if bypass == nil {
return false
}
bypass.mutex.Lock()
defer bypass.mutex.Unlock()
return bypass.active
}
func (bypass *commandBypass) toggle() string {
if bypass == nil {
return "Command bypass is unavailable on this display backend."
}
bypass.mutex.Lock()
defer bypass.mutex.Unlock()
bypass.active = !bypass.active
bypass.keys.SetCommandBypass(bypass.active)
if bypass.active {
return "Cthulhu command keys off."
}
return "Cthulhu command keys on."
}
func (bypass *commandBypass) restore() {
if bypass == nil {
return
}
bypass.mutex.Lock()
defer bypass.mutex.Unlock()
bypass.restoreLocked()
}
func (bypass *commandBypass) restoreLocked() {
if bypass.active {
bypass.active = false
bypass.keys.SetCommandBypass(false)
}
}
func (bypass *commandBypass) observeForeground(client xorg.Foreground) {
if bypass == nil || !client.Known {
return
}
bypass.mutex.Lock()
defer bypass.mutex.Unlock()
if client.Window == 0 || bypass.pid > 0 && client.PID > 0 && bypass.pid != client.PID {
bypass.restoreLocked()
bypass.bus = ""
}
bypass.nativePID = client.Window != 0 && client.PID > 0
if client.Window == 0 || client.PID > 0 {
bypass.pid = client.PID
}
}
func (bypass *commandBypass) observeApplication(bus string) {
if bypass == nil || bus == "" {
return
}
bypass.mutex.Lock()
defer bypass.mutex.Unlock()
// Native process identity also covers applications without AT-SPI and
// keeps browser content processes from looking like application switches.
if !bypass.nativePID && bypass.bus != "" && bypass.bus != bus {
bypass.restoreLocked()
}
bypass.bus = bus
}
+162
View File
@@ -0,0 +1,162 @@
package app
import (
"context"
"log/slog"
"sync/atomic"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
type bypassTestCommands struct {
fakeCommands
bypassed atomic.Bool
changes chan bool
}
func (keys *bypassTestCommands) SetCommandBypass(active bool) {
keys.bypassed.Store(active)
if keys.changes != nil {
keys.changes <- active
}
}
func TestCommandBypassLifetime(t *testing.T) {
keys := &bypassTestCommands{}
bypass := newFocusGate(keys).bypass
bypass.observeForeground(xorg.Foreground{Known: true, Window: 1, PID: 100})
bypass.observeApplication(":1.10")
if bypass.toggle() != "Cthulhu command keys off." || !keys.bypassed.Load() {
t.Fatal("bypass did not enable")
}
bypass.observeForeground(xorg.Foreground{})
bypass.observeForeground(xorg.Foreground{Known: true, Window: 2, PID: 100})
bypass.observeApplication(":1.11")
if !bypass.enabled() {
t.Fatal("same application window or browser process ended bypass")
}
bypass.observeForeground(xorg.Foreground{Known: true, Window: 2})
bypass.observeForeground(xorg.Foreground{Known: true, Window: 3, PID: 200})
if bypass.enabled() || keys.bypassed.Load() {
t.Fatal("application switch did not restore commands")
}
bypass.toggle()
bypass.observeForeground(xorg.Foreground{Known: true})
if bypass.enabled() {
t.Fatal("leaving foreground retained bypass")
}
bypass.observeApplication(":1.20")
bypass.toggle()
bypass.observeApplication(":1.21")
if bypass.enabled() {
t.Fatal("accessible application switch retained bypass without PID")
}
bypass.toggle()
if bypass.toggle() != "Cthulhu command keys on." || keys.bypassed.Load() {
t.Fatal("second toggle did not restore commands")
}
bypass.toggle()
bypass.restore()
if bypass.enabled() || keys.bypassed.Load() {
t.Fatal("lifecycle reset retained bypass")
}
}
func TestCommandBypassRestoresForInaccessibleApplication(t *testing.T) {
adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 4)}, changes: make(chan struct{}, 1)}
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 10, PID: 100}, Epoch: 1}
adapter.state.Store(&state)
keys := &bypassTestCommands{changes: make(chan bool, 4)}
gate := newFocusGate(keys)
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, nil, gate, nil)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.WindowActivatedEvent, ":1.10", "/window", "/button")
focus.Source.Name, focus.Source.Role, focus.Foreground = "Current", "push button", state
adapter.events <- focus
expectActivationSpeech(t, speech.spoken, "Current, push button")
gate.bypass.toggle()
<-keys.changes
next := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 20, PID: 200}, Epoch: 2}
adapter.state.Store(&next)
adapter.changes <- struct{}{}
select {
case active := <-keys.changes:
if active {
t.Fatal("new application retained bypass")
}
case <-time.After(time.Second):
t.Fatal("foreground switch without accessibility did not restore commands")
}
}
func TestCommandBypassDispatchKeepsSpeechAndBraille(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
keys := &bypassTestCommands{fakeCommands: fakeCommands{commands: make(chan input.CommandEvent, 8)}}
gate := newFocusGate(keys)
spoken := &modeTestSpeaker{spoken: make(chan string, 8)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), spoken)
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
display := &modeTestBraille{messages: make(chan string, 8)}
live := &liveSettings{}
live.Store(config.DefaultSettings())
output := outputQueue{speech: worker, braille: display, live: live}
requests := make(chan input.Command, 8)
done := make(chan error, 1)
go func() {
done <- (commandRuntime{logger: slog.New(slog.DiscardHandler), commands: keys, focusGate: gate,
speechOutput: output, live: live, reviewRequests: requests}).run(ctx)
}()
defer func() {
cancel()
if err := <-done; err != nil {
t.Error(err)
}
}()
checkOutput := func(want string) {
t.Helper()
for _, messages := range []chan string{spoken.spoken, display.messages} {
select {
case got := <-messages:
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatal("missing speech or braille")
}
}
}
keys.commands <- input.CommandEvent{Command: input.ToggleCommandBypass}
checkOutput("Cthulhu command keys off.")
keys.commands <- input.CommandEvent{Command: input.PresentCurrentLine}
keys.commands <- input.CommandEvent{Command: input.Quit}
gatedSpeech{speech: output, gate: gate}.Enqueue(speechRequest{Text: "Focused control", Role: "system", Cause: "focus"})
checkOutput("Focused control")
keys.commands <- input.CommandEvent{Command: input.ToggleCommandBypass}
checkOutput("Cthulhu command keys on.")
select {
case command := <-requests:
t.Fatalf("bypassed command dispatched: %s", command)
default:
}
keys.commands <- input.CommandEvent{Command: input.PresentCurrentLine}
select {
case <-requests:
case <-time.After(time.Second):
t.Fatal("restored command did not dispatch")
}
}
+3
View File
@@ -171,6 +171,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
return
}
consumed := handler.acceptFocusOwnership(event, notification)
if focusGate != nil && isFocusGainEvent(event) && !notification && state.owner.applicationBus == event.Source.BusName {
focusGate.bypass.observeApplication(event.Source.BusName)
}
if isFocusGainEvent(event) && !notification && live != nil && live.onApplication != nil &&
state.owner.applicationBus == event.Source.BusName && state.owner.window == eventWindow(event) {
application := handler.titles.applicationForEvent(event)
+6
View File
@@ -155,6 +155,9 @@ func processEventsWithAttributes(
if foreground != nil {
foregroundChanges = foreground.ForegroundChanges()
state.foregroundState = foreground.Foreground()
if focusGate != nil {
focusGate.bypass.observeForeground(state.foregroundState.Client)
}
}
titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground, inputSource: inputSource}
titles.source, _ = adapter.(windowTitleSource)
@@ -213,6 +216,9 @@ func processEventsWithAttributes(
}
syncForeground := func() {
state.syncForeground(foreground, logger, clearOwnedFocus)
if focusGate != nil && foreground != nil {
focusGate.bypass.observeForeground(state.foregroundState.Client)
}
if wineReview != nil {
wineReview.foregroundChanged()
}
+4
View File
@@ -30,6 +30,7 @@ type commandGrabController interface {
}
type focusGate struct {
bypass *commandBypass
tentacleOutput *tentacleOutput
remotePolicy *tentacle.RemotePolicy
@@ -46,6 +47,9 @@ type focusGate struct {
func newFocusGate(commands commandListener) *focusGate {
gate := &focusGate{lockChanges: make(chan struct{}, 1)}
if keys, ok := commands.(interface{ SetCommandBypass(bool) }); ok {
gate.bypass = &commandBypass{keys: keys}
}
if controller, ok := commands.(commandGrabController); ok {
gate.commands = controller
}
+2
View File
@@ -158,6 +158,8 @@ func learnCommandDescription(command input.Command) string {
return "Show the actions available for the current object"
case input.ToggleLearnMode:
return "Enter or exit learn mode."
case input.ToggleCommandBypass:
return "Pass command keys to the application until pressed again or you switch applications."
case input.DisableTentacles:
return "Disable all tentacles until Cthulhu restarts."
case input.Quit:
+25
View File
@@ -133,11 +133,36 @@ func (worker commandRuntime) run(ctx context.Context) error {
continue
}
command := event.Command
var bypass *commandBypass
if focusGate != nil {
bypass = focusGate.bypass
}
if command != input.ToggleCommandBypass && command != input.OCRDismiss && bypass.enabled() {
continue
}
if speechMessages != nil {
speechMessages.ProtectCommand(event.Timestamp)
}
focusGate.dismiss_confirmation()
logger.Debug("keyboard command received", "command", command, "input_timestamp", event.Timestamp)
if command == input.ToggleCommandBypass {
if focusGate != nil {
focusGate.mutex.Lock()
if focusGate.Locked() || focusGate.Suspended() {
focusGate.mutex.Unlock()
continue
}
}
message := bypass.toggle()
if focusGate != nil {
focusGate.mutex.Unlock()
}
if bypass.enabled() {
worker.ocr.Retire()
}
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "command-bypass"})
continue
}
if worker.internalUI.Handle(event) {
continue
}
+3
View File
@@ -139,6 +139,9 @@ func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputLis
if !gate.secure.CompareAndSwap(!active, active) {
return
}
if active {
gate.bypass.restore()
}
gate.retireTentacleOutput()
gate.sync_remote_policy()
for _, target := range []any{adapter, listener, commands} {
+2
View File
@@ -63,6 +63,8 @@ func CommandLabel(command Command) string {
return "List " + strings.ToLower(binding.Title)
}
switch command {
case ToggleCommandBypass:
return "Toggle passing command keys to the application"
case PresentCurrentLine:
return "Read the current line"
case PresentWindowTitle:
+16
View File
@@ -0,0 +1,16 @@
package input
// BypassCommandBindings leaves only the command which restores reader keys.
// Keep a recovery shortcut if preferences remove it during an active bypass.
func BypassCommandBindings(bindings CommandBindings) CommandBindings {
result := make(CommandBindings)
for chord, command := range bindings {
if command == ToggleCommandBypass {
result[chord] = command
}
}
if len(result) == 0 {
result[CommandChord{Symbol: 'z', CthulhuModifier: true}] = ToggleCommandBypass
}
return result
}
+28
View File
@@ -0,0 +1,28 @@
package input
import "testing"
func TestBypassRetainsOnlyRecoveryCommand(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
bindings := BrowseCommandBindings(layout)
chord := CommandChord{Symbol: 'z', CthulhuModifier: true}
if bindings[chord] != ToggleCommandBypass {
t.Fatal("missing default bypass shortcut")
}
filtered := BypassCommandBindings(bindings)
if len(filtered) != 1 || filtered[chord] != ToggleCommandBypass {
t.Fatalf("bypass bindings = %v", filtered)
}
delete(bindings, chord)
custom := CommandChord{Symbol: 0xffc6}
bindings[custom] = ToggleCommandBypass
filtered = BypassCommandBindings(bindings)
if len(filtered) != 1 || filtered[custom] != ToggleCommandBypass {
t.Fatalf("custom bypass bindings = %v", filtered)
}
delete(bindings, custom)
if BypassCommandBindings(bindings)[chord] != ToggleCommandBypass {
t.Fatal("missing recovery after shortcut removal")
}
}
}
+2
View File
@@ -23,6 +23,7 @@ const (
CycleProgressBarOutput Command = "cycle-progress-bar-output"
PresentVersion Command = "present-version"
ToggleLearnMode Command = "toggle-learn-mode"
ToggleCommandBypass Command = "toggle-command-bypass"
EmojiMenu Command = "emoji-menu"
ShowActions Command = "show-actions"
InternalUIKey Command = "internal-ui-key"
@@ -220,6 +221,7 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings {
{Symbol: 'v', Modifiers: 1, CthulhuModifier: true}: PresentVersion,
{Symbol: 'a', CthulhuModifier: true}: ToggleDocumentMode,
{Symbol: 'h', CthulhuModifier: true}: ToggleLearnMode,
{Symbol: 'z', CthulhuModifier: true}: ToggleCommandBypass,
{Symbol: 'h', Modifiers: 1, CthulhuModifier: true}: DocumentPreviousHeading,
{Symbol: 'k', CthulhuModifier: true, Modifiers: controlModifier}: DocumentNextLink,
{Symbol: 'k', Modifiers: controlModifier | 1, CthulhuModifier: true}: DocumentPreviousLink,
@@ -0,0 +1,179 @@
package xorg
import (
"bufio"
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestNativeCommandBypass(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_X11") != "1" {
t.Skip("requires isolated X11 display")
}
for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} {
for _, modifier := range []string{"Insert", "KP_Insert", "Caps_Lock"} {
t.Run(string(rune('0'+layout))+"/"+modifier, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
previousFocus, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output()
if err != nil {
t.Fatal(err)
}
defer exec.Command("xdotool", "windowfocus", strings.TrimSpace(string(previousFocus))).Run()
probe := exec.CommandContext(ctx, "stdbuf", "-oL", "xev", "-name", "Cthulhu Bypass Probe", "-event", "keyboard")
out, err := probe.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := probe.Start(); err != nil {
t.Fatal(err)
}
defer func() { probe.Process.Kill(); probe.Wait() }()
seen := make(chan string, 512)
go func() {
scanner := bufio.NewScanner(out)
for scanner.Scan() {
if line := scanner.Text(); strings.Contains(line, "keysym") {
select {
case seen <- line:
case <-ctx.Done():
return
}
}
}
}()
key := func(args ...string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool %v: %v %s", args, err, out)
}
}
window := ""
for window == "" {
out, _ := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Bypass Probe$").Output()
window = strings.TrimSpace(string(out))
if ctx.Err() != nil {
t.Fatal(ctx.Err())
}
}
key("windowfocus", window)
listener := NewCommands([]string{modifier}, layout)
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
wait := func(check func() bool) {
t.Helper()
for !check() {
select {
case err := <-listener.Failures():
t.Fatal(err)
case <-ctx.Done():
t.Fatal(ctx.Err())
case <-time.After(time.Millisecond):
}
}
}
next := func(want input.Command) {
t.Helper()
select {
case event := <-listener.Commands():
if event.Command != want {
t.Fatalf("command = %s, want %s", event.Command, want)
}
case err := <-listener.Failures():
t.Fatal(err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
listener.SetBrowseNavigation(true)
wait(listener.BrowseNavigationEnabled)
key("keydown", modifier, "keydown", "z")
next(input.ToggleCommandBypass)
listener.SetCommandBypass(true)
// A binding update acknowledges only after the complete chord.
applied := make(chan error, 1)
go func() { applied <- listener.SetKeyBindings(ctx, nil) }()
select {
case err := <-applied:
t.Fatalf("update applied with chord held: %v", err)
case <-time.After(25 * time.Millisecond):
}
key("keyup", modifier, "keyup", "z")
if err := <-applied; err != nil {
t.Fatal(err)
}
wait(listener.CommandBypassEnabled)
if listener.BrowseNavigationEnabled() {
t.Fatal("browse navigation survived bypass")
}
key("key", "h", "Down", "KP_Begin")
key("keydown", modifier, "key", "s", "keyup", modifier)
key("key", "F12")
counts := map[string]int{}
for counts["F12"] < 2 {
select {
case line := <-seen:
for _, name := range []string{"h", "Down", "KP_Begin", modifier, "s", "F12", "z"} {
if strings.Contains(strings.ToLower(line), strings.ToLower(", "+name+")")) {
counts[name]++
}
}
case <-ctx.Done():
t.Fatalf("application events = %v: %v", counts, ctx.Err())
}
}
for _, name := range []string{"h", "Down", "KP_Begin", modifier, "s"} {
if counts[name] != 2 {
t.Fatalf("application events = %v; want one press/release for %s", counts, name)
}
}
if counts["z"] != 0 {
t.Fatal("bypass toggle leaked to application")
}
select {
case event := <-listener.Commands():
t.Fatalf("command escaped bypass: %s", event.Command)
default:
}
// Caps Lock's native action is restored by the replayed chord.
if modifier == "Caps_Lock" {
select {
case state := <-listener.LockStates():
if state.Name != "Caps Lock" || !state.Locked {
t.Fatalf("lock = %+v", state)
}
case <-ctx.Done():
t.Fatal("Caps Lock did not toggle during bypass")
}
key("key", "Caps_Lock")
select {
case state := <-listener.LockStates():
if state.Locked {
t.Fatal("Caps Lock did not unlock during bypass")
}
case <-ctx.Done():
t.Fatal("missing Caps Lock unlock")
}
}
key("keydown", modifier, "key", "z", "keyup", modifier)
next(input.ToggleCommandBypass)
listener.SetCommandBypass(false)
wait(func() bool { return !listener.CommandBypassEnabled() && listener.BrowseNavigationEnabled() })
key("key", "h")
next(input.DocumentNextHeading)
listener.SetBrowseNavigation(false)
wait(func() bool { return !listener.browseApplied.Load() })
key("keydown", modifier, "key", "s", "keyup", modifier)
next(input.CycleSpeechMode)
})
}
}
}
+8 -1
View File
@@ -15,6 +15,8 @@ type CommandListener struct {
ocrPending atomic.Bool
browse atomic.Bool
browseApplied atomic.Bool
bypass atomic.Bool
bypassApplied atomic.Bool
nativeButtonFocus atomic.Bool
nativeButtonApplied atomic.Bool
grabActive atomic.Bool
@@ -82,6 +84,11 @@ func NewCommands(modifiers []string, layout input.KeyboardLayout) *CommandListen
// complete chords, preserving every key release during a mode transition.
func (listener *CommandListener) SetBrowseNavigation(enabled bool) { listener.browse.Store(enabled) }
// SetCommandBypass retains only the bypass shortcut after held keys release.
func (listener *CommandListener) SetCommandBypass(enabled bool) { listener.bypass.Store(enabled) }
func (listener *CommandListener) CommandBypassEnabled() bool { return listener.bypassApplied.Load() }
// SetNativeButtonFocus leaves Return to a standard button with real DOM focus.
// Virtual browse targets elsewhere still use Cthulhu's document activation.
func (listener *CommandListener) SetNativeButtonFocus(focused bool) {
@@ -94,7 +101,7 @@ func (listener *CommandListener) SetCommandGrabs(enabled bool) {
// BrowseNavigationEnabled reports whether requested browse grabs have reached X11.
func (listener *CommandListener) BrowseNavigationEnabled() bool {
return !listener.ocrApplied.Load() && !listener.secure.Load() && listener.browse.Load() && listener.browseApplied.Load() && listener.commandsApplied.Load()
return !listener.bypass.Load() && !listener.ocrApplied.Load() && !listener.secure.Load() && listener.browse.Load() && listener.browseApplied.Load() && listener.commandsApplied.Load()
}
func (listener *CommandListener) CommandGrabsEnabled() bool {
+5 -2
View File
@@ -159,7 +159,10 @@ func configure_native_bindings(device *C.CommandDevice, layout input.KeyboardLay
}
global := resolvedBindings(layout, false, overrides)
browse := resolvedBindings(layout, true, overrides)
if ocrReview {
if device.bypass != 0 {
global = input.BypassCommandBindings(global)
browse = global
} else if ocrReview {
browse = input.OCRCommandBindings(global)
if len(pending) > 0 && pending[0] {
browse = input.OCRPendingBindings(global)
@@ -202,7 +205,7 @@ func configure_native_bindings(device *C.CommandDevice, layout input.KeyboardLay
// Own Control/Shift from their press in temporary review. Otherwise the
// application can see a modifier press whose release the arrow grab owns.
// Unbound chords are replayed whole after all their physical keys release.
if ocrReview && !(len(pending) > 0 && pending[0]) {
if device.bypass == 0 && ocrReview && !(len(pending) > 0 && pending[0]) {
for _, symbol := range []uint32{0xffe1, 0xffe2, 0xffe3, 0xffe4} {
for _, mask := range []uint16{0, 1, 4, 5} {
native = append(native, C.NativeBinding{symbol: C.ulong(symbol), modifiers: C.uint(mask), browseOnly: 1})
+1 -1
View File
@@ -147,7 +147,7 @@ func (owner *commandOwner) handleRoutedKey(key C.CommandKey) bool {
}
func (owner *commandOwner) finishChord() bool {
if !owner.bound && owner.device.capsCode != 0 && is_caps_lock_toggle(owner.buffered) {
if owner.device.bypass == 0 && !owner.bound && owner.device.capsCode != 0 && is_caps_lock_toggle(owner.buffered) {
C.toggle_command_caps_lock(owner.device)
if result := C.replay_command_keys(owner.device, &owner.buffered[0], C.int(len(owner.buffered))); result != 0 {
owner.listener.failures <- fmt.Errorf("release Caps Lock toggle chord: native error %d", int(result))
+6 -1
View File
@@ -674,7 +674,7 @@ int replay_command_keys(CommandDevice *device, CommandKey *keys, int count) {
for (int i=0;i<count;i++) {
// Replaying an unbound chord must not restore Caps Lock's lock action
// while that key is configured as the reader modifier.
if (device->capsCode && keys[i].code==device->capsCode) continue;
if (!device->bypass && device->capsCode && keys[i].code==device->capsCode) continue;
if (!XTestFakeKeyEvent(device->display,keys[i].code,keys[i].pressed,CurrentTime)) injected=0;
}
if (device->commands) grab_command_keys(device);
@@ -683,6 +683,11 @@ int replay_command_keys(CommandDevice *device, CommandKey *keys, int count) {
int error=grab_error;
error_display=NULL;
pthread_mutex_unlock(&error_mutex);
if (device->bypass && device->capsCode) {
XkbStateRec state;
if (XkbGetState(device->display,XkbUseCoreKbd,&state)!=Success) return -7;
device->capsLocked=(state.locked_mods & LockMask)!=0;
}
if (!injected) return -1;
return error;
}
+1
View File
@@ -18,6 +18,7 @@ typedef struct {
struct xkb_compose_state *composeState;
unsigned int lockIndicators[3];
int commands;
int bypass;
int active;
int internalUI;
int localKeyboard, xiOpcode;
+5 -2
View File
@@ -67,10 +67,13 @@ func (listener *CommandListener) set_ocr_context(ctx context.Context, enabled, p
}
func (listener *CommandListener) OCRReviewEnabled() bool {
return !listener.ocrPending.Load() && !listener.secure.Load() && listener.commandsEnabled.Load() && listener.commandsApplied.Load() && listener.ocrApplied.Load()
return !listener.bypass.Load() && !listener.ocrPending.Load() && !listener.secure.Load() && listener.commandsEnabled.Load() && listener.commandsApplied.Load() && listener.ocrApplied.Load()
}
func (owner *commandOwner) context_bindings() input.CommandBindings {
if owner.device.bypass != 0 {
return input.BypassCommandBindings(owner.globalBindings)
}
if owner.ocrReview {
if owner.ocrPending {
return input.OCRPendingBindings(owner.globalBindings)
@@ -85,7 +88,7 @@ func (owner *commandOwner) context_bindings() input.CommandBindings {
func configure_context_bindings(device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, overrides []input.BindingOverride, browse, ocrReview bool, pending ...bool) error {
previous, enabled := device.browse, device.commands
device.browse = 0
if browse || ocrReview {
if device.bypass == 0 && (browse || ocrReview) {
device.browse = 1
}
err := configure_native_bindings(device, layout, modifiers, overrides, ocrReview, pending...)
+1
View File
@@ -67,6 +67,7 @@ func (listener *CommandListener) Start(ctx context.Context) error {
defer listener.browseApplied.Store(false)
defer listener.ocrApplied.Store(false)
defer listener.commandsApplied.Store(false)
defer listener.bypassApplied.Store(false)
listener.commandsApplied.Store(true)
layout := listener.layout
modifiers := append([]string(nil), listener.modifiers...)
+24 -8
View File
@@ -164,7 +164,7 @@ func (owner *commandOwner) applyBindingUpdate() bool {
nextOverrides, nextOCR = owner.overrides, *update.ocrReview
}
err := update.ctx.Err()
if err == nil && nextOCR && (!owner.commandGrabsAllowed() || owner.learnMode) {
if err == nil && nextOCR && (!owner.commandGrabsAllowed() || owner.learnMode || owner.listener.bypass.Load()) {
err = errors.New("OCR review input unavailable")
}
if err == nil && nextOCR {
@@ -226,6 +226,26 @@ func (owner *commandOwner) resetForSecureInput() {
}
func (owner *commandOwner) syncGrabs() bool {
if len(owner.held) == 0 && (owner.device.bypass != 0) != owner.listener.bypass.Load() {
if owner.ocrReview {
if !owner.leave_ocr_review() || !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: input.OCRDismiss}) {
return false
}
}
previous := owner.device.bypass
owner.device.bypass = 0
if owner.listener.bypass.Load() {
owner.device.bypass = 1
}
if err := configure_context_bindings(owner.device, owner.layout, owner.modifiers, owner.overrides, owner.listener.browse.Load(), false); err != nil {
owner.device.bypass = previous
owner.listener.failures <- err
return false
}
owner.router.ReplaceBindings(owner.context_bindings())
owner.listener.bypassApplied.Store(owner.device.bypass != 0)
owner.listener.browseApplied.Store(owner.device.browse != 0)
}
if owner.ocrReview && len(owner.held) == 0 {
foreground := C.read_foreground(owner.device.display)
if foreground.known == 0 || foreground.window == 0 || foreground.window != owner.device.browseWindow {
@@ -265,8 +285,8 @@ func (owner *commandOwner) syncGrabs() bool {
owner.listener.commandsApplied.Store(enabled)
}
if !owner.ocrReview && len(owner.held) == 0 && (owner.device.commands != 0) &&
(owner.device.browse != 0) != owner.listener.browse.Load() {
enabled := owner.listener.browse.Load()
(owner.device.browse != 0) != (owner.listener.browse.Load() && owner.device.bypass == 0) {
enabled := owner.listener.browse.Load() && owner.device.bypass == 0
value := C.int(0)
if enabled {
value = 1
@@ -275,11 +295,7 @@ func (owner *commandOwner) syncGrabs() bool {
owner.listener.failures <- fmt.Errorf("change X11 browse grabs: native error %d", int(err))
return false
}
bindings := resolvedBindings(owner.layout, false, owner.overrides)
if enabled {
bindings = resolvedBindings(owner.layout, true, owner.overrides)
}
owner.router.ReplaceBindings(bindings)
owner.router.ReplaceBindings(owner.context_bindings())
owner.listener.browseApplied.Store(enabled)
}
owner.device.nativeButtonFocus = 0
@@ -1100,7 +1100,7 @@ func TestNativeModifierConfigurationUpdate(t *testing.T) {
if after := lockState(); after != before {
t.Fatalf("configured Caps Lock alone toggled capitalization: %s -> %s", before, after)
}
key("keydown", "Caps_Lock", "key", "z", "keyup", "Caps_Lock")
key("keydown", "Caps_Lock", "key", "y", "keyup", "Caps_Lock")
if after := lockState(); after != before {
t.Fatalf("unbound Caps Lock chord toggled capitalization: %s -> %s", before, after)
}
+1
View File
@@ -77,3 +77,4 @@ web-message-article-focus,src/cthulhu/scripts/web/speech_generator.py;src/cthulh
web-disclosure-refresh,src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py,web,Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation,,internal/atspi internal/app,implemented,"CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py",Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile.,,Original-page audible speech acceptance pending.,Physical Braille acceptance pending.,Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
mumble-message-history,src/cthulhu/scripts/apps/Mumble/chat.py;src/cthulhu/chat.py,messaging,Review or copy twelve recent Mumble Activity log messages with Cthulhu+F1 through F12,,internal/atspi internal/messaging internal/app internal/input,in-progress,Message cleanup and twelve-slot ordering tests; focused messaging race checks; python3 -B tests/native_messaging.py.,Private Qt transcript and unrelated-provider checks; synthetic X11 review and copy chords through F12; clipboard checks.,,Recorded presentation tested; actual Mumble listening pending.,Single flash and review text tested; physical display pending.,Legacy combined history defaults. Strip timestamps and Qt noncharacters; ignore timestamp-only insertions; preserve repeated messages and keyboard focus. Pidgin keeps its nine-message review range. History is session-only and clears on lock or transcript retirement. See docs/testing/messaging.md for live acceptance.
web-inline-visual-lines,Orca src/orca/scripts/web/script_utilities.py;src/orca/ax_utilities.py,web,Group same-row ordinary links and list items during browse line navigation,,internal/review internal/presentation internal/app internal/atspi,in-progress,Review AT-SPI presentation and app package tests; private browser fragments lists namedlinks linkedheadings document selection; Firefox baseline and tabs,Private Brave and Firefox Xvfb sessions,,Speech requests verified; live listening pending,Native fragment routing checked; physical display acceptance pending,See docs/testing/browser-visual-lines.md. Preserve block boundaries and grid items; atomic named controls remain independent. Human review and original-page live acceptance pending.
command-bypass,Orca src/orca/bypass_mode_manager.py;src/orca/bypass_mode_manager_command_definitions.py,input,Pass reader command keys to the current application with Cthulhu+Z while retaining automatic output,,internal/input internal/app internal/platform/xorg,in-progress,go test ./internal/input ./internal/app ./internal/platform/xorg -count=1; focused race tests; see docs/testing/command-bypass.md,Private Xvfb and D-Bus listener checks pass for both layouts and all three modifier keys; verifies key delivery and Caps Lock state plus restoration and related grab regressions.,Not implemented; reports bypass unavailable.,Queued speech and continued automatic output tested; audible acceptance pending.,Braille flash delivery tested; physical display acceptance pending.,Restores on second toggle or application departure or screen lock. Same-application windows preserve bypass. Existing XTerm handoff retained. Human review and live acceptance pending.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
77 web-disclosure-refresh src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py web Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation internal/atspi internal/app implemented CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile. Original-page audible speech acceptance pending. Physical Braille acceptance pending. Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
78 mumble-message-history src/cthulhu/scripts/apps/Mumble/chat.py;src/cthulhu/chat.py messaging Review or copy twelve recent Mumble Activity log messages with Cthulhu+F1 through F12 internal/atspi internal/messaging internal/app internal/input in-progress Message cleanup and twelve-slot ordering tests; focused messaging race checks; python3 -B tests/native_messaging.py. Private Qt transcript and unrelated-provider checks; synthetic X11 review and copy chords through F12; clipboard checks. Recorded presentation tested; actual Mumble listening pending. Single flash and review text tested; physical display pending. Legacy combined history defaults. Strip timestamps and Qt noncharacters; ignore timestamp-only insertions; preserve repeated messages and keyboard focus. Pidgin keeps its nine-message review range. History is session-only and clears on lock or transcript retirement. See docs/testing/messaging.md for live acceptance.
79 web-inline-visual-lines Orca src/orca/scripts/web/script_utilities.py;src/orca/ax_utilities.py web Group same-row ordinary links and list items during browse line navigation internal/review internal/presentation internal/app internal/atspi in-progress Review AT-SPI presentation and app package tests; private browser fragments lists namedlinks linkedheadings document selection; Firefox baseline and tabs Private Brave and Firefox Xvfb sessions Speech requests verified; live listening pending Native fragment routing checked; physical display acceptance pending See docs/testing/browser-visual-lines.md. Preserve block boundaries and grid items; atomic named controls remain independent. Human review and original-page live acceptance pending.
80 command-bypass Orca src/orca/bypass_mode_manager.py;src/orca/bypass_mode_manager_command_definitions.py input Pass reader command keys to the current application with Cthulhu+Z while retaining automatic output internal/input internal/app internal/platform/xorg in-progress go test ./internal/input ./internal/app ./internal/platform/xorg -count=1; focused race tests; see docs/testing/command-bypass.md Private Xvfb and D-Bus listener checks pass for both layouts and all three modifier keys; verifies key delivery and Caps Lock state plus restoration and related grab regressions. Not implemented; reports bypass unavailable. Queued speech and continued automatic output tested; audible acceptance pending. Braille flash delivery tested; physical display acceptance pending. Restores on second toggle or application departure or screen lock. Same-application windows preserve bypass. Existing XTerm handoff retained. Human review and live acceptance pending.
+3 -2
View File
@@ -175,9 +175,10 @@ def run_inside(directory):
subprocess.run(['xdotool', 'windowfocus', window], check=True)
nativeTest = os.environ.get('CTHULHU_XRECORD_TEST')
if nativeTest:
subprocess.run([nativeTest, '-test.run', '^TestNative(ModifierPressRelease|CommandGrabSuspensionAndRestoration|BrowseKeyTransitions|DispatchBeforeRelease|CustomBindingsUpdateAndRelease|TentacleBindingsUpdateAndRelease|ModifierConfigurationUpdate|LockStatesAndShiftCapsLock|XTermHandoffKeepsKeypadReleased|SuspendedQueuedKey|LearnModeCapturesKeysAndRestoresCommands|SecureInputDropsKeysAndRestoresGrabs)$', '-test.timeout=40s'], env=dict(os.environ, CTHULHU_NATIVE_X11='1', CTHULHU_NATIVE_BROWSER='1'), check=True, timeout=45, cwd=Path(__file__).resolve().parents[1] / 'internal/platform/xorg')
native_tests = os.environ.get('CTHULHU_NATIVE_X11_TESTS', '^TestNative(ModifierPressRelease|CommandBypass|CommandGrabSuspensionAndRestoration|BrowseKeyTransitions|DispatchBeforeRelease|CustomBindingsUpdateAndRelease|TentacleBindingsUpdateAndRelease|ModifierConfigurationUpdate|LockStatesAndShiftCapsLock|XTermHandoffKeepsKeypadReleased|SuspendedQueuedKey|LearnModeCapturesKeysAndRestoresCommands|SecureInputDropsKeysAndRestoresGrabs)$')
subprocess.run([nativeTest, '-test.run', native_tests, '-test.timeout=40s'], env=dict(os.environ, CTHULHU_NATIVE_X11='1', CTHULHU_NATIVE_BROWSER='1'), check=True, timeout=45, cwd=Path(__file__).resolve().parents[1] / 'internal/platform/xorg')
if nativeTest and os.environ.get('CTHULHU_NATIVE_LISTENER_ONLY') == '1':
print('PASS: native command bindings, press/release ordering, browse transitions, suspension, custom updates, and grab rollback')
print('PASS: selected native X11 command listener tests')
return
app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
processes.append(app)