Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,56 @@ fn modifiers_to_state(modifiers: &[&str]) -> KeyButMask {
}
KeyButMask::from(state)
}

/// Inject text into a Tk window via Tk's `send` command — the Tk-specific
/// override for focus-free writes (Tk has no AT-SPI bridge). Requires the target
/// app to have registered itself with a known name via `tk appname <name>`.
/// Returns Ok(true) if text was sent, Ok(false) if the target isn't reachable
/// (not a Tk app or `wish` unavailable), Err on a send failure.
pub fn inject_tk_send(text: &str) -> Result<bool> {
use std::io::Write;

// Escape the text for safe Tcl interpolation (braces for literal strings).
// Tcl's `send` command: `send <target-app-name> <tcl-command>`.
// We target "cua-tk-target" (the name the test app registers with) and
// insert at the entry widget's current cursor position.
let tcl_text = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}");
let tcl_script = format!(
r#"if {{[catch {{send cua-tk-target {{.entry insert insert {{{}}}}}}} err]}} {{
puts stderr "tk send failed: $err"
exit 1
}}
exit 0"#,
tcl_text
);

// Try to spawn wish (Tk's shell). If it's not available, this isn't a
// Tk-based environment and we should fall back to XSendEvent.
let mut child = match std::process::Command::new("wish")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn() {
Ok(c) => c,
Err(_) => return Ok(false),
};

if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(tcl_script.as_bytes())?;
}

let output = child.wait_with_output()?;

if output.status.success() {
Ok(true)
} else {
// If send fails (e.g., target not registered), treat as "not a Tk app"
// and let the caller fall back to XSendEvent.
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("application named") || stderr.contains("no registered") {
Ok(false)
} else {
anyhow::bail!("wish send failed: {}", stderr)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -857,10 +857,15 @@ impl Tool for TypeTextTool {
// GUI apps: X11 only routes keystrokes to the *focused* toplevel's
// focused widget, so background XSendEvent typing doesn't land. Fill
// the editable field via AT-SPI instead — focus-free and toolkit-
// agnostic. Fall back to XSendEvent when no a11y field is exposed.
// agnostic. Fall back to Tk send or XSendEvent when no a11y field is exposed.
if crate::atspi::insert_text(pid, &text).unwrap_or(false) {
return Ok(());
}
// Tk apps: use Tk's `send` command (no AT-SPI bridge, so AT-SPI above
// returned false). This is the Tk-specific override, like CDP for Chromium.
if crate::input::inject_tk_send(&text).unwrap_or(false) {
return Ok(());
}
crate::input::send_type_text(xid, &text)
}).await;
match result {
Expand Down
37 changes: 32 additions & 5 deletions nix/cua-driver/tests/linux-background-gui.nix
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,19 @@ let
$CC app.c -o $out/bin/cua-gtk4 $(pkg-config --cflags --libs gtk4)
'';

# Tk (tkinter) app — Tk has no AT-SPI bridge, so this is the negative-control
# data point: the driver can't reach an editable, and get_text falls back to
# the X11 window node. Proves graceful degradation for non-accessible toolkits.
# Tk (tkinter) app — Tk has no AT-SPI bridge, so focus-free writes use Tk's
# `send` command instead: the app registers itself with a known name, and the
# driver injects text by invoking `wish` to send Tcl commands over X11 IPC.
# This is the Tk-specific override (like CDP for Chromium), proving that
# non-accessible toolkits can still support background input with bespoke paths.
tkEnv = pkgs.python3.withPackages (ps: [ ps.tkinter ]);
tkScript = pkgs.writeText "cua-tk.py" ''
import tkinter as tk
root = tk.Tk()
root.title("cua-initial")
entry = tk.Entry(root, width=40)
# Register the app with a known name so `send` commands can reach it.
tk._default_root.tk.call('tk', 'appname', 'cua-tk-target')
entry = tk.Entry(root, width=40, name='entry')
entry.pack(padx=20, pady=20)
entry.focus_set()
root.geometry("400x120+700+150")
Expand Down Expand Up @@ -363,8 +367,9 @@ let
'';
};
tk = {
packages = [ tkEnv ];
packages = [ tkEnv pkgs.tk ];
memoryMB = 2048;
tksend = true;
launch = pkgs.writeShellScript "cua-launch-tk.sh" ''
exec ${tkEnv}/bin/python3 ${tkScript}
'';
Expand Down Expand Up @@ -423,6 +428,27 @@ let
assert cdp_control == cdp_active, "focus moved during CDP write: got " + cdp_active
'';

# Tk send focus-free write subtest — only for Tk apps. Asserts the driver's
# Tk-specific override (using Tk's `send` command) writes into the background
# window while the control terminal keeps focus. Tk has no AT-SPI bridge, so
# this is the approved override path for focus-free Tk input.
tkGetScript = pkgs.writeText "tk-get-value.tcl" ''
puts [send cua-tk-target {.entry get}]
'';
tkSubtest = lib.optionalString (selected.tksend or false) ''
with subtest("Tk send focus-free write into the background window (Tk override)"):
# The driver already typed via inject_tk_send in the main test. Now read
# the entry widget's value back via Tk send to prove the write landed.
machine.copy_from_host("${tkGetScript}", "/tmp/tk-get-value.tcl")
tk_readback = machine.succeed("${a11yEnv} ${pkgs.tk}/bin/wish /tmp/tk-get-value.tcl 2>&1").strip()
machine.log("Tk send readback: " + repr(tk_readback))
assert "${typed}" in tk_readback, f"Expected '${typed}' in Tk entry, got: {tk_readback}"
# The override must remain focus-free: control terminal still active.
tk_control = machine.succeed("head -1 /tmp/control-xid.txt").strip()
tk_active = machine.succeed("DISPLAY=:99 xdotool getactivewindow").strip()
assert tk_control == tk_active, "focus moved during Tk write: got " + tk_active
'';

mcpTest = pkgs.writeText "mcp-background-gui-test.py" ''
import json, os, sys, threading, time

Expand Down Expand Up @@ -649,6 +675,7 @@ pkgs.testers.nixosTest {
assert control == active, "expected active window " + control + ", got " + active

${cdpSubtest}
${tkSubtest}
with subtest("Confirm: focusing the window exposes the editable (diagnostic)"):
# Direct confirmation of the focus-gate finding. Activate the target so it
# becomes the focused window, then re-run the driver: with focus the
Expand Down
Loading