use core_graphics::event::{CGEvent, CGEventTapLocation};
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use std::thread::sleep;
use std::time::Duration;
use thiserror::Error;
const MAX_UNITS_PER_EVENT: usize = 20;
unsafe extern "C" {
fn CGPreflightPostEventAccess() -> bool;
fn CGRequestPostEventAccess() -> bool;
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TypeError {
#[error("no permission to post keyboard events")]
PermissionDenied,
#[error("failed to create CoreGraphics event source")]
EventSourceFailed,
#[error("failed to create keyboard event")]
EventCreationFailed,
}
pub fn has_permission() -> bool {
unsafe { CGPreflightPostEventAccess() }
}
pub fn request_permission() -> bool {
unsafe { CGRequestPostEventAccess() }
}
pub fn type_string(text: &str) -> Result<(), TypeError> {
if !has_permission() {
return Err(TypeError::PermissionDenied);
}
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
.map_err(|_| TypeError::EventSourceFailed)?;
for chunk in utf16_chunks(text, MAX_UNITS_PER_EVENT) {
for key_down in [true, false] {
let event = CGEvent::new_keyboard_event(source.clone(), 0, key_down)
.map_err(|_| TypeError::EventCreationFailed)?;
event.set_string_from_utf16_unchecked(&chunk);
event.post(CGEventTapLocation::HID);
}
sleep(Duration::from_millis(1));
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
Type {
text: String,
delay_ms: u64,
},
Help,
Version,
}
pub fn parse_args(args: &[String]) -> Result<Action, String> {
let mut delay_ms: u64 = 0;
let mut text: Option<String> = None;
let mut codepoints = String::new();
let mut literal = false;
let set_text = |s: &str, text: &mut Option<String>| -> Result<(), String> {
if text.is_some() {
return Err("only one text argument is allowed (quote the whole string)".into());
}
*text = Some(s.to_string());
Ok(())
};
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if literal {
set_text(arg, &mut text)?;
continue;
}
match arg.as_str() {
"--help" | "-h" => return Ok(Action::Help),
"--version" | "-V" => return Ok(Action::Version),
"--" => literal = true,
"--delay" | "-d" => {
let value = iter
.next()
.ok_or("--delay requires a value in milliseconds")?;
delay_ms = value
.parse()
.map_err(|_| format!("invalid --delay value: {value}"))?;
}
"--unicode" | "-u" => {
let value = iter.next().ok_or("--unicode requires a hex codepoint")?;
codepoints.push(decode_codepoint(value)?);
}
flag if flag.starts_with('-') && flag.len() > 1 => {
return Err(format!(
"unknown option: {flag} (use -- to type it literally)"
));
}
other => set_text(other, &mut text)?,
}
}
let text = match (text, codepoints.is_empty()) {
(Some(t), true) => t,
(None, false) => codepoints,
(Some(_), false) => return Err("pass either a string or --unicode, not both".into()),
(None, true) => return Err("nothing to type (pass a string or --unicode <hex>)".into()),
};
Ok(Action::Type { text, delay_ms })
}
pub fn decode_codepoint(hex: &str) -> Result<char, String> {
let digits = hex
.strip_prefix("U+")
.or_else(|| hex.strip_prefix("u+"))
.unwrap_or(hex);
let value =
u32::from_str_radix(digits, 16).map_err(|_| format!("invalid hex codepoint: {hex}"))?;
char::from_u32(value).ok_or(format!("not a valid Unicode scalar value: {hex}"))
}
pub fn utf16_chunks(text: &str, max_units: usize) -> Vec<Vec<u16>> {
let mut chunks: Vec<Vec<u16>> = Vec::new();
let mut current: Vec<u16> = Vec::new();
for ch in text.chars() {
let mut buf = [0u16; 2];
let units = ch.encode_utf16(&mut buf);
if current.len() + units.len() > max_units && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
}
current.extend_from_slice(units);
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn type_error_is_a_std_error_with_useful_messages() {
fn assert_error<E: std::error::Error>(_: &E) {}
let e = TypeError::PermissionDenied;
assert_error(&e);
assert_eq!(e.to_string(), "no permission to post keyboard events");
assert_eq!(
TypeError::EventSourceFailed.to_string(),
"failed to create CoreGraphics event source"
);
assert_eq!(
TypeError::EventCreationFailed.to_string(),
"failed to create keyboard event"
);
}
#[test]
fn type_string_without_permission_returns_permission_denied() {
match type_string("") {
Ok(()) | Err(TypeError::PermissionDenied) => {}
Err(other) => panic!("unexpected error: {other}"),
}
}
#[test]
fn types_a_positional_string() {
let a = parse_args(&args(&["€"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "€".into(),
delay_ms: 0
}
);
}
#[test]
fn types_a_multichar_snippet() {
let a = parse_args(&args(&["¯\\_(ツ)_/¯"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "¯\\_(ツ)_/¯".into(),
delay_ms: 0
}
);
}
#[test]
fn delay_flag_sets_delay() {
let a = parse_args(&args(&["--delay", "150", "€"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "€".into(),
delay_ms: 150
}
);
}
#[test]
fn unicode_flag_decodes_hex_codepoint() {
let a = parse_args(&args(&["--unicode", "20ac"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "€".into(),
delay_ms: 0
}
);
}
#[test]
fn unicode_short_flag_works() {
let a = parse_args(&args(&["-u", "2713"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "✓".into(),
delay_ms: 0
}
);
}
#[test]
fn unicode_flag_accepts_multiple_codepoints() {
let a = parse_args(&args(&["-u", "20ac", "-u", "2713"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "€✓".into(),
delay_ms: 0
}
);
}
#[test]
fn double_dash_makes_following_arg_literal() {
let a = parse_args(&args(&["--", "--delay"])).unwrap();
assert_eq!(
a,
Action::Type {
text: "--delay".into(),
delay_ms: 0
}
);
}
#[test]
fn no_args_is_an_error() {
assert!(parse_args(&[]).is_err());
}
#[test]
fn missing_delay_value_is_an_error() {
assert!(parse_args(&args(&["--delay"])).is_err());
}
#[test]
fn non_numeric_delay_is_an_error() {
assert!(parse_args(&args(&["--delay", "abc", "€"])).is_err());
}
#[test]
fn unknown_flag_is_an_error() {
assert!(parse_args(&args(&["--frobnicate", "€"])).is_err());
}
#[test]
fn two_positional_args_is_an_error() {
assert!(parse_args(&args(&["€", "✓"])).is_err());
}
#[test]
fn help_flag_wins() {
assert_eq!(parse_args(&args(&["--help"])).unwrap(), Action::Help);
assert_eq!(parse_args(&args(&["-h"])).unwrap(), Action::Help);
}
#[test]
fn version_flag_wins() {
assert_eq!(parse_args(&args(&["--version"])).unwrap(), Action::Version);
assert_eq!(parse_args(&args(&["-V"])).unwrap(), Action::Version);
}
#[test]
fn decodes_plain_hex() {
assert_eq!(decode_codepoint("20ac").unwrap(), '€');
}
#[test]
fn decodes_uppercase_and_u_plus_prefix() {
assert_eq!(decode_codepoint("U+20AC").unwrap(), '€');
assert_eq!(decode_codepoint("u+20ac").unwrap(), '€');
}
#[test]
fn decodes_astral_plane_codepoint() {
assert_eq!(decode_codepoint("1F600").unwrap(), '😀');
}
#[test]
fn rejects_invalid_hex() {
assert!(decode_codepoint("xyz").is_err());
}
#[test]
fn rejects_surrogate_codepoints() {
assert!(decode_codepoint("D800").is_err());
}
#[test]
fn rejects_out_of_range_codepoints() {
assert!(decode_codepoint("110000").is_err());
}
#[test]
fn short_text_is_one_chunk() {
let chunks = utf16_chunks("abc", 20);
assert_eq!(chunks, vec!["abc".encode_utf16().collect::<Vec<u16>>()]);
}
#[test]
fn long_text_splits_at_max_units() {
let text = "a".repeat(25);
let chunks = utf16_chunks(&text, 20);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), 20);
assert_eq!(chunks[1].len(), 5);
}
#[test]
fn never_splits_a_surrogate_pair() {
let chunks = utf16_chunks("😀😀😀", 3);
for chunk in &chunks {
assert!(
String::from_utf16(chunk).is_ok(),
"chunk split a surrogate pair"
);
}
let total: Vec<u16> = chunks.concat();
assert_eq!(total, "😀😀😀".encode_utf16().collect::<Vec<u16>>());
}
#[test]
fn empty_text_yields_no_chunks() {
assert!(utf16_chunks("", 20).is_empty());
}
}