From b804ee7d986f893243ddc0f1343969e5d327bb82 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:56:31 -0600 Subject: [PATCH 1/3] Add desktop autotype unittests for windows --- apps/desktop/desktop_native/Cargo.lock | 112 +++++++ .../desktop_native/autotype/Cargo.toml | 2 + .../desktop_native/autotype/src/lib.rs | 7 +- .../autotype/src/windows/mod.rs | 41 +++ .../src/{windows.rs => windows/type_input.rs} | 241 ++++++--------- .../autotype/src/windows/window_title.rs | 292 ++++++++++++++++++ 6 files changed, 554 insertions(+), 141 deletions(-) create mode 100644 apps/desktop/desktop_native/autotype/src/windows/mod.rs rename apps/desktop/desktop_native/autotype/src/{windows.rs => windows/type_input.rs} (56%) create mode 100644 apps/desktop/desktop_native/autotype/src/windows/window_title.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 9020e08362e8..e84fb64bb6a1 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -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", @@ -1043,6 +1045,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" @@ -1246,6 +1254,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" @@ -1879,6 +1893,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" @@ -2532,6 +2572,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" @@ -2824,6 +2890,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" @@ -2867,6 +2942,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" @@ -2971,6 +3052,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" @@ -3221,6 +3327,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" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index 3d1e74254ce4..267074d0bc83 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -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", diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index 92996996434f..c87fea23b60d 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -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 { /// /// # 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, keyboard_shortcut: Vec) -> Result<()> { windowing::type_input(input, keyboard_shortcut) diff --git a/apps/desktop/desktop_native/autotype/src/windows/mod.rs b/apps/desktop/desktop_native/autotype/src/windows/mod.rs new file mode 100644 index 000000000000..3ea63b2b8f44 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows/mod.rs @@ -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 { + window_title::get_foreground_window_title() +} + +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { + type_input::type_input(input, keyboard_shortcut) +} diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs similarity index 56% rename from apps/desktop/desktop_native/autotype/src/windows.rs rename to apps/desktop/desktop_native/autotype/src/windows/type_input.rs index 1e125ef8e214..cd4014f8e621 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -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); - -fn clear_last_error() { - debug!("Clearing last error with SetLastError."); - unsafe { - SetLastError(WIN32_ERROR(0)); - } -} +use super::{ErrorOperations, Win32ErrorOperations}; -fn get_last_error() -> WIN32_ERROR { - let last_err = unsafe { GetLastError() }; - debug!("GetLastError(): {}", last_err.to_hresult().message()); - last_err +/// `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: &Vec) -> u32; } -// 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(()) -} +struct Win32InputOperations; -// ---------- Window title -------------- - -/// Gets the title bar string for the foreground window. -pub fn get_foreground_window_title() -> Result { - // 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 { - // GetWindowTextLengthW does not itself clear the last error so we must do it ourselves. - clear_last_error(); +impl InputOperations for Win32InputOperations { + fn send_input(inputs: &Vec) -> u32 { + const INPUT_STRUCT_SIZE: i32 = std::mem::size_of::() as i32; + let insert_count = unsafe { SendInput(&inputs, INPUT_STRUCT_SIZE) }; - validate_window_handle(window_handle)?; + debug!(insert_count, "SendInput() called."); - 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}")); - } + insert_count } - - 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 { - 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 = 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, keyboard_shortcut: Vec) -> Result<()> { - const TAB_KEY: u8 = 9; - +pub(super) fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { // the length of this vec is always shortcut keys to release + (2x length of input chars) let mut keyboard_inputs: Vec = Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2)); @@ -142,25 +48,31 @@ pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> 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::(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) { + 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) -> Result<()> { - let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; - - debug!("SendInput() called."); +fn send_input(inputs: Vec) -> 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,20 +229,25 @@ fn send_input(inputs: Vec) -> 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(); - println!("{}", letter); let converted = get_alphabetic_hotkey(letter).unwrap(); assert_eq!(converted, c as u16); } @@ -350,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::(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::(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::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } } diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs new file mode 100644 index 000000000000..45ee05fd442b --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -0,0 +1,292 @@ +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 HandleOperations { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw + fn get_window_text_length_w(&self) -> Result; + + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw + fn get_window_text_w(&self, buffer: &mut Vec) -> Result; +} + +/// `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 HandleOperations for WindowHandle { + fn get_window_text_length_w(&self) -> Result { + self.validate()?; + let length = unsafe { GetWindowTextLengthW(self.handle) }; + Ok(length) + } + + fn get_window_text_w(&self, buffer: &mut Vec) -> Result { + 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 { + let window_handle = get_foreground_window_handle()?; + + let expected_window_title_length = + get_window_title_length::(&window_handle)?; + + get_window_title::( + &window_handle, + expected_window_title_length, + ) +} + +/// Retrieves the foreground window handle and validates it. +fn get_foreground_window_handle() -> Result { + // 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(window_handle: &H) -> Result +where + H: HandleOperations, + 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(window_handle: &H, expected_title_length: usize) -> Result +where + H: HandleOperations, + 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 = 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 = MockHandleOperations::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::(&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 = MockHandleOperations::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::(&mock_handle).unwrap(); + } + + #[test] + fn get_window_title_succeeds() { + let mut mock_handle = MockHandleOperations::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::(&mock_handle, 42) + .unwrap(); + + assert_eq!(title.len(), 43); // TODO Hmm do we need that extra slot in the buffer + + assert_eq!(title, "*******************************************"); + } + + #[test] + fn get_window_title_returns_empty_string() { + let mock_handle = MockHandleOperations::new(); + + let title = + get_window_title::(&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 = MockHandleOperations::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::(&mock_handle, 42).unwrap(); + } + + #[test] + #[serial] + fn get_window_title_doesnt_fail_but_reads_zero() { + let mut mock_handle = MockHandleOperations::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::(&mock_handle, 42).unwrap(); + } +} From f141364f91216eb730661eb4bc3581c2b6c96a15 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:45:10 -0600 Subject: [PATCH 2/3] lint --- .../desktop_native/autotype/src/windows/type_input.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs index cd4014f8e621..b757cf7752ff 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -14,15 +14,15 @@ 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: &Vec) -> u32; + fn send_input(inputs: &[INPUT]) -> u32; } struct Win32InputOperations; impl InputOperations for Win32InputOperations { - fn send_input(inputs: &Vec) -> u32 { + fn send_input(inputs: &[INPUT]) -> u32 { const INPUT_STRUCT_SIZE: i32 = std::mem::size_of::() as i32; - let insert_count = unsafe { SendInput(&inputs, INPUT_STRUCT_SIZE) }; + let insert_count = unsafe { SendInput(inputs, INPUT_STRUCT_SIZE) }; debug!(insert_count, "SendInput() called."); From 9951bca35241db7315addb656d05f9935ffa24cb Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:17:39 -0600 Subject: [PATCH 3/3] fix TODO comment --- .../desktop/desktop_native/autotype/src/windows/window_title.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs index 45ee05fd442b..e5e17ff743e3 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -242,7 +242,7 @@ mod tests { let title = get_window_title::(&mock_handle, 42) .unwrap(); - assert_eq!(title.len(), 43); // TODO Hmm do we need that extra slot in the buffer + assert_eq!(title.len(), 43); // That extra slot in the buffer for null char assert_eq!(title, "*******************************************"); }