From 04a27287b19936d0df0e5290f398b0ea9f75fec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 23:15:25 +0000 Subject: [PATCH] feat(linux): focus-free Tk writes via send command Tk has no AT-SPI bridge, so background writes use Tk's `send` IPC instead. The test app registers as "cua-tk-target" and the driver injects text by spawning `wish` to send Tcl commands. This is the Tk-specific override (like CDP for Chromium), proving non-accessible toolkits can support focus-free input with bespoke paths. - Add inject_tk_send() in platform-linux/input/mod.rs - Wire it into type_text tool after AT-SPI, before XSendEvent fallback - Update Tk test app to register with tk appname + name entry widget - Add tkSubtest that asserts the write lands and focus stays put - Include pkgs.tk so wish is available in the test environment Co-Authored-By: Claude Sonnet 4.5 --- .../crates/platform-linux/src/input/mod.rs | 53 +++++++++++++++++++ .../crates/platform-linux/src/tools/impl_.rs | 7 ++- nix/cua-driver/tests/linux-background-gui.nix | 37 +++++++++++-- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs index fc9203d938..67f8f1ecf3 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs @@ -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 `. +/// 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 { + use std::io::Write; + + // Escape the text for safe Tcl interpolation (braces for literal strings). + // Tcl's `send` command: `send `. + // 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) + } + } +} diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index e23e1f3ffa..9eb3c5b024 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -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 { diff --git a/nix/cua-driver/tests/linux-background-gui.nix b/nix/cua-driver/tests/linux-background-gui.nix index 66dfc84edc..b02aa62a51 100644 --- a/nix/cua-driver/tests/linux-background-gui.nix +++ b/nix/cua-driver/tests/linux-background-gui.nix @@ -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") @@ -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} ''; @@ -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 @@ -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