#![cfg(windows)]
#![warn(missing_docs)]
#![warn(clippy::doc_markdown)]
pub mod clipboard;
pub mod delayed;
pub mod modifiers;
pub mod sendinput;
mod target;
pub use delayed::Offer;
pub use sendinput::{type_text, INJECT_TAG};
pub use target::{Integrity, Target};
use std::time::Duration;
use windows::Win32::UI::Input::KeyboardAndMouse::{
VIRTUAL_KEY, VK_CONTROL, VK_INSERT, VK_SHIFT, VK_V,
};
#[derive(Debug)]
pub enum Error {
NoForegroundWindow,
ClipboardLocked(windows::core::Error),
Clipboard(windows::core::Error),
Alloc(windows::core::Error),
SendInputBlocked,
FocusChanged,
OwnerWindowFailed,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NoForegroundWindow => write!(f, "no foreground window"),
Error::ClipboardLocked(e) => write!(f, "clipboard held by another process: {e}"),
Error::Clipboard(e) => write!(f, "clipboard operation failed: {e}"),
Error::Alloc(e) => write!(f, "global allocation failed: {e}"),
Error::SendInputBlocked => write!(f, "SendInput was blocked, most likely by UIPI"),
Error::FocusChanged => write!(f, "focus moved away from the captured target"),
Error::OwnerWindowFailed => write!(f, "clipboard owner window could not be created"),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chord {
CtrlV,
CtrlShiftV,
ShiftInsert,
}
impl Chord {
pub fn for_exe(exe: &str) -> Self {
match exe {
"windowsterminal.exe"
| "conhost.exe"
| "mintty.exe"
| "putty.exe"
| "alacritty.exe"
| "wezterm-gui.exe" => Chord::CtrlShiftV,
_ => Chord::CtrlV,
}
}
fn keys(self) -> (&'static [VIRTUAL_KEY], VIRTUAL_KEY) {
match self {
Chord::CtrlV => (&[VK_CONTROL], VK_V),
Chord::CtrlShiftV => (&[VK_CONTROL, VK_SHIFT], VK_V),
Chord::ShiftInsert => (&[VK_SHIFT], VK_INSERT),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Strategy {
#[default]
ClipboardPaste,
UnicodeType,
ClipboardOnly,
}
#[derive(Debug, Clone, Copy)]
pub struct Options {
pub strategy: Strategy,
pub chord: Option<Chord>,
pub pre_paste: Duration,
pub post_paste: Duration,
pub restore_clipboard: bool,
pub require_same_target: bool,
pub delayed_render: bool,
pub read_timeout: Duration,
pub read_quiet: Duration,
}
impl Default for Options {
fn default() -> Self {
Self {
strategy: Strategy::default(),
chord: None,
pre_paste: Duration::from_millis(30),
post_paste: Duration::from_millis(120),
restore_clipboard: true,
require_same_target: true,
delayed_render: true,
read_timeout: Duration::from_secs(3),
read_quiet: Duration::from_millis(400),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Pasted {
read_confirmed: bool,
},
Typed,
ClipboardOnly(ClipboardOnlyReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardOnlyReason {
Requested,
ElevatedTarget,
}
impl Outcome {
pub fn needs_manual_paste(self) -> bool {
matches!(self, Outcome::ClipboardOnly(_))
}
}
pub fn inject(target: &Target, text: &str, options: Options) -> Result<Outcome, Error> {
if options.require_same_target && !target.still_foreground() {
return Err(Error::FocusChanged);
}
if !target.accepts_injection() {
clipboard::set_text_private(text)?;
return Ok(Outcome::ClipboardOnly(ClipboardOnlyReason::ElevatedTarget));
}
match options.strategy {
Strategy::ClipboardOnly => {
clipboard::set_text_private(text)?;
Ok(Outcome::ClipboardOnly(ClipboardOnlyReason::Requested))
}
Strategy::UnicodeType => {
modifiers::sanitize()?;
sendinput::type_text(text)?;
Ok(Outcome::Typed)
}
Strategy::ClipboardPaste => {
let snapshot = if options.restore_clipboard {
clipboard::Snapshot::capture().ok()
} else {
None
};
let chord = options.chord.unwrap_or_else(|| Chord::for_exe(&target.exe));
if options.delayed_render {
let offer = delayed::Offer::publish(text)?;
std::thread::sleep(options.pre_paste);
modifiers::sanitize()?;
offer.mark_paste_sent();
send_chord(chord)?;
let reads = offer.wait_for_target_read(options.read_timeout, options.read_quiet);
let read_confirmed = reads.is_some();
let should_restore = if read_confirmed {
true
} else if offer.consumed_before_paste() {
std::thread::sleep(options.post_paste);
true
} else {
false
};
if should_restore {
if let Some(snapshot) = snapshot {
let _ = snapshot.restore();
}
}
return Ok(Outcome::Pasted { read_confirmed });
}
clipboard::set_text_private(text)?;
let ours = clipboard::sequence_number();
std::thread::sleep(options.pre_paste);
modifiers::sanitize()?;
send_chord(chord)?;
std::thread::sleep(options.post_paste);
if let Some(snapshot) = snapshot {
let _ = snapshot.restore_if_ours(ours);
}
Ok(Outcome::Pasted {
read_confirmed: false,
})
}
}
}
fn send_chord(chord: Chord) -> Result<(), Error> {
use windows::Win32::UI::Input::KeyboardAndMouse::KEYBD_EVENT_FLAGS;
let (mods, key) = chord.keys();
let mut inputs = Vec::with_capacity(mods.len() * 2 + 2);
for m in mods {
inputs.push(sendinput::tagged_keyboard_input(*m, KEYBD_EVENT_FLAGS(0)));
}
inputs.push(sendinput::tagged_keyboard_input(key, KEYBD_EVENT_FLAGS(0)));
inputs.push(modifiers::key_up(key));
for m in mods.iter().rev() {
inputs.push(modifiers::key_up(*m));
}
sendinput::send(&inputs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminals_get_ctrl_shift_v() {
assert_eq!(Chord::for_exe("windowsterminal.exe"), Chord::CtrlShiftV);
assert_eq!(Chord::for_exe("alacritty.exe"), Chord::CtrlShiftV);
}
#[test]
fn vs_code_gets_ctrl_v_not_shift_insert() {
assert_eq!(Chord::for_exe("code.exe"), Chord::CtrlV);
assert_eq!(Chord::for_exe("cursor.exe"), Chord::CtrlV);
}
#[test]
fn unknown_apps_fall_back_to_ctrl_v() {
assert_eq!(Chord::for_exe("notepad.exe"), Chord::CtrlV);
assert_eq!(Chord::for_exe(""), Chord::CtrlV);
}
#[test]
fn chord_lookup_assumes_lowercased_input() {
assert_eq!(Chord::for_exe("Code.exe"), Chord::CtrlV);
}
#[test]
fn only_clipboard_only_requires_manual_paste() {
assert!(!Outcome::Pasted {
read_confirmed: true
}
.needs_manual_paste());
assert!(!Outcome::Typed.needs_manual_paste());
assert!(Outcome::ClipboardOnly(ClipboardOnlyReason::ElevatedTarget).needs_manual_paste());
}
#[test]
fn defaults_restore_the_clipboard_and_pin_the_target() {
let o = Options::default();
assert!(o.restore_clipboard);
assert!(o.require_same_target);
assert_eq!(o.strategy, Strategy::ClipboardPaste);
}
#[test]
fn delayed_render_is_the_default() {
assert!(Options::default().delayed_render);
}
#[test]
fn timer_path_cannot_confirm_a_read() {
assert!(!Outcome::Pasted {
read_confirmed: false
}
.needs_manual_paste());
}
#[test]
fn chord_key_sequences_are_well_formed() {
assert_eq!(Chord::CtrlV.keys().0.len(), 1);
assert_eq!(Chord::CtrlShiftV.keys().0.len(), 2);
assert_eq!(Chord::ShiftInsert.keys().1, VK_INSERT);
}
}