mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
Add desktop autotype unittests for windows (#16710)
* Add desktop autotype unittests for windows * lint * fix TODO comment * feedback coltonhurst: rename trait
This commit is contained in:
112
apps/desktop/desktop_native/Cargo.lock
generated
112
apps/desktop/desktop_native/Cargo.lock
generated
@@ -343,6 +343,8 @@ name = "autotype"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mockall",
|
||||
"serial_test",
|
||||
"tracing",
|
||||
"windows 0.61.1",
|
||||
"windows-core 0.61.0",
|
||||
@@ -1070,6 +1072,12 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562"
|
||||
|
||||
[[package]]
|
||||
name = "downcast"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
@@ -1288,6 +1296,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fragile"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "2.11.0"
|
||||
@@ -1943,6 +1957,32 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockall"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"downcast",
|
||||
"fragile",
|
||||
"mockall_derive",
|
||||
"predicates",
|
||||
"predicates-tree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockall_derive"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "2.16.17"
|
||||
@@ -2575,6 +2615,32 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates"
|
||||
version = "3.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"predicates-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates-core"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
|
||||
|
||||
[[package]]
|
||||
name = "predicates-tree"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
|
||||
dependencies = [
|
||||
"predicates-core",
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
@@ -2877,6 +2943,15 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||
dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -2920,6 +2995,12 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
@@ -3024,6 +3105,31 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -3263,6 +3369,12 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
|
||||
@@ -9,6 +9,8 @@ publish.workspace = true
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
mockall = "=0.13.1"
|
||||
serial_test = "=3.2.0"
|
||||
tracing.workspace = true
|
||||
windows = { workspace = true, features = [
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
|
||||
@@ -2,7 +2,7 @@ use anyhow::Result;
|
||||
|
||||
#[cfg_attr(target_os = "linux", path = "linux.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows/mod.rs")]
|
||||
mod windowing;
|
||||
|
||||
/// Gets the title bar string for the foreground window.
|
||||
@@ -20,12 +20,13 @@ pub fn get_foreground_window_title() -> Result<String> {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input` must be an array of utf-16 encoded characters to insert.
|
||||
/// * `input` an array of utf-16 encoded characters to insert.
|
||||
/// * `keyboard_shortcut` a vector of valid shortcut keys: Control, Alt, Super, Shift, letters a - Z
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function returns an `anyhow::Error` if there is any
|
||||
/// issue obtaining the window title. Detailed reasons will
|
||||
/// issue in typing the input. Detailed reasons will
|
||||
/// vary based on platform implementation.
|
||||
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
|
||||
windowing::type_input(input, keyboard_shortcut)
|
||||
|
||||
41
apps/desktop/desktop_native/autotype/src/windows/mod.rs
Normal file
41
apps/desktop/desktop_native/autotype/src/windows/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use anyhow::Result;
|
||||
use tracing::debug;
|
||||
use windows::Win32::Foundation::{GetLastError, SetLastError, WIN32_ERROR};
|
||||
|
||||
mod type_input;
|
||||
mod window_title;
|
||||
|
||||
/// The error code from Win32 API that represents a non-error.
|
||||
const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0);
|
||||
|
||||
/// `ErrorOperations` provides an interface to the Win32 API for dealing with
|
||||
/// win32 errors.
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
trait ErrorOperations {
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setlasterror
|
||||
fn set_last_error(err: u32) {
|
||||
debug!(err, "Calling SetLastError");
|
||||
unsafe {
|
||||
SetLastError(WIN32_ERROR(err));
|
||||
}
|
||||
}
|
||||
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-getlasterror
|
||||
fn get_last_error() -> WIN32_ERROR {
|
||||
let last_err = unsafe { GetLastError() };
|
||||
debug!("GetLastError(): {}", last_err.to_hresult().message());
|
||||
last_err
|
||||
}
|
||||
}
|
||||
|
||||
/// Default implementation for Win32 API errors.
|
||||
struct Win32ErrorOperations;
|
||||
impl ErrorOperations for Win32ErrorOperations {}
|
||||
|
||||
pub fn get_foreground_window_title() -> Result<String> {
|
||||
window_title::get_foreground_window_title()
|
||||
}
|
||||
|
||||
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
|
||||
type_input::type_input(input, keyboard_shortcut)
|
||||
}
|
||||
@@ -1,136 +1,42 @@
|
||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::{debug, error, warn};
|
||||
use windows::Win32::{
|
||||
Foundation::{GetLastError, SetLastError, HWND, WIN32_ERROR},
|
||||
UI::{
|
||||
Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
|
||||
KEYEVENTF_UNICODE, VIRTUAL_KEY,
|
||||
},
|
||||
WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW},
|
||||
},
|
||||
use tracing::{debug, error};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE,
|
||||
VIRTUAL_KEY,
|
||||
};
|
||||
|
||||
const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0);
|
||||
use super::{ErrorOperations, Win32ErrorOperations};
|
||||
|
||||
fn clear_last_error() {
|
||||
debug!("Clearing last error with SetLastError.");
|
||||
unsafe {
|
||||
SetLastError(WIN32_ERROR(0));
|
||||
/// `InputOperations` provides an interface to Window32 API for
|
||||
/// working with inputs.
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
trait InputOperations {
|
||||
/// Attempts to type the provided input wherever the user's cursor is.
|
||||
///
|
||||
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
|
||||
fn send_input(inputs: &[INPUT]) -> u32;
|
||||
}
|
||||
|
||||
struct Win32InputOperations;
|
||||
|
||||
impl InputOperations for Win32InputOperations {
|
||||
fn send_input(inputs: &[INPUT]) -> u32 {
|
||||
const INPUT_STRUCT_SIZE: i32 = std::mem::size_of::<INPUT>() as i32;
|
||||
let insert_count = unsafe { SendInput(inputs, INPUT_STRUCT_SIZE) };
|
||||
|
||||
debug!(insert_count, "SendInput() called.");
|
||||
|
||||
insert_count
|
||||
}
|
||||
}
|
||||
|
||||
fn get_last_error() -> WIN32_ERROR {
|
||||
let last_err = unsafe { GetLastError() };
|
||||
debug!("GetLastError(): {}", last_err.to_hresult().message());
|
||||
last_err
|
||||
}
|
||||
|
||||
// The handle should be validated before any unsafe calls referencing it.
|
||||
fn validate_window_handle(handle: &HWND) -> Result<()> {
|
||||
if handle.is_invalid() {
|
||||
error!("Window handle is invalid.");
|
||||
return Err(anyhow!("Window handle is invalid."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Window title --------------
|
||||
|
||||
/// Gets the title bar string for the foreground window.
|
||||
pub fn get_foreground_window_title() -> Result<String> {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow
|
||||
let window_handle = unsafe { GetForegroundWindow() };
|
||||
|
||||
debug!("GetForegroundWindow() called.");
|
||||
|
||||
validate_window_handle(&window_handle)?;
|
||||
|
||||
get_window_title(&window_handle)
|
||||
}
|
||||
|
||||
/// Gets the length of the window title bar text.
|
||||
///
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw
|
||||
fn get_window_title_length(window_handle: &HWND) -> Result<usize> {
|
||||
// GetWindowTextLengthW does not itself clear the last error so we must do it ourselves.
|
||||
clear_last_error();
|
||||
|
||||
validate_window_handle(window_handle)?;
|
||||
|
||||
let length = unsafe { GetWindowTextLengthW(*window_handle) };
|
||||
|
||||
let length = usize::try_from(length)?;
|
||||
|
||||
debug!(length, "window text length retrieved from handle.");
|
||||
|
||||
if length == 0 {
|
||||
// attempt to retreive win32 error
|
||||
let last_err = get_last_error();
|
||||
if last_err != WIN32_SUCCESS {
|
||||
let last_err = last_err.to_hresult().message();
|
||||
error!(last_err, "Error getting window text length.");
|
||||
return Err(anyhow!("Error getting window text length: {last_err}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
/// Gets the window title bar title.
|
||||
///
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw
|
||||
fn get_window_title(window_handle: &HWND) -> Result<String> {
|
||||
let expected_window_title_length = get_window_title_length(window_handle)?;
|
||||
|
||||
// This isn't considered an error by the windows API, but in practice it means we can't
|
||||
// match against the title so we'll stop here.
|
||||
// The upstream will make a contains comparison on what we return, so an empty string
|
||||
// will not result on a match.
|
||||
if expected_window_title_length == 0 {
|
||||
warn!("Window title length is zero.");
|
||||
return Ok(String::from(""));
|
||||
}
|
||||
|
||||
let mut buffer: Vec<u16> = vec![0; expected_window_title_length + 1]; // add extra space for the null character
|
||||
|
||||
validate_window_handle(window_handle)?;
|
||||
|
||||
let actual_window_title_length = unsafe { GetWindowTextW(*window_handle, &mut buffer) };
|
||||
|
||||
debug!(actual_window_title_length, "window title retrieved.");
|
||||
|
||||
if actual_window_title_length == 0 {
|
||||
// attempt to retreive win32 error
|
||||
let last_err = get_last_error();
|
||||
if last_err != WIN32_SUCCESS {
|
||||
let last_err = last_err.to_hresult().message();
|
||||
error!(last_err, "Error retrieving window title.");
|
||||
return Err(anyhow!("Error retrieving window title. {last_err}"));
|
||||
}
|
||||
// in practice, we should not get to the below code, since we asserted the len > 0
|
||||
// above. but it is an extra protection in case the windows API didn't set an error.
|
||||
warn!(expected_window_title_length, "No window title retrieved.");
|
||||
}
|
||||
|
||||
let window_title = OsString::from_wide(&buffer);
|
||||
|
||||
Ok(window_title.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
// ---------- Type Input --------------
|
||||
|
||||
/// Attempts to type the input text wherever the user's cursor is.
|
||||
///
|
||||
/// `input` must be a vector of utf-16 encoded characters to insert.
|
||||
/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z
|
||||
///
|
||||
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
|
||||
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
|
||||
const TAB_KEY: u8 = 9;
|
||||
|
||||
pub(super) fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
|
||||
// the length of this vec is always shortcut keys to release + (2x length of input chars)
|
||||
let mut keyboard_inputs: Vec<INPUT> =
|
||||
Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2));
|
||||
@@ -142,25 +48,31 @@ pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<()>
|
||||
keyboard_inputs.push(convert_shortcut_key_to_up_input(key)?);
|
||||
}
|
||||
|
||||
// Add key "down" and "up" inputs for the input
|
||||
// (currently in this form: {username}/t{password})
|
||||
add_input(&input, &mut keyboard_inputs);
|
||||
|
||||
send_input::<Win32InputOperations, Win32ErrorOperations>(keyboard_inputs)
|
||||
}
|
||||
|
||||
// Add key "down" and "up" inputs for the input
|
||||
// (currently in this form: {username}/t{password})
|
||||
fn add_input(input: &[u16], keyboard_inputs: &mut Vec<INPUT>) {
|
||||
const TAB_KEY: u8 = 9;
|
||||
|
||||
for i in input {
|
||||
let next_down_input = if i == TAB_KEY.into() {
|
||||
build_virtual_key_input(InputKeyPress::Down, i as u8)
|
||||
let next_down_input = if *i == TAB_KEY.into() {
|
||||
build_virtual_key_input(InputKeyPress::Down, *i as u8)
|
||||
} else {
|
||||
build_unicode_input(InputKeyPress::Down, i)
|
||||
build_unicode_input(InputKeyPress::Down, *i)
|
||||
};
|
||||
let next_up_input = if i == TAB_KEY.into() {
|
||||
build_virtual_key_input(InputKeyPress::Up, i as u8)
|
||||
let next_up_input = if *i == TAB_KEY.into() {
|
||||
build_virtual_key_input(InputKeyPress::Up, *i as u8)
|
||||
} else {
|
||||
build_unicode_input(InputKeyPress::Up, i)
|
||||
build_unicode_input(InputKeyPress::Up, *i)
|
||||
};
|
||||
|
||||
keyboard_inputs.push(next_down_input);
|
||||
keyboard_inputs.push(next_up_input);
|
||||
}
|
||||
|
||||
send_input(keyboard_inputs)
|
||||
}
|
||||
|
||||
/// Converts a valid shortcut key to an "up" keyboard input.
|
||||
@@ -294,21 +206,20 @@ fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to type the provided input wherever the user's cursor is.
|
||||
///
|
||||
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
|
||||
fn send_input(inputs: Vec<INPUT>) -> Result<()> {
|
||||
let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::<INPUT>() as i32) };
|
||||
|
||||
debug!("SendInput() called.");
|
||||
fn send_input<I, E>(inputs: Vec<INPUT>) -> Result<()>
|
||||
where
|
||||
I: InputOperations,
|
||||
E: ErrorOperations,
|
||||
{
|
||||
let insert_count = I::send_input(&inputs);
|
||||
|
||||
if insert_count == 0 {
|
||||
let last_err = get_last_error().to_hresult().message();
|
||||
let last_err = E::get_last_error().to_hresult().message();
|
||||
error!(GetLastError = %last_err, "SendInput sent 0 inputs. Input was blocked by another thread.");
|
||||
|
||||
return Err(anyhow!("SendInput sent 0 inputs. Input was blocked by another thread. GetLastError: {last_err}"));
|
||||
} else if insert_count != inputs.len() as u32 {
|
||||
let last_err = get_last_error().to_hresult().message();
|
||||
let last_err = E::get_last_error().to_hresult().message();
|
||||
error!(sent = %insert_count, expected = inputs.len(), GetLastError = %last_err,
|
||||
"SendInput sent does not match expected."
|
||||
);
|
||||
@@ -318,17 +229,23 @@ fn send_input(inputs: Vec<INPUT>) -> Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
debug!(insert_count, "Autotype sent input.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! For the mocking of the traits that are static methods, we need to use the `serial_test` crate
|
||||
//! in order to mock those, since the mock expectations set have to be global in absence of a `self`.
|
||||
//! More info: https://docs.rs/mockall/latest/mockall/#static-methods
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::windowing::MockErrorOperations;
|
||||
use serial_test::serial;
|
||||
use windows::Win32::Foundation::WIN32_ERROR;
|
||||
|
||||
#[test]
|
||||
fn get_alphabetic_hot_key_happy() {
|
||||
fn get_alphabetic_hot_key_succeeds() {
|
||||
for c in ('a'..='z').chain('A'..='Z') {
|
||||
let letter = c.to_string();
|
||||
let converted = get_alphabetic_hotkey(letter).unwrap();
|
||||
@@ -349,4 +266,53 @@ mod tests {
|
||||
let letter = String::from("}");
|
||||
get_alphabetic_hotkey(letter).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn send_input_succeeds() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.expect().returning(|_| 1);
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
InputKeyPress::Up,
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[should_panic(
|
||||
expected = "SendInput sent 0 inputs. Input was blocked by another thread. GetLastError:"
|
||||
)]
|
||||
fn send_input_fails_sent_zero() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.expect().returning(|_| 0);
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
InputKeyPress::Up,
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")]
|
||||
fn send_input_fails_sent_mismatch() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.expect().returning(|_| 2);
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
InputKeyPress::Up,
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
298
apps/desktop/desktop_native/autotype/src/windows/window_title.rs
Normal file
298
apps/desktop/desktop_native/autotype/src/windows/window_title.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::{debug, error, warn};
|
||||
use windows::Win32::{
|
||||
Foundation::HWND,
|
||||
UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW},
|
||||
};
|
||||
|
||||
use super::{ErrorOperations, Win32ErrorOperations, WIN32_SUCCESS};
|
||||
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
trait WindowHandleOperations {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw
|
||||
fn get_window_text_length_w(&self) -> Result<i32>;
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw
|
||||
fn get_window_text_w(&self, buffer: &mut Vec<u16>) -> Result<i32>;
|
||||
}
|
||||
|
||||
/// `WindowHandle` provides a light wrapper over the `HWND` (which is just a void *).
|
||||
/// The raw pointer can become invalid during runtime so it's validity must be checked
|
||||
/// before usage.
|
||||
struct WindowHandle {
|
||||
handle: HWND,
|
||||
}
|
||||
|
||||
impl WindowHandle {
|
||||
/// Create a new `WindowHandle`
|
||||
fn new(handle: HWND) -> Self {
|
||||
Self { handle }
|
||||
}
|
||||
|
||||
/// Assert that the raw pointer is valid.
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.handle.is_invalid() {
|
||||
error!("Window handle is invalid.");
|
||||
return Err(anyhow!("Window handle is invalid."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowHandleOperations for WindowHandle {
|
||||
fn get_window_text_length_w(&self) -> Result<i32> {
|
||||
self.validate()?;
|
||||
let length = unsafe { GetWindowTextLengthW(self.handle) };
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
fn get_window_text_w(&self, buffer: &mut Vec<u16>) -> Result<i32> {
|
||||
self.validate()?;
|
||||
let len_written = unsafe { GetWindowTextW(self.handle, buffer) };
|
||||
Ok(len_written)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the title bar string for the foreground window.
|
||||
pub(super) fn get_foreground_window_title() -> Result<String> {
|
||||
let window_handle = get_foreground_window_handle()?;
|
||||
|
||||
let expected_window_title_length =
|
||||
get_window_title_length::<WindowHandle, Win32ErrorOperations>(&window_handle)?;
|
||||
|
||||
get_window_title::<WindowHandle, Win32ErrorOperations>(
|
||||
&window_handle,
|
||||
expected_window_title_length,
|
||||
)
|
||||
}
|
||||
|
||||
/// Retrieves the foreground window handle and validates it.
|
||||
fn get_foreground_window_handle() -> Result<WindowHandle> {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow
|
||||
let handle = unsafe { GetForegroundWindow() };
|
||||
|
||||
debug!("GetForegroundWindow() called.");
|
||||
|
||||
let window_handle = WindowHandle::new(handle);
|
||||
window_handle.validate()?;
|
||||
|
||||
Ok(window_handle)
|
||||
}
|
||||
|
||||
/// # Returns
|
||||
///
|
||||
/// The length of the window title.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - If the length zero and GetLastError() != 0, return the GetLastError() message.
|
||||
fn get_window_title_length<H, E>(window_handle: &H) -> Result<usize>
|
||||
where
|
||||
H: WindowHandleOperations,
|
||||
E: ErrorOperations,
|
||||
{
|
||||
// GetWindowTextLengthW does not itself clear the last error so we must do it ourselves.
|
||||
E::set_last_error(0);
|
||||
|
||||
let length = window_handle.get_window_text_length_w()?;
|
||||
|
||||
let length = usize::try_from(length)?;
|
||||
|
||||
debug!(length, "window text length retrieved from handle.");
|
||||
|
||||
if length == 0 {
|
||||
// attempt to retreive win32 error
|
||||
let last_err = E::get_last_error();
|
||||
if last_err != WIN32_SUCCESS {
|
||||
let last_err = last_err.to_hresult().message();
|
||||
error!(last_err, "Error getting window text length.");
|
||||
return Err(anyhow!("Error getting window text length: {last_err}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
/// Gets the window title bar title using the expected length to determine size of buffer
|
||||
/// to store it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// If the `expected_title_length` is zero, return an Ok result containing empty string. It
|
||||
/// Isn't considered an error by the Win32 API.
|
||||
///
|
||||
/// Otherwise, return the retrieved window title string.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - If the actual window title length (what the win32 API declares was written into the
|
||||
/// buffer), is length zero and GetLastError() != 0 , return the GetLastError() message.
|
||||
fn get_window_title<H, E>(window_handle: &H, expected_title_length: usize) -> Result<String>
|
||||
where
|
||||
H: WindowHandleOperations,
|
||||
E: ErrorOperations,
|
||||
{
|
||||
if expected_title_length == 0 {
|
||||
// This isn't considered an error by the windows API, but in practice it means we can't
|
||||
// match against the title so we'll stop here.
|
||||
// The upstream will make a contains comparison on what we return, so an empty string
|
||||
// will not result on a match.
|
||||
warn!("Window title length is zero.");
|
||||
return Ok(String::from(""));
|
||||
}
|
||||
|
||||
let mut buffer: Vec<u16> = vec![0; expected_title_length + 1]; // add extra space for the null character
|
||||
|
||||
let actual_window_title_length = window_handle.get_window_text_w(&mut buffer)?;
|
||||
|
||||
debug!(actual_window_title_length, "window title retrieved.");
|
||||
|
||||
if actual_window_title_length == 0 {
|
||||
// attempt to retreive win32 error
|
||||
let last_err = E::get_last_error();
|
||||
if last_err != WIN32_SUCCESS {
|
||||
let last_err = last_err.to_hresult().message();
|
||||
error!(last_err, "Error retrieving window title.");
|
||||
return Err(anyhow!("Error retrieving window title: {last_err}"));
|
||||
}
|
||||
// in practice, we should not get to the below code, since we asserted the len > 0
|
||||
// above. but it is an extra protection in case the windows API didn't set an error.
|
||||
warn!(expected_title_length, "No window title retrieved.");
|
||||
}
|
||||
|
||||
let window_title = OsString::from_wide(&buffer);
|
||||
|
||||
Ok(window_title.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! For the mocking of the traits that are static methods, we need to use the `serial_test` crate
|
||||
//! in order to mock those, since the mock expectations set have to be global in absence of a `self`.
|
||||
//! More info: https://docs.rs/mockall/latest/mockall/#static-methods
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::windowing::MockErrorOperations;
|
||||
use mockall::predicate;
|
||||
use serial_test::serial;
|
||||
use windows::Win32::Foundation::WIN32_ERROR;
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_window_title_length_can_be_zero() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let ctxse = MockErrorOperations::set_last_error_context();
|
||||
ctxse
|
||||
.expect()
|
||||
.once()
|
||||
.with(predicate::eq(0))
|
||||
.returning(|_| {});
|
||||
|
||||
mock_handle
|
||||
.expect_get_window_text_length_w()
|
||||
.once()
|
||||
.returning(|| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(0));
|
||||
|
||||
let len = get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(
|
||||
&mock_handle,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(len, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[should_panic(expected = "Error getting window text length:")]
|
||||
fn get_window_title_length_fails() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let ctxse = MockErrorOperations::set_last_error_context();
|
||||
ctxse.expect().with(predicate::eq(0)).returning(|_| {});
|
||||
|
||||
mock_handle
|
||||
.expect_get_window_text_length_w()
|
||||
.once()
|
||||
.returning(|| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_window_title_succeeds() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
mock_handle
|
||||
.expect_get_window_text_w()
|
||||
.once()
|
||||
.returning(|buffer| {
|
||||
buffer.fill_with(|| 42); // because why not
|
||||
Ok(42)
|
||||
});
|
||||
|
||||
let title =
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(title.len(), 43); // That extra slot in the buffer for null char
|
||||
|
||||
assert_eq!(title, "*******************************************");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_window_title_returns_empty_string() {
|
||||
let mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let title =
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(title, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[should_panic(expected = "Error retrieving window title:")]
|
||||
fn get_window_title_fails_with_last_error() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
mock_handle
|
||||
.expect_get_window_text_w()
|
||||
.once()
|
||||
.returning(|_| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_window_title_doesnt_fail_but_reads_zero() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
mock_handle
|
||||
.expect_get_window_text_w()
|
||||
.once()
|
||||
.returning(|_| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(0));
|
||||
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user