Add global command bypass with Cthulhu+Z
This commit is contained in:
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user