diff --git a/README.md b/README.md index 60f07f0..499fa6b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ fn GetWindowDesktopId(hwnd: HWND) -> GUID fn GetWindowDesktopNumber(hwnd: HWND) -> i32 fn IsWindowOnCurrentVirtualDesktop(hwnd: HWND) -> i32 fn MoveWindowToDesktopNumber(hwnd: HWND, desktop_number: i32) -> i32 -fn GoToDesktopNumber(desktop_number: i32) -> i32 +fn GoToDesktopNumber(desktop_number: i32) -> i32 // Win11 24H2+: Automatically restores focus to top application +fn GoToDesktopNumberRaw(desktop_number: i32) -> i32 // Pure COM desktop switch without focus restoration +fn GoToDesktopNumberAndMoveForegroundWindow(desktop_number: i32) -> i32 // Moves active window to target desktop and switches to it fn SetDesktopName(desktop_number: i32, in_name_ptr: *const i8) -> i32 // Win11 only fn GetDesktopName(desktop_number: i32, out_utf8_ptr: *mut u8, out_utf8_len: usize) -> i32 // Win11 only fn RegisterPostMessageHook(listener_hwnd: HWND, message_offset: u32) -> i32 diff --git a/dll/src/lib.rs b/dll/src/lib.rs index 3eed142..c2de527 100644 --- a/dll/src/lib.rs +++ b/dll/src/lib.rs @@ -1,4 +1,5 @@ #![allow(non_snake_case)] +#![allow(clippy::not_unsafe_ptr_arg_deref)] use once_cell::sync::Lazy; use std::{ @@ -72,6 +73,16 @@ pub extern "C" fn GoToDesktopNumber(desktop_number: i32) -> i32 { switch_desktop(desktop_number as u32).map_or(-1, |_| 1) } +#[no_mangle] +pub extern "C" fn GoToDesktopNumberRaw(desktop_number: i32) -> i32 { + switch_desktop_raw(desktop_number as u32).map_or(-1, |_| 1) +} + +#[no_mangle] +pub extern "C" fn GoToDesktopNumberAndMoveForegroundWindow(desktop_number: i32) -> i32 { + move_foreground_window_to_desktop(desktop_number as u32).map_or(-1, |_| 1) +} + #[no_mangle] pub extern "C" fn SetDesktopName(desktop_number: i32, in_name_ptr: *const i8) -> i32 { let name_str = unsafe { CStr::from_ptr(in_name_ptr).to_string_lossy() }; diff --git a/src/comobjects.rs b/src/comobjects.rs index 4d2296d..8edc48e 100644 --- a/src/comobjects.rs +++ b/src/comobjects.rs @@ -598,19 +598,221 @@ impl ComObjects { } } + /// Maximum retry attempts when waiting for OS virtual desktop switch confirmation. + const DESKTOP_SWITCH_RETRIES: usize = 10; + + /// Delay in milliseconds between desktop switch status polling retries. + const DESKTOP_SWITCH_RETRY_DELAY_MS: u64 = 5; + + /// Maximum width threshold for small floating WS_EX_TOPMOST windows (e.g. PiP overlays, HUDs). + const SMALL_TOPMOST_MAX_WIDTH: i32 = 800; + + /// Maximum height threshold for small floating WS_EX_TOPMOST windows (e.g. PiP overlays, HUDs). + const SMALL_TOPMOST_MAX_HEIGHT: i32 = 600; + + // Experimental heuristic. + // + // Some Picture-in-Picture (PiP) windows are reported near the top of the + // application Z-order and may receive focus after a desktop switch. + // Until Windows exposes a reliable way to identify these windows, apply a + // conservative heuristic based on window styles, size and (where necessary) + // window title. + // + // This heuristic may be refined as additional PiP implementations are tested. + fn is_focusable_window(hwnd: HWND) -> bool { + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowLongW, GetWindowRect, GetWindowTextW, IsIconic, IsWindowVisible, GWL_EXSTYLE, + WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, + }; + + if hwnd == HWND::default() { + return false; + } + + unsafe { + // Skip minimized windows + if IsIconic(hwnd).as_bool() { + return false; + } + + // Skip non-visible windows + if !IsWindowVisible(hwnd).as_bool() { + return false; + } + + // Filter out Tool Windows and Non-Activatable Windows + let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; + if ex_style & (WS_EX_TOOLWINDOW.0 | WS_EX_NOACTIVATE.0) != 0 { + return false; + } + + // Filter out Picture-In-Picture windows by title + let mut title_buf = [0u16; 256]; + let len = GetWindowTextW(hwnd, &mut title_buf); + if len > 0 { + let title = String::from_utf16_lossy(&title_buf[..len as usize]).to_lowercase(); + if title.contains("picture-in-picture") + || title.contains("picture in picture") + || title == "pip" + { + return false; + } + } + + // Filter out small floating WS_EX_TOPMOST windows (e.g. video overlays, HUDs) + if ex_style & WS_EX_TOPMOST.0 != 0 { + let mut rect = windows::Win32::Foundation::RECT::default(); + if GetWindowRect(hwnd, &mut rect).is_ok() { + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width < Self::SMALL_TOPMOST_MAX_WIDTH + && height < Self::SMALL_TOPMOST_MAX_HEIGHT + { + return false; + } + } + } + } + + true + } + #[apply(retry_function)] pub fn unregister_for_notifications(&self, cookie: u32) -> Result<()> { let notification_service = self.get_notification_service()?; unsafe { notification_service.unregister(cookie).as_result() } } + /// Restores keyboard focus to the highest Z-ordered visible application view on the target desktop. + /// + /// Note: Starting with Windows 11 24H2+, IVirtualDesktopManagerInternal::switch_desktop() + /// switches the desktop view, but no longer automatically transfers active window focus. + /// To match native Explorer behavior, this function queries IApplicationViewCollection + /// ordered by Z-order, skipping minimized windows, invisible views, and Picture-in-Picture / Tool + /// windows, and sets focus to the primary active application. + pub fn restore_desktop_focus(&self, desktop: &DesktopInternal) -> Result<()> { + use windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow; + + let desktop_guid = self.get_desktop_id(desktop)?; + if let Ok(view_collection) = self.get_view_collection() { + let mut views_array: Option = None; + unsafe { + let _ = view_collection.get_views_by_zorder(&mut views_array as *mut _ as *mut _); + } + if let Some(views) = views_array { + let count = unsafe { views.GetCount().unwrap_or(0) }; + for i in 0..count { + if let Ok(view) = unsafe { views.GetAt::(i) } { + let mut view_desktop_id = GUID::default(); + let mut show_in_switchers = 0; + let mut can_receive_input = 0; + unsafe { + let _ = view.get_virtual_desktop_id(&mut view_desktop_id); + let _ = view.get_show_in_switchers(&mut show_in_switchers); + let _ = view.can_receive_input(&mut can_receive_input); + } + + if view_desktop_id == desktop_guid + && show_in_switchers != 0 + && can_receive_input != 0 + { + let mut hwnd = HWND::default(); + unsafe { + if view.get_thumbnail_window(&mut hwnd).is_ok() { + if !Self::is_focusable_window(hwnd) { + continue; + } + + let _ = view.set_focus(); + let _ = SetForegroundWindow(hwnd); + return Ok(()); + } + } + } + } + } + } + } + Ok(()) + } + + /// Pure COM switch desktop without focus restoration side-effects. #[apply(retry_function)] + pub fn switch_desktop_raw(&self, desktop: &DesktopInternal) -> Result<()> { + let desktop_obj = self.get_idesktop(desktop)?; + let manager_internal = self.get_manager_internal()?; + unsafe { + manager_internal + .switch_desktop(ComIn::new(&desktop_obj)) + .as_result()?; + } + Ok(()) + } + + /// Switches to the specified virtual desktop and restores focus to its top application view. pub fn switch_desktop(&self, desktop: &DesktopInternal) -> Result<()> { - let desktop = self.get_idesktop(desktop)?; + // Handle same-desktop trigger: if already on target desktop, check if foreground window was stolen + if let Ok(current) = self.get_current_desktop() { + if let (Ok(curr_guid), Ok(target_guid)) = + (self.get_desktop_id(¤t), self.get_desktop_id(desktop)) + { + if curr_guid == target_guid { + use windows::Win32::UI::WindowsAndMessaging::{ + GetClassNameW, GetForegroundWindow, + }; + let fg_hwnd = unsafe { GetForegroundWindow() }; + if fg_hwnd != HWND::default() { + let mut class_buf = [0u16; 256]; + let len = unsafe { GetClassNameW(fg_hwnd, &mut class_buf) }; + if len > 0 { + let class_name = String::from_utf16_lossy(&class_buf[..len as usize]); + if class_name != "Shell_TrayWnd" + && class_name != "WorkerW" + && class_name != "Progman" + { + return Ok(()); + } + } + } + // If foreground was stolen by Taskbar/Shell, restore focus back to the top app + let _ = self.restore_desktop_focus(desktop); + return Ok(()); + } + } + } + + self.switch_desktop_raw(desktop)?; + + // Briefly wait for OS desktop switch confirmation before restoring focus + if let Ok(target_guid) = self.get_desktop_id(desktop) { + let mut attempts = 0; + while attempts < Self::DESKTOP_SWITCH_RETRIES { + if let Ok(current) = self.get_current_desktop() { + if let Ok(current_guid) = self.get_desktop_id(¤t) { + if current_guid == target_guid { + break; + } + } + } + std::thread::sleep(std::time::Duration::from_millis( + Self::DESKTOP_SWITCH_RETRY_DELAY_MS, + )); + attempts += 1; + } + } + + let _ = self.restore_desktop_focus(desktop); + Ok(()) + } + + #[apply(retry_function)] + pub fn move_foreground_window_to_desktop(&self, desktop: &DesktopInternal) -> Result<()> { + let desktop_obj = self.get_idesktop(desktop)?; + let manager_internal = self.get_manager_internal()?; unsafe { - self.get_manager_internal()? - .switch_desktop(ComIn::new(&desktop)) - .as_result()? + manager_internal + .switch_desktop_and_move_foreground_view(ComIn::new(&desktop_obj)) + .as_result()?; } Ok(()) } diff --git a/src/desktop.rs b/src/desktop.rs index a5f5a50..820794b 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -146,7 +146,7 @@ where desktop.into() } -/// Switch desktop by index or GUID +/// Switch desktop by index or GUID (with automatic focus restoration) pub fn switch_desktop(desktop: T) -> Result<()> where T: Into, @@ -155,6 +155,24 @@ where with_com_objects(move |o| o.switch_desktop(&desktop.into().into())) } +/// Raw COM switch desktop without focus restoration +pub fn switch_desktop_raw(desktop: T) -> Result<()> +where + T: Into, + T: Send + 'static + Copy, +{ + with_com_objects(move |o| o.switch_desktop_raw(&desktop.into().into())) +} + +/// Move active foreground window to desktop and switch to it +pub fn move_foreground_window_to_desktop(desktop: T) -> Result<()> +where + T: Into, + T: Send + 'static + Copy, +{ + with_com_objects(move |o| o.move_foreground_window_to_desktop(&desktop.into().into())) +} + /// Remove desktop by index or GUID pub fn remove_desktop(desktop: T, fallback_desktop: T) -> Result<()> where diff --git a/src/events.rs b/src/events.rs index 237e940..7025df2 100644 --- a/src/events.rs +++ b/src/events.rs @@ -105,7 +105,8 @@ unsafe impl Send for DesktopEvent {} /// /// # Example /// -/// ```rust +/// ```rust,no_run +/// use winvd::*; /// let (tx, rx) = std::sync::mpsc::channel::(); /// let _notifications_thread = listen_desktop_events(tx); /// // Do with receiver something diff --git a/src/interfaces.rs b/src/interfaces.rs index 810f8b9..02b2e4c 100644 --- a/src/interfaces.rs +++ b/src/interfaces.rs @@ -1,4 +1,5 @@ #![allow(non_camel_case_types)] +#![allow(non_upper_case_globals)] /// Interface definitions for the Virtual Desktop API /// /// Most of the functions are not tested or used, beware if you try to use these @@ -35,7 +36,6 @@ /// /// If you read the rules carefully, ComIn is most common usecase in Rust /// API definitions as most parameters are `In` parameters. -#[allow(non_upper_case_globals)] use std::ffi::c_void; use std::ops::Deref; use windows::{ @@ -56,7 +56,7 @@ use windows::{ /// /// E.g. /// -/// ```rust +/// ```rust,ignore /// fn get_current_desktop(&mut self, desktop: &mut Option) -> HRESULT; /// fn switch_desktop(&self, desktop: ManuallyDrop) -> HRESULT; /// @@ -71,7 +71,7 @@ use windows::{ /// /// To make things safer and easier to use, ComIn is used instead. /// -/// ```rust +/// ```rust,ignore /// fn get_current_desktop(&mut self, desktop: &mut Option) -> HRESULT; /// fn switch_desktop(&self, desktop: ComIn) -> HRESULT; /// diff --git a/src/listener.rs b/src/listener.rs index a9f830c..9a84651 100644 --- a/src/listener.rs +++ b/src/listener.rs @@ -139,7 +139,7 @@ impl<'a> VirtualDesktopNotificationWrapper<'a> { pub fn new( com_objects: &'a ComObjects, sender: Box, - ) -> Result>> { + ) -> Result>>> { let ptr: Pin> = Pin::new(Box::new(VirtualDesktopNotification { sender }.into())); let raw_ptr = ptr.as_raw(); diff --git a/src/tests.rs b/src/tests.rs index d23ae61..c86dbc2 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -440,3 +440,17 @@ fn test_desktop_count() { assert!(count > 1); }) } + +#[test] +fn test_switch_desktop_raw() { + sync_test(|| { + let current_desktop = get_current_desktop().unwrap().get_index().unwrap(); + switch_desktop_raw(0).unwrap(); + assert_eq!(get_current_desktop().unwrap().get_index().unwrap(), 0); + switch_desktop_raw(current_desktop).unwrap(); + assert_eq!( + get_current_desktop().unwrap().get_index().unwrap(), + current_desktop + ); + }); +}