From d24e002f76399746942757ae2051652f2cb70f79 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 24 May 2026 11:13:56 +0800 Subject: [PATCH] feat: add 'keyboard type --strategy pid' for background Unicode typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'type_text_bg(pid, text)' in src/input.rs — mirrors 'type_text' but uses CGEventPostToPid instead of global HID, so Unicode strings are delivered to the target process's first responder without activation or focus steal. Same chunking (20 UTF-16 code units) and pacing as the global path; Unicode works because CGEventKeyboardSetUnicodeString feeds UTF-16 directly, bypassing the keycode table. CLI surface (src/main.rs): - 'keyboard type' subcommand grows a '--strategy ' flag (reusing the existing PressStrategy enum for symmetry with 'press'). - Default remains 'hid' for backward compat; 'pid' requires '--app' or '--pid' at the top level. - Updated 'Keyboard' command docstring; the subcommand is no longer purely global when --strategy pid is in use. Motivation: ETF dry-run automation against custom-rendered trading clients needs to type 3-5 strings per session (codes, prices, qty). The previous flow had to activate the target app before each typed string, which is intrusive and visibly steals focus from the user's foreground work. With --strategy pid the entire session runs while another app stays frontmost. Verified on TextEdit: 'hello from bg path 中文测试' delivered fully background, Brave Browser stayed frontmost throughout. Implementation drafted with AI assistance, manually reviewed and tested. --- README.md | 10 ++++++++-- src/input.rs | 35 +++++++++++++++++++++++++++++++++++ src/main.rs | 50 +++++++++++++++++++++++++++++++++++++------------- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 932723c..72377cc 100644 --- a/README.md +++ b/README.md @@ -172,15 +172,19 @@ axcli --app Lark watch axcli --app Lark watch --format json ``` -**Global mouse / keyboard (ignores --app/--pid):** +**Global mouse / keyboard:** ```sh axcli mouse pos # print current cursor position axcli mouse move 400 300 axcli mouse click 400 300 axcli mouse scroll 0 -120 # scroll down 120px at current cursor -axcli keyboard type 'hello world' +axcli keyboard type 'hello world' # global HID path; target must own first responder axcli keyboard press 'Command+Shift+4' + +# Type into a background app (no activation, no focus steal): +# Unicode (Chinese, emoji, ...) works on both paths. +axcli --app TextEdit keyboard type '你好 from background' --strategy pid ``` **List running apps:** @@ -223,6 +227,8 @@ Tested on AppKit (Calculator, TextEdit, Finder) and Chromium/Electron apps (Lark `press` defaults to the global HID path (which activates the app). Use `press --strategy pid` to deliver to a background app's first responder. +`keyboard type` defaults to global HID as well; use `--strategy pid --app ` (or `--pid `) to deliver Unicode strings to a background app without activation. Confirmed on TextEdit with Chinese / emoji input. + ## Locator Syntax axcli uses a CSS-like selector syntax to target elements in the accessibility tree: diff --git a/src/input.rs b/src/input.rs index 637d60f..6b2835a 100644 --- a/src/input.rs +++ b/src/input.rs @@ -344,6 +344,41 @@ pub fn type_text(text: &str) { } } +/// Type text directly to a specific pid via `CGEventPostToPid`. +/// +/// Background-safe counterpart to `type_text` — delivers Unicode keystrokes +/// to the target process's first responder without activating the app or +/// stealing focus. Mirrors `press_key_combo_bg` for parameterised keys. +/// +/// The chunk size (20 UTF-16 code units) matches `type_text`; per-chunk +/// sleeps are the same so timing-sensitive apps see identical pacing on +/// both paths. Confirmed working on TextEdit's document body with the +/// app fully occluded behind another window. Unicode (Chinese, emoji) +/// is supported because `CGEventKeyboardSetUnicodeString` feeds UTF-16 +/// directly, bypassing the keycode table. +pub fn type_text_bg(pid: i32, text: &str) { + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState); + let utf16: Vec = text.encode_utf16().collect(); + for chunk in utf16.chunks(20) { + let down = CGEvent::new_keyboard_event(source.as_deref(), 0, true); + if let Some(ref ev) = down { + unsafe { + CGEvent::keyboard_set_unicode_string(Some(ev), chunk.len() as _, chunk.as_ptr()); + } + CGEvent::post_to_pid(pid, Some(ev)); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + let up = CGEvent::new_keyboard_event(source.as_deref(), 0, false); + if let Some(ref ev) = up { + unsafe { + CGEvent::keyboard_set_unicode_string(Some(ev), chunk.len() as _, chunk.as_ptr()); + } + CGEvent::post_to_pid(pid, Some(ev)); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + /// Parse a key combo string like "Control+a", "Command+Shift+v", "Enter" /// into (keycode, modifier_flags). pub fn parse_key_combo(combo: &str) -> (u16, u64) { diff --git a/src/main.rs b/src/main.rs index de596e3..9deb137 100644 --- a/src/main.rs +++ b/src/main.rs @@ -414,11 +414,12 @@ Known attributes: #[command(subcommand)] action: MouseAction, }, - /// Global keyboard input — ignores --app/--pid. + /// Keyboard input. Default path is global HID (ignores --app/--pid); + /// `type --strategy pid` switches to `CGEventPostToPid` and respects + /// --app/--pid for background delivery (no focus steal). /// - /// Events are posted via `CGEventPost(HIDEventTap)` and delivered to - /// whichever process currently holds keyboard focus (first responder). - /// To target a specific background app use `press --strategy pid`. + /// For single-key presses targeting a background app, use the top-level + /// `press --strategy pid` instead. Keyboard { #[command(subcommand)] action: KeyboardAction, @@ -468,9 +469,25 @@ enum MouseAction { /// `axcli keyboard ...` actions. #[derive(Subcommand)] enum KeyboardAction { - /// Type literal text (Unicode via CGEventKeyboardSetUnicodeString) to - /// whatever app currently has keyboard focus. - Type { text: String }, + /// Type literal text (Unicode via `CGEventKeyboardSetUnicodeString`). + /// + /// Default strategy `hid` posts via global `CGEventPost(HIDEventTap)` + /// and lands on whichever app currently owns first responder — this + /// usually implies the target must be foregrounded. Strategy `pid` + /// posts via `CGEventPostToPid` and delivers directly to the target + /// process's first responder without activation or focus steal; pair + /// with `--app` / `--pid` to pick the target. + /// + /// Unicode (Chinese, emoji, ...) works on both paths. + Type { + text: String, + /// Post strategy. `hid` (default): global `CGEventPost(HIDEventTap)`, + /// activates the target first. `pid`: `CGEventPostToPid`, delivers + /// to the target process in the background without focus steal — + /// requires `--app` or `--pid` on the top-level invocation. + #[arg(long, value_enum, default_value_t = PressStrategy::Hid)] + strategy: PressStrategy, + }, /// Press a key or key combo. Examples: `Enter`, `Command+a`, /// `Control+Shift+v`, `F5`, `Escape`. Sent to the current first /// responder. @@ -511,7 +528,7 @@ fn main() { return; } if let Command::Keyboard { ref action } = cli.command { - if let Err(e) = cmd_keyboard(action) { + if let Err(e) = cmd_keyboard(&cli, action) { eprintln!("error: {e}"); std::process::exit(exit_code(&e)); } @@ -822,15 +839,22 @@ fn cmd_mouse(action: &MouseAction) -> Result<(), AxError> { Ok(()) } -fn cmd_keyboard(action: &KeyboardAction) -> Result<(), AxError> { +fn cmd_keyboard(cli: &Cli, action: &KeyboardAction) -> Result<(), AxError> { if !accessibility::is_trusted() { return Err(AxError::AccessDenied); } match action { - KeyboardAction::Type { text } => { - eprintln!("Typing: {text:?}"); - input::type_text(text); - } + KeyboardAction::Type { text, strategy } => match strategy { + PressStrategy::Hid => { + eprintln!("Typing: {text:?} [hid]"); + input::type_text(text); + } + PressStrategy::Pid => { + let (pid, _) = resolve_app(cli)?; + eprintln!("Typing: {text:?} [pid={pid}]"); + input::type_text_bg(pid, text); + } + }, KeyboardAction::Press { key } => { let (keycode, flags) = input::parse_key_combo(key); eprintln!("Pressing: {key} (keycode={keycode}, flags=0x{flags:x})");