// 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") } }