From 7b75e28bf0b77a86b76e3b39bfcb32817d1505c4 Mon Sep 17 00:00:00 2001 From: timburman Date: Tue, 4 Aug 2026 23:44:19 +0530 Subject: [PATCH 1/4] feat: dektop switch with window focus restore --- dll/src/lib.rs | 5 ++++ src/comobjects.rs | 58 +++++++++++++++++++++++++++++++++++++++++++---- src/desktop.rs | 9 ++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/dll/src/lib.rs b/dll/src/lib.rs index 3eed142..f7e2c0b 100644 --- a/dll/src/lib.rs +++ b/dll/src/lib.rs @@ -72,6 +72,11 @@ 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 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..48679e9 100644 --- a/src/comobjects.rs +++ b/src/comobjects.rs @@ -604,13 +604,63 @@ impl ComObjects { unsafe { notification_service.unregister(cookie).as_result() } } + pub fn focus_top_window_on_desktop(&self, desktop: &DesktopInternal) -> Result<()> { + 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; + unsafe { + let _ = view.get_virtual_desktop_id(&mut view_desktop_id); + let _ = view.get_show_in_switchers(&mut show_in_switchers); + } + if view_desktop_id == desktop_guid && show_in_switchers != 0 { + unsafe { + let _ = view.set_focus(); + let mut hwnd = HWND::default(); + if view.get_thumbnail_window(&mut hwnd).is_ok() + && hwnd != HWND::default() + { + let _ = windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow(hwnd); + } + } + return Ok(()); + } + } + } + } + } + Ok(()) + } + #[apply(retry_function)] pub fn switch_desktop(&self, desktop: &DesktopInternal) -> Result<()> { - let desktop = self.get_idesktop(desktop)?; + 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(ComIn::new(&desktop_obj)) + .as_result()?; + } + let _ = self.focus_top_window_on_desktop(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 { + 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..be81463 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -155,6 +155,15 @@ where with_com_objects(move |o| o.switch_desktop(&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 From 5296fbcd760831c39a3972fbab8f6e84b0280516 Mon Sep 17 00:00:00 2001 From: timburman Date: Wed, 5 Aug 2026 02:40:43 +0530 Subject: [PATCH 2/4] refactor: desktop switching into raw and high-level APIs --- dll/src/lib.rs | 5 ++ src/comobjects.rs | 168 +++++++++++++++++++++++++++++++++++++++++++--- src/desktop.rs | 11 ++- src/listener.rs | 2 +- 4 files changed, 173 insertions(+), 13 deletions(-) diff --git a/dll/src/lib.rs b/dll/src/lib.rs index f7e2c0b..42e90c4 100644 --- a/dll/src/lib.rs +++ b/dll/src/lib.rs @@ -72,6 +72,11 @@ 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) diff --git a/src/comobjects.rs b/src/comobjects.rs index 48679e9..81c3ccf 100644 --- a/src/comobjects.rs +++ b/src/comobjects.rs @@ -598,13 +598,99 @@ 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() == false { + 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() } } - pub fn focus_top_window_on_desktop(&self, desktop: &DesktopInternal) -> 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; @@ -617,21 +703,29 @@ impl ComObjects { 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 { + + if view_desktop_id == desktop_guid + && show_in_switchers != 0 + && can_receive_input != 0 + { + let mut hwnd = HWND::default(); unsafe { - let _ = view.set_focus(); - let mut hwnd = HWND::default(); - if view.get_thumbnail_window(&mut hwnd).is_ok() - && hwnd != HWND::default() - { - let _ = windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow(hwnd); + 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(()); } } - return Ok(()); } } } @@ -640,8 +734,9 @@ impl ComObjects { Ok(()) } + /// Pure COM switch desktop without focus restoration side-effects. #[apply(retry_function)] - pub fn switch_desktop(&self, desktop: &DesktopInternal) -> Result<()> { + pub fn switch_desktop_raw(&self, desktop: &DesktopInternal) -> Result<()> { let desktop_obj = self.get_idesktop(desktop)?; let manager_internal = self.get_manager_internal()?; unsafe { @@ -649,7 +744,58 @@ impl ComObjects { .switch_desktop(ComIn::new(&desktop_obj)) .as_result()?; } - let _ = self.focus_top_window_on_desktop(desktop); + Ok(()) + } + + /// Switches to the specified virtual desktop and restores focus to its top application view. + pub fn switch_desktop(&self, desktop: &DesktopInternal) -> Result<()> { + // 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(()) } diff --git a/src/desktop.rs b/src/desktop.rs index be81463..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,15 @@ 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 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(); From 2fef20a5dbf2f8916c2f4a39ea47db9fe17217f4 Mon Sep 17 00:00:00 2001 From: timburman Date: Wed, 5 Aug 2026 02:41:07 +0530 Subject: [PATCH 3/4] Update documentation and tests --- README.md | 4 +++- src/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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/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 + ); + }); +} From 337e3a1bb62b20e3f749f076547dfd9cd70f8658 Mon Sep 17 00:00:00 2001 From: timburman Date: Thu, 6 Aug 2026 12:13:42 +0530 Subject: [PATCH 4/4] refactor: apply rustfmt and clean up clippy warnings --- dll/src/lib.rs | 1 + src/comobjects.rs | 18 ++++++++++++------ src/events.rs | 3 ++- src/interfaces.rs | 6 +++--- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/dll/src/lib.rs b/dll/src/lib.rs index 42e90c4..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::{ diff --git a/src/comobjects.rs b/src/comobjects.rs index 81c3ccf..8edc48e 100644 --- a/src/comobjects.rs +++ b/src/comobjects.rs @@ -621,8 +621,8 @@ impl ComObjects { // 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, + GetWindowLongW, GetWindowRect, GetWindowTextW, IsIconic, IsWindowVisible, GWL_EXSTYLE, + WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, }; if hwnd == HWND::default() { @@ -636,7 +636,7 @@ impl ComObjects { } // Skip non-visible windows - if IsWindowVisible(hwnd).as_bool() == false { + if !IsWindowVisible(hwnd).as_bool() { return false; } @@ -665,7 +665,9 @@ impl ComObjects { 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 { + if width < Self::SMALL_TOPMOST_MAX_WIDTH + && height < Self::SMALL_TOPMOST_MAX_HEIGHT + { return false; } } @@ -755,7 +757,9 @@ impl ComObjects { (self.get_desktop_id(¤t), self.get_desktop_id(desktop)) { if curr_guid == target_guid { - use windows::Win32::UI::WindowsAndMessaging::{GetClassNameW, GetForegroundWindow}; + use windows::Win32::UI::WindowsAndMessaging::{ + GetClassNameW, GetForegroundWindow, + }; let fg_hwnd = unsafe { GetForegroundWindow() }; if fg_hwnd != HWND::default() { let mut class_buf = [0u16; 256]; @@ -790,7 +794,9 @@ impl ComObjects { } } } - std::thread::sleep(std::time::Duration::from_millis(Self::DESKTOP_SWITCH_RETRY_DELAY_MS)); + std::thread::sleep(std::time::Duration::from_millis( + Self::DESKTOP_SWITCH_RETRY_DELAY_MS, + )); attempts += 1; } } 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; ///