Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions dll/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![allow(non_snake_case)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]

use once_cell::sync::Lazy;
use std::{
Expand Down Expand Up @@ -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() };
Expand Down
210 changes: 206 additions & 4 deletions src/comobjects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IObjectArray> = 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::<IApplicationView>(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(&current), 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(&current) {
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(())
}
Expand Down
20 changes: 19 additions & 1 deletion src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(desktop: T) -> Result<()>
where
T: Into<Desktop>,
Expand All @@ -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<T>(desktop: T) -> Result<()>
where
T: Into<Desktop>,
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<T>(desktop: T) -> Result<()>
where
T: Into<Desktop>,
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<T>(desktop: T, fallback_desktop: T) -> Result<()>
where
Expand Down
3 changes: 2 additions & 1 deletion src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ unsafe impl Send for DesktopEvent {}
///
/// # Example
///
/// ```rust
/// ```rust,no_run
/// use winvd::*;
/// let (tx, rx) = std::sync::mpsc::channel::<DesktopEvent>();
/// let _notifications_thread = listen_desktop_events(tx);
/// // Do with receiver something
Expand Down
6 changes: 3 additions & 3 deletions src/interfaces.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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::{
Expand All @@ -56,7 +56,7 @@ use windows::{
///
/// E.g.
///
/// ```rust
/// ```rust,ignore
/// fn get_current_desktop(&mut self, desktop: &mut Option<IVirtualDesktop>) -> HRESULT;
/// fn switch_desktop(&self, desktop: ManuallyDrop<IVirtualDesktop>) -> HRESULT;
///
Expand All @@ -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<IVirtualDesktop>) -> HRESULT;
/// fn switch_desktop(&self, desktop: ComIn<IVirtualDesktop>) -> HRESULT;
///
Expand Down
2 changes: 1 addition & 1 deletion src/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ impl<'a> VirtualDesktopNotificationWrapper<'a> {
pub fn new(
com_objects: &'a ComObjects,
sender: Box<dyn Fn(DesktopEvent)>,
) -> Result<Pin<Box<VirtualDesktopNotificationWrapper>>> {
) -> Result<Pin<Box<VirtualDesktopNotificationWrapper<'a>>>> {
let ptr: Pin<Box<IVirtualDesktopNotification>> =
Pin::new(Box::new(VirtualDesktopNotification { sender }.into()));
let raw_ptr = ptr.as_raw();
Expand Down
14 changes: 14 additions & 0 deletions src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
}