207 lines
6.0 KiB
Go
207 lines
6.0 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// 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 TestNativeInternalUICapturesAndReleases(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_X11") != "1" {
|
|
t.Skip("requires isolated X11 display")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
log, err := os.CreateTemp(t.TempDir(), "keys-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer log.Close()
|
|
probe := exec.CommandContext(ctx, "stdbuf", "-oL", "xev", "-name", "Internal UI probe", "-event", "keyboard")
|
|
probe.Stdout = log
|
|
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", "^Internal UI probe$").Output()
|
|
if err == nil {
|
|
window = strings.TrimSpace(string(data))
|
|
}
|
|
if ctx.Err() != nil {
|
|
t.Fatal("probe did not open")
|
|
}
|
|
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(names ...string) {
|
|
t.Helper()
|
|
args := append([]string{"key"}, names...)
|
|
if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
|
t.Fatalf("keys %v: %v %s", names, err, data)
|
|
}
|
|
}
|
|
want := func(command input.Command) input.CommandEvent {
|
|
t.Helper()
|
|
select {
|
|
case event := <-listener.Commands():
|
|
if event.Command != command {
|
|
t.Fatalf("command=%s, want %s", event.Command, command)
|
|
}
|
|
return event
|
|
case err := <-listener.Failures():
|
|
t.Fatal(err)
|
|
case <-ctx.Done():
|
|
t.Fatalf("missing %s", command)
|
|
}
|
|
return input.CommandEvent{}
|
|
}
|
|
key("Insert+shift+a")
|
|
opened := want(input.ShowActions)
|
|
if opened.UISession == 0 || listener.InternalUISession() != opened.UISession {
|
|
t.Fatal("actions did not capture atomically")
|
|
}
|
|
for _, name := range []string{"Down", "Up", "shift+a", "exclam", "U00e9", "Return"} {
|
|
key(name)
|
|
event := want(input.InternalUIKey)
|
|
if event.UISession != opened.UISession {
|
|
t.Fatal("wrong session")
|
|
}
|
|
if name == "shift+a" && event.Text != "A" {
|
|
t.Fatalf("shift translation=%q", event.Text)
|
|
}
|
|
if name == "exclam" && event.Text != "!" {
|
|
t.Fatalf("punctuation=%q", event.Text)
|
|
}
|
|
if name == "U00e9" && event.Text != "é" {
|
|
t.Fatalf("unicode=%q", event.Text)
|
|
}
|
|
}
|
|
if focus, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output(); err != nil || strings.TrimSpace(string(focus)) != window {
|
|
t.Fatal("capture changed application focus")
|
|
}
|
|
key("Escape")
|
|
want(input.InternalUIKey)
|
|
for listener.InternalUIEchoActive() {
|
|
if ctx.Err() != nil {
|
|
t.Fatal("Escape did not release")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
key("y")
|
|
// An independently opened form uses the same ownership and stale-close rules.
|
|
second, err := listener.BeginInternalUI(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := listener.EndInternalUI(ctx, opened.UISession); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if listener.InternalUISession() != second {
|
|
t.Fatal("stale close revoked new form")
|
|
}
|
|
key("z")
|
|
want(input.InternalUIKey)
|
|
if err := exec.CommandContext(ctx, "xdotool", "keydown", "Return").Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want(input.InternalUIKey)
|
|
closed := make(chan error, 1)
|
|
go func() { closed <- listener.EndInternalUI(ctx, second) }()
|
|
for listener.InternalUISession() != 0 {
|
|
if ctx.Err() != nil {
|
|
t.Fatal("held-key close did not revoke UI")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
select {
|
|
case <-closed:
|
|
t.Fatal("close acknowledged before physical release")
|
|
default:
|
|
}
|
|
if err := exec.CommandContext(ctx, "xdotool", "keyup", "Return").Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := <-closed; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := listener.EndInternalUI(ctx, second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
third, err := listener.BeginInternalUI(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := exec.CommandContext(ctx, "xdotool", "keydown", "Shift_L", "F12").Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want(input.InternalUIKey)
|
|
listener.SetSecureInputActive(true)
|
|
for listener.InternalUISession() != 0 || listener.InternalUIEchoActive() {
|
|
if ctx.Err() != nil {
|
|
t.Fatal("lock did not revoke")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if err := exec.CommandContext(ctx, "xdotool", "keyup", "F12", "Shift_L").Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := listener.BeginInternalUI(ctx); err == nil {
|
|
t.Fatal("opened during lock")
|
|
}
|
|
listener.SetSecureInputActive(false)
|
|
if err := listener.EndInternalUI(ctx, third); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if background := os.Getenv("CTHULHU_UI_BACKGROUND"); background != "" {
|
|
fourth, err := listener.BeginInternalUI(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", background).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dismissed := want(input.InternalUIDismiss)
|
|
if dismissed.UISession != fourth || listener.InternalUISession() != 0 {
|
|
t.Fatal("focus change did not revoke interaction")
|
|
}
|
|
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
key("Insert+t")
|
|
want(input.PresentTime)
|
|
key("y")
|
|
_ = probe.Process.Kill()
|
|
_ = probe.Wait()
|
|
data, err := os.ReadFile(log.Name())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"keysym 0xff54, Down", "keysym 0xff52, Up", "keysym 0xff0d, Return", "keysym 0xff1b, Escape", "keysym 0x7a, z", "keysym 0x41, A", "keysym 0x21, exclam", "keysym 0xe9, eacute"} {
|
|
if strings.Contains(string(data), name) {
|
|
t.Fatalf("UI key leaked to application: %s", name)
|
|
}
|
|
}
|
|
if !strings.Contains(string(data), "keysym 0x79, y") {
|
|
t.Fatal("ordinary typing did not resume")
|
|
}
|
|
}
|