More work on tentacles.
This commit is contained in:
1 parent
573d15d7b4
commit
4cf3335017
29 files changed
+1194
-66
No files matched your search
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "commands_native.h"
|
||||
#include <X11/extensions/XInput2.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/XKBlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
// Xorg marks XTEST devices with a non-deletable, read-only property. Device
|
||||
// names and the core send_event flag are not provenance. Unknown/non-device
|
||||
// sources stay ineligible; no timing match against XRecord is involved.
|
||||
static int local_source(CommandDevice *device, int source) {
|
||||
if (source <= 0 || source == device->localKeyboard) return 0;
|
||||
int count = 0, allowed = 0;
|
||||
cthulhu_x11_begin_read(device->display);
|
||||
XIDeviceInfo *info = XIQueryDevice(device->display, source, &count);
|
||||
if (info && count == 1 && info->use == XISlaveKeyboard && info->enabled &&
|
||||
info->attachment == device->localKeyboard) {
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long items = 0, remaining = 0;
|
||||
unsigned char *data = NULL;
|
||||
Atom xtest = XInternAtom(device->display, "XTEST Device", True);
|
||||
int result = xtest == None ? BadAtom : XIGetProperty(device->display,
|
||||
source, xtest, 0, 1, False, AnyPropertyType, &type, &format,
|
||||
&items, &remaining, &data);
|
||||
// The atom must exist on the server, but not on this source device.
|
||||
int synthetic = result != Success || type != None;
|
||||
if (data) XFree(data);
|
||||
data = NULL;
|
||||
Atom node = XInternAtom(device->display, "Device Node", True);
|
||||
if (!synthetic && node != None && XIGetProperty(device->display, source,
|
||||
node, 0, 64, False, XA_STRING, &type, &format, &items,
|
||||
&remaining, &data) == Success && type == XA_STRING && format == 8 &&
|
||||
items > 16 && items < 256 && remaining == 0 && data &&
|
||||
memcmp(data, "/dev/input/event", 16) == 0) {
|
||||
int valid = 1;
|
||||
for (unsigned long i = 16; i < items; i++)
|
||||
if (data[i] < '0' || data[i] > '9') valid = 0;
|
||||
char path[256];
|
||||
memcpy(path, data, items);
|
||||
path[items] = 0;
|
||||
struct stat status;
|
||||
if (valid && stat(path, &status) == 0 && S_ISCHR(status.st_mode))
|
||||
allowed = 1;
|
||||
}
|
||||
if (data) XFree(data);
|
||||
}
|
||||
if (info) XIFreeDeviceInfo(info);
|
||||
if (cthulhu_x11_end_read(device->display)) allowed = 0;
|
||||
return allowed;
|
||||
}
|
||||
|
||||
static int acquire_local_keyboard_unchecked(CommandDevice *device) {
|
||||
int eventBase, errorBase, major = 2, minor = 0, pointer = 0;
|
||||
if (device->active || !XQueryExtension(device->display, "XInputExtension",
|
||||
&device->xiOpcode, &eventBase, &errorBase) ||
|
||||
XIQueryVersion(device->display, &major, &minor) != Success || major < 2 ||
|
||||
!XIGetClientPointer(device->display, None, &pointer)) return -1;
|
||||
int count = 0, keyboard = 0;
|
||||
XIDeviceInfo *info = XIQueryDevice(device->display, pointer, &count);
|
||||
if (info && count == 1 && info->use == XIMasterPointer && info->enabled)
|
||||
keyboard = info->attachment;
|
||||
if (info) XIFreeDeviceInfo(info);
|
||||
if (!keyboard) return -1;
|
||||
// A key held before the dialog cannot submit it on repeat or provide an
|
||||
// inherited modifier. The caller may retry after releasing that chord.
|
||||
char held[32];
|
||||
XQueryKeymap(device->display, held);
|
||||
for (int i = 0; i < 32; i++) if (held[i]) return -1;
|
||||
unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0};
|
||||
XISetMask(bits, XI_KeyPress);
|
||||
XISetMask(bits, XI_KeyRelease);
|
||||
XIEventMask mask = {keyboard, sizeof(bits), bits};
|
||||
int result = XIGrabDevice(device->display, keyboard, device->root, CurrentTime,
|
||||
None, GrabModeAsync, GrabModeAsync, False, &mask);
|
||||
if (result != GrabSuccess) return result ? result : -1;
|
||||
unsigned char changes[XIMaskLen(XI_LASTEVENT)] = {0};
|
||||
XISetMask(changes, XI_HierarchyChanged);
|
||||
XIEventMask hierarchy = {XIAllDevices, sizeof(changes), changes};
|
||||
XISelectEvents(device->display, device->root, &hierarchy, 1);
|
||||
device->localKeyboard = keyboard;
|
||||
memset(device->localHeld, 0, sizeof(device->localHeld));
|
||||
// Initialize the ordinary text/compose renderer after acquiring XI2. Its
|
||||
// core-grab branch is skipped because the device already owns capture.
|
||||
device->active = 1;
|
||||
acquire_internal_keyboard(device);
|
||||
XFlush(device->display);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int acquire_local_keyboard(CommandDevice *device) {
|
||||
// The selected master can disappear between query and grab. Like source
|
||||
// validation, acquisition must turn BadDevice into denial, not reader exit.
|
||||
cthulhu_x11_begin_read(device->display);
|
||||
int result = acquire_local_keyboard_unchecked(device);
|
||||
int error = cthulhu_x11_end_read(device->display);
|
||||
if (error) {
|
||||
release_local_keyboard(device);
|
||||
device->active = 0;
|
||||
reset_internal_compose(device);
|
||||
return -1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert the very same device event to the existing text renderer. Core and
|
||||
// SendEvent traffic is discarded in this mode, as are XTEST and unknown sources.
|
||||
int next_local_key(CommandDevice *device, XEvent *event) {
|
||||
if (event->type != GenericEvent || event->xcookie.extension != device->xiOpcode ||
|
||||
!XGetEventData(device->display, &event->xcookie)) return 0;
|
||||
int accepted = 0;
|
||||
XKeyEvent key = {0};
|
||||
// Device removal/disable/reattachment invalidates this capture, including
|
||||
// keys whose release can no longer arrive. Reopen against a fresh hierarchy.
|
||||
if (!event->xcookie.send_event && event->xcookie.evtype == XI_HierarchyChanged) accepted = -1;
|
||||
if (event->xcookie.evtype == XI_KeyPress || event->xcookie.evtype == XI_KeyRelease) {
|
||||
XIDeviceEvent *input = event->xcookie.data;
|
||||
if (!event->xcookie.send_event && !input->send_event &&
|
||||
input->deviceid == device->localKeyboard && input->detail >= 8 &&
|
||||
input->detail <= 255 && !(input->flags & XIKeyRepeat) &&
|
||||
local_source(device, input->sourceid)) {
|
||||
int code = input->detail;
|
||||
// Duplicate downs and releases without a down in this capture
|
||||
// lifetime cannot navigate or activate a local form.
|
||||
if ((input->evtype == XI_KeyPress) != !!device->localHeld[code]) {
|
||||
key.type = input->evtype == XI_KeyPress ? KeyPress : KeyRelease;
|
||||
key.display = device->display;
|
||||
key.window = input->event;
|
||||
key.root = input->root;
|
||||
key.time = input->time;
|
||||
key.keycode = code;
|
||||
key.same_screen = True;
|
||||
// Ignore modifiers held by an injected source. Build state
|
||||
// solely from keys admitted during this local capture.
|
||||
for (int i = 8; i < 256; i++) if (device->localHeld[i])
|
||||
key.state |= XkbKeysymToModifiers(device->display,
|
||||
XkbKeycodeToKeysym(device->display, i, 0, 0));
|
||||
device->localHeld[code] = key.type == KeyPress;
|
||||
accepted = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
XFreeEventData(device->display, &event->xcookie);
|
||||
if (accepted > 0) { memset(event, 0, sizeof(*event)); event->xkey = key; }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
void release_local_keyboard(CommandDevice *device) {
|
||||
if (device->localKeyboard) {
|
||||
cthulhu_x11_begin_read(device->display);
|
||||
XIUngrabDevice(device->display, device->localKeyboard, CurrentTime);
|
||||
unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0};
|
||||
XIEventMask mask = {XIAllDevices, sizeof(bits), bits};
|
||||
XISelectEvents(device->display, device->root, &mask, 1);
|
||||
// A removed master has already lost its grab. Still clear our local
|
||||
// ownership and root event selection when XIUngrab reports BadDevice.
|
||||
cthulhu_x11_end_read(device->display);
|
||||
device->localKeyboard = 0;
|
||||
memset(device->localHeld, 0, sizeof(device->localHeld));
|
||||
}
|
||||
}
|
||||
@@ -588,6 +588,11 @@ int next_command_key(CommandDevice *device, CommandKey *key) {
|
||||
key->timestamp=((XkbEvent *)&event)->indicators.time;
|
||||
return 2;
|
||||
}
|
||||
if (device->localKeyboard) {
|
||||
int local = next_local_key(device, &event);
|
||||
if (local < 0) return 3;
|
||||
if (!local) return 0;
|
||||
}
|
||||
if (event.type!=KeyPress && event.type!=KeyRelease) return 0;
|
||||
// Events captured before suspension can still be queued in Xlib after
|
||||
// the grabs are released. Discard that old reader input; it must neither
|
||||
@@ -654,6 +659,7 @@ int next_command_key(CommandDevice *device, CommandKey *key) {
|
||||
return 1;
|
||||
}
|
||||
void release_command_keyboard(CommandDevice *device) {
|
||||
release_local_keyboard(device);
|
||||
XUngrabKeyboard(device->display,CurrentTime); XFlush(device->display); device->active=0;
|
||||
}
|
||||
int replay_command_keys(CommandDevice *device, CommandKey *keys, int count) {
|
||||
|
||||
@@ -20,6 +20,8 @@ typedef struct {
|
||||
int commands;
|
||||
int active;
|
||||
int internalUI;
|
||||
int localKeyboard, xiOpcode;
|
||||
unsigned char localHeld[256];
|
||||
int browse;
|
||||
int nativeButtonFocus;
|
||||
Window browseWindow;
|
||||
@@ -55,6 +57,9 @@ int command_lock_state(CommandDevice *device);
|
||||
void toggle_command_caps_lock(CommandDevice *device);
|
||||
int next_command_key(CommandDevice *device, CommandKey *key);
|
||||
int acquire_internal_keyboard(CommandDevice *device);
|
||||
int acquire_local_keyboard(CommandDevice *device);
|
||||
int next_local_key(CommandDevice *device, XEvent *event);
|
||||
void release_local_keyboard(CommandDevice *device);
|
||||
void reset_internal_compose(CommandDevice *device);
|
||||
void release_command_keyboard(CommandDevice *device);
|
||||
int replay_command_keys(CommandDevice *device, CommandKey *keys, int count);
|
||||
|
||||
@@ -91,6 +91,21 @@ func (owner *commandOwner) run() {
|
||||
owner.publishLocks(key)
|
||||
continue
|
||||
}
|
||||
if result == 3 {
|
||||
// A changed XI2 hierarchy invalidates the local source set. A
|
||||
// removed device may never release its held keys; retire that chord.
|
||||
session := owner.uiSession
|
||||
clear(owner.held)
|
||||
owner.buffered = nil
|
||||
owner.router.Reset()
|
||||
owner.bound, owner.passThrough = false, false
|
||||
owner.dismiss_internal_ui()
|
||||
if session != 0 && !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: input.InternalUIDismiss, UISession: session}) {
|
||||
owner.report_ui_failure(errors.New("internal UI queue overflow"))
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !owner.handleKey(key) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package xorg
|
||||
|
||||
/*
|
||||
#cgo pkg-config: xkbcommon
|
||||
#cgo pkg-config: xi
|
||||
#include "commands_native.h"
|
||||
*/
|
||||
import "C"
|
||||
@@ -26,6 +27,7 @@ type uiUpdate struct {
|
||||
ctx context.Context
|
||||
closeSession uint64
|
||||
result chan uiReply
|
||||
localOnly bool
|
||||
}
|
||||
|
||||
func (listener *CommandListener) InternalUISession() uint64 { return listener.uiSession.Load() }
|
||||
@@ -35,7 +37,14 @@ func (listener *CommandListener) InternalUIEchoActive() bool { return listener.u
|
||||
// BeginInternalUI acknowledges exclusive capture without changing X input
|
||||
// focus. EndInternalUI waits for physical releases, and cannot close a newer UI.
|
||||
func (listener *CommandListener) BeginInternalUI(ctx context.Context) (uint64, error) {
|
||||
return listener.update_internal_ui(ctx, 0)
|
||||
return listener.update_internal_ui(ctx, 0, false)
|
||||
}
|
||||
|
||||
// BeginLocalUI admits only device-backed XI2 events and excludes XTEST. This
|
||||
// is a core API, never a Tentacle-controlled form flag. It fails on unsupported
|
||||
// servers; ordinary forms keep their existing input path.
|
||||
func (listener *CommandListener) BeginLocalUI(ctx context.Context) (uint64, error) {
|
||||
return listener.update_internal_ui(ctx, 0, true)
|
||||
}
|
||||
func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint64) error {
|
||||
if session == 0 {
|
||||
@@ -50,17 +59,17 @@ func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint
|
||||
break
|
||||
}
|
||||
}
|
||||
_, err := listener.update_internal_ui(ctx, session)
|
||||
_, err := listener.update_internal_ui(ctx, session, false)
|
||||
return err
|
||||
}
|
||||
func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64) (uint64, error) {
|
||||
func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64, localOnly bool) (uint64, error) {
|
||||
listener.mutex.Lock()
|
||||
done := listener.done
|
||||
listener.mutex.Unlock()
|
||||
if done == nil {
|
||||
return 0, errors.New("command listener is not running")
|
||||
}
|
||||
update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1)}
|
||||
update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1), localOnly: localOnly}
|
||||
select {
|
||||
case listener.uiUpdates <- update:
|
||||
case <-ctx.Done():
|
||||
@@ -76,7 +85,7 @@ func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSe
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *commandOwner) begin_internal_ui() (uint64, error) {
|
||||
func (owner *commandOwner) begin_internal_ui_mode(localOnly bool) (uint64, error) {
|
||||
if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 {
|
||||
return 0, errors.New("internal UI input unavailable")
|
||||
}
|
||||
@@ -86,7 +95,13 @@ func (owner *commandOwner) begin_internal_ui() (uint64, error) {
|
||||
return 0, errors.New("no focused application")
|
||||
}
|
||||
owner.uiWindow = C.read_foreground(owner.device.display).window
|
||||
if status := C.acquire_internal_keyboard(owner.device); status != 0 {
|
||||
var status C.int
|
||||
if localOnly {
|
||||
status = C.acquire_local_keyboard(owner.device)
|
||||
} else {
|
||||
status = C.acquire_internal_keyboard(owner.device)
|
||||
}
|
||||
if status != 0 {
|
||||
return 0, fmt.Errorf("internal keyboard capture failed: %d", int(status))
|
||||
}
|
||||
owner.uiSession = owner.listener.uiSequence.Add(1)
|
||||
@@ -96,6 +111,10 @@ func (owner *commandOwner) begin_internal_ui() (uint64, error) {
|
||||
return owner.uiSession, nil
|
||||
}
|
||||
|
||||
func (owner *commandOwner) begin_internal_ui() (uint64, error) {
|
||||
return owner.begin_internal_ui_mode(false)
|
||||
}
|
||||
|
||||
func (owner *commandOwner) dismiss_internal_ui() {
|
||||
owner.uiSession = 0
|
||||
owner.listener.uiSession.Store(0)
|
||||
@@ -136,7 +155,7 @@ func (owner *commandOwner) sync_internal_ui() bool {
|
||||
owner.dismiss_internal_ui()
|
||||
}
|
||||
} else {
|
||||
result.session, result.err = owner.begin_internal_ui()
|
||||
result.session, result.err = owner.begin_internal_ui_mode(update.localOnly)
|
||||
}
|
||||
}
|
||||
update.result <- result
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package xorg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func TestNativeLocalUIRejectsInjectedApproval(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_REMOTE_INPUT") != "1" {
|
||||
t.Skip("requires private Remote input harness")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
probe := exec.CommandContext(ctx, "xev", "-name", "Local approval probe", "-event", "keyboard")
|
||||
if err := probe.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }()
|
||||
var window string
|
||||
for window == "" {
|
||||
data, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Local approval probe$").Output()
|
||||
if err == nil {
|
||||
window = strings.TrimSpace(string(data))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("probe unavailable")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)
|
||||
if err := listener.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Stop(context.Background())
|
||||
key := func(args ...string) {
|
||||
t.Helper()
|
||||
if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("private injection: %v %s", err, data)
|
||||
}
|
||||
}
|
||||
key("keydown", "Return")
|
||||
if id, err := listener.BeginLocalUI(ctx); err == nil {
|
||||
_ = listener.EndInternalUI(ctx, id)
|
||||
t.Fatal("capture admitted pre-held key")
|
||||
}
|
||||
key("keyup", "Return")
|
||||
id, err := listener.BeginLocalUI(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// XTEST has send_event=False. Neither navigation, approval, secret entry,
|
||||
// clipboard shortcuts nor Escape may enter this local-only form.
|
||||
key("key", "Tab", "space", "Return", "a", "ctrl+v", "Insert+space", "Insert+shift+a", "Escape")
|
||||
select {
|
||||
case event := <-listener.Commands():
|
||||
t.Fatalf("injected form command: %+v", event)
|
||||
case err := <-listener.Failures():
|
||||
t.Fatal(err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
if listener.InternalUISession() != id {
|
||||
t.Fatal("injected Escape dismissed local form")
|
||||
}
|
||||
if err := listener.EndInternalUI(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ordinary, err := listener.BeginInternalUI(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key("key", "Return")
|
||||
select {
|
||||
case event := <-listener.Commands():
|
||||
if event.Command != input.InternalUIKey || event.UISession != ordinary {
|
||||
t.Fatal(event)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ordinary form stopped receiving keys")
|
||||
}
|
||||
if err := listener.EndInternalUI(ctx, ordinary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Reopening local capture must not inherit the ordinary form's event path.
|
||||
id, err = listener.BeginLocalUI(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data, err := exec.CommandContext(ctx, "xinput", "create-master", "CthulhuProbe").CombinedOutput(); err != nil {
|
||||
t.Fatalf("private hierarchy change: %v %s", err, data)
|
||||
}
|
||||
defer func() {
|
||||
data, err := exec.CommandContext(ctx, "xinput", "list", "--id-only", "CthulhuProbe pointer").Output()
|
||||
if err == nil {
|
||||
if out, err := exec.CommandContext(ctx, "xinput", "remove-master", strings.TrimSpace(string(data))).CombinedOutput(); err != nil {
|
||||
t.Errorf("remove private master: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case event := <-listener.Commands():
|
||||
if event.Command != input.InternalUIDismiss || event.UISession != id {
|
||||
t.Fatal("hierarchy did not retire capture", event)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("hierarchy change left local capture active")
|
||||
}
|
||||
if err := listener.EndInternalUI(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err = listener.BeginLocalUI(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetSecureInputActive(true)
|
||||
for listener.InternalUISession() != 0 {
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("secure input did not revoke local capture")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if err := listener.EndInternalUI(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("XTEST rejected by XI2 source identity; genuine hardware approval remains unverified")
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
probe := filepath.Join(t.TempDir(), "suspension-probe")
|
||||
if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil {
|
||||
if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "commands_local.c", "-o", probe, "-lX11", "-lXtst", "-lXi", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil {
|
||||
t.Fatalf("compile native probe: %v: %s", err, out)
|
||||
}
|
||||
for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} {
|
||||
|
||||
Reference in new issue
Block a user