Skip to content
Open
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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 <key> --strategy pid` to deliver to a background app's first responder.

`keyboard type` defaults to global HID as well; use `--strategy pid --app <name>` (or `--pid <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:
Expand Down
35 changes: 35 additions & 0 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16> = 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) {
Expand Down
50 changes: 37 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <KEY> --strategy pid`.
/// For single-key presses targeting a background app, use the top-level
/// `press <KEY> --strategy pid` instead.
Keyboard {
#[command(subcommand)]
action: KeyboardAction,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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})");
Expand Down