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

@@ -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)
}