diff --git a/.gitignore b/.gitignore index c2819b03..2606d3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ Cargo.lock # We don't want to commit IDE configuration files. .idea/ .vscode/ +*.iml + +# Operating system generated metadata files +.DS_Store +Thumbs.db +Desktop.ini diff --git a/Cargo.toml b/Cargo.toml index 0e5b9072..221f7a06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,5 +3,6 @@ members = [ "winapps", "winapps-cli", "winapps-gui", + "winapps-monitor", ] resolver = "2" diff --git a/winapps-cli/src/main.rs b/winapps-cli/src/main.rs index 3fb78ee0..140be7d1 100644 --- a/winapps-cli/src/main.rs +++ b/winapps-cli/src/main.rs @@ -1,7 +1,7 @@ -use clap::{arg, Command}; +use clap::{Command, arg}; use winapps::freerdp::freerdp_back::Freerdp; use winapps::quickemu::{create_vm, kill_vm, start_vm}; -use winapps::{unwrap_or_panic, RemoteClient}; +use winapps::{RemoteClient, unwrap_or_panic}; fn cli() -> Command { Command::new("winapps-cli") diff --git a/winapps-monitor/Cargo.toml b/winapps-monitor/Cargo.toml new file mode 100644 index 00000000..9b80b900 --- /dev/null +++ b/winapps-monitor/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "winapps-monitor" +version = "0.1.0" +edition = "2024" + +[dependencies] +tray-icon = "0.21.1" +ico = "0.4.0" +anyhow = "1.0.100" +chrono = "0.4.42" + +[dependencies.windows] +version = "0.62.1" +features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", + "Win32_System_Threading", + "Win32_Storage_FileSystem", + "Win32_System_ProcessStatus", + "Win32_Graphics_Dwm", + "Win32_UI_Accessibility", + "Win32_System_Com" +] diff --git a/winapps-monitor/assets/icons/system_tray_icon.ico b/winapps-monitor/assets/icons/system_tray_icon.ico new file mode 100644 index 00000000..ebb54847 Binary files /dev/null and b/winapps-monitor/assets/icons/system_tray_icon.ico differ diff --git a/winapps-monitor/assets/icons/system_tray_icon.svg b/winapps-monitor/assets/icons/system_tray_icon.svg new file mode 100644 index 00000000..548c4159 --- /dev/null +++ b/winapps-monitor/assets/icons/system_tray_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/winapps-monitor/src/main.rs b/winapps-monitor/src/main.rs new file mode 100644 index 00000000..e4a50684 --- /dev/null +++ b/winapps-monitor/src/main.rs @@ -0,0 +1,551 @@ +use anyhow::Result; +use chrono::Local; +use std::cell::Cell; +use std::collections::HashMap; +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use tray_icon::{Icon, TrayIconBuilder}; +use windows::{ + Win32::{ + Foundation::{HWND, LPARAM}, + Graphics::Dwm::{DWMWA_CLOAKED, DwmGetWindowAttribute}, + UI::{ + Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent}, + WindowsAndMessaging::{ + DispatchMessageW, EVENT_OBJECT_CLOAKED, EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY, + EVENT_OBJECT_HIDE, EVENT_OBJECT_NAMECHANGE, EVENT_OBJECT_SHOW, + EVENT_OBJECT_UNCLOAKED, EnumChildWindows, EnumWindows, GA_ROOTOWNER, GWL_EXSTYLE, + GetAncestor, GetClassNameW, GetLastActivePopup, GetMessageW, GetWindowLongPtrW, + GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible, + MSG, OBJECT_IDENTIFIER, OBJID_WINDOW, TranslateMessage, WINEVENT_OUTOFCONTEXT, + WINEVENT_SKIPOWNPROCESS, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW, + }, + }, + }, + core::BOOL, +}; + +type WindowKey = isize; +#[derive(Clone, Debug)] +struct WindowEntry { + pid: u32, + title: String, +} +#[repr(C)] +struct UwpPidSearch<'a> { + frame_pid: u32, + found_pid: &'a Cell, +} +static WINDOWS: OnceLock>> = OnceLock::new(); + +/// Name: windows_map +/// Purpose: Lazy global initialiser for 'WINDOWS'. +fn windows_map() -> &'static Mutex> { + WINDOWS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Name: on_win_event +/// Purpose: Callback function registered via SetWinEventHook. +/// Events: +/// - WINDOW APPEARED: EVENT_OBJECT_CREATE, EVENT_OBJECT_SHOW, EVENT_OBJECT_UNCLOAKED +/// - WINDOW DISAPPEARED: EVENT_OBJECT_HIDE, EVENT_OBJECT_DESTROY, EVENT_OBJECT_CLOAKED +/// - WINDOW NAME CHANGED: EVENT_OBJECT_NAMECHANGE +extern "system" fn on_win_event( + _hook: HWINEVENTHOOK, + event: u32, + hwnd: HWND, + id_object: i32, + _id_child: i32, + _evt_thread: u32, + _evt_time: u32, +) { + // Debugging + //println!("[evt {event:#x}] hwnd={:?} id_object={}", hwnd, id_object); + //return; + + // Ignore non-window events + if OBJECT_IDENTIFIER(id_object) != OBJID_WINDOW { + return; + } + + // Ignore events without an associated window to inspect + if hwnd == HWND::default() { + return; + } + + // Check if a window appeared or disappeared + if matches!( + event, + EVENT_OBJECT_CREATE | EVENT_OBJECT_SHOW | EVENT_OBJECT_UNCLOAKED + ) { + // Check the new window is a user-facing main/top-level application window + if !is_candidate_window(hwnd) { + return; + } + + /* + // Require non-empty title + unsafe { + if GetWindowTextLengthW(hwnd) == 0 { + return; + } + } + */ + + // Identify the process ID + let pid: u32 = if is_application_frame_window(hwnd) { + // UWP + uwp_pid(hwnd) + } else { + // Win32 + let mut p: u32 = 0u32; + unsafe { + GetWindowThreadProcessId(hwnd, Some(&mut p)); + } + p + }; + + // Grab the current title (can empty on window creation) + let title: String = window_title(hwnd); + + // Add window + add_window(hwnd.0 as isize, pid, title); + debug_dump_windows(); + } else if matches!(event, EVENT_OBJECT_NAMECHANGE) { + // Store the new window title + let title: String = window_title(hwnd); + update_window_title(hwnd.0 as isize, title); + debug_dump_windows(); + } else if matches!( + event, + EVENT_OBJECT_HIDE | EVENT_OBJECT_DESTROY | EVENT_OBJECT_CLOAKED + ) { + // Remove window + remove_window(hwnd.0 as isize); + debug_dump_windows(); + } +} + +/// Name: add_window +/// Purpose: Add a window to the window list. +fn add_window(hwnd: isize, pid: u32, title: String) { + let mut map: MutexGuard> = windows_map().lock().unwrap(); + (&mut *map).insert(hwnd, WindowEntry { pid, title }); +} + +/// Name: update_window_title +/// Purpose: Update a window title in the window list. +fn update_window_title(hwnd: isize, new_title: String) { + let mut map: MutexGuard> = windows_map().lock().unwrap(); + if let Some(entry) = (&mut *map).get_mut(&hwnd) { + entry.title = new_title; + } +} + +/// Name: remove_window +/// Purpose: Remove a window from the window list. +fn remove_window(hwnd: isize) { + let mut map: MutexGuard> = windows_map().lock().unwrap(); + (&mut *map).remove(&hwnd); +} + +/// Name: window_title +/// Purpose: Return the title of a window given a window handle. +fn window_title(hwnd: HWND) -> String { + unsafe { + let len = GetWindowTextLengthW(hwnd); + if len == 0 { + return String::new(); + } + let mut buf = vec![0u16; len as usize + 1]; + let written = GetWindowTextW(hwnd, &mut buf); + buf.truncate(written as usize); + OsString::from_wide(&buf).to_string_lossy().into_owned() + } +} + +/// Name: debug_dump_windows +/// Purpose: Print the current contents of the window list. +pub fn debug_dump_windows() { + // Take a stable snapshot of the window list + let snapshot: Vec<(WindowKey, WindowEntry)> = { + let map: MutexGuard> = windows_map().lock().unwrap(); + map.iter().map(|(&k, v)| (k, v.clone())).collect() + }; + + // Sort entries + let mut rows: Vec<(WindowKey, WindowEntry)> = snapshot; + (&mut *rows).sort_by(|a, b| a.1.pid.cmp(&b.1.pid).then(a.0.cmp(&b.0))); + + // Timestamp + let ts: String = Local::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string(); + + // Print Table Header + println!( + "WinApps Monitor Window List - {} Entr{} - Last Refresh: {}\r\n", + rows.len(), + if rows.len() == 1 { "y" } else { "ies" }, + ts + ); + println!("{:<14} {:<8} {}", "HWND", "PID", "TITLE"); + println!("{:-<14} {:-<8} {:-<80}", "", "", ""); + + // Print rows with aligned columns + for (hwnd, entry) in rows { + println!( + "{:<14} {:<8} {}", + format!("{:#x}", hwnd), + entry.pid, + truncate(&entry.title, 80) + ); + } + + // Closing horizontal rule + let total_width = 14 + 1 + 8 + 1 + 80; // widths + spaces + println!("{:-<1$}", "", total_width); + println!(); +} + +fn main() -> Result<()> { + // Display System Tray Icon + let icon = icon_from_ico(); + let _tray_icon = TrayIconBuilder::new() + .with_tooltip("WinApps Monitor") + .with_icon(icon) + .build()?; + + // Flags: + // - Call the callback function from outside the target application's process + // - Suppress events originating from this process + let flags: u32 = WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS; + + // Install hooks for the specific events we care about + let hook_create: HWINEVENTHOOK = unsafe { + SetWinEventHook( + EVENT_OBJECT_CREATE, + EVENT_OBJECT_CREATE, + None, + Some(on_win_event), + 0, + 0, + flags, + ) + }; + let hook_show: HWINEVENTHOOK = unsafe { + SetWinEventHook( + EVENT_OBJECT_SHOW, + EVENT_OBJECT_SHOW, + None, + Some(on_win_event), + 0, + 0, + flags, + ) + }; + let hook_hide: HWINEVENTHOOK = unsafe { + SetWinEventHook( + EVENT_OBJECT_HIDE, + EVENT_OBJECT_HIDE, + None, + Some(on_win_event), + 0, + 0, + flags, + ) + }; + let hook_destroy: HWINEVENTHOOK = unsafe { + SetWinEventHook( + EVENT_OBJECT_DESTROY, + EVENT_OBJECT_DESTROY, + None, + Some(on_win_event), + 0, + 0, + flags, + ) + }; + let hook_cloaked: HWINEVENTHOOK = unsafe { + SetWinEventHook( + EVENT_OBJECT_CLOAKED, + EVENT_OBJECT_CLOAKED, + None, + Some(on_win_event), + 0, + 0, + flags, + ) + }; + let hook_uncloaked: HWINEVENTHOOK = unsafe { + SetWinEventHook( + EVENT_OBJECT_UNCLOAKED, + EVENT_OBJECT_UNCLOAKED, + None, + Some(on_win_event), + 0, + 0, + flags, + ) + }; + + // Hook verification (non-null handles mean success) + println!("WINDOW HOOKS INSTALLED:"); + println!( + "- CREATE = {}", + (!hook_create.0.is_null()).to_string().to_uppercase() + ); + println!( + "- SHOW = {}", + (!hook_show.0.is_null()).to_string().to_uppercase() + ); + println!( + "- HIDE = {}", + (!hook_hide.0.is_null()).to_string().to_uppercase() + ); + println!( + "- DESTROY = {}", + (!hook_destroy.0.is_null()).to_string().to_uppercase() + ); + println!( + "- CLOAKED = {}", + (!hook_cloaked.0.is_null()).to_string().to_uppercase() + ); + println!( + "- UNCLOAKED = {}", + (!hook_uncloaked.0.is_null()).to_string().to_uppercase() + ); + println!(); + + // Seed the current state so already-open windows are present + seed_open_windows(); + + // Dump once to see the baseline state + debug_dump_windows(); + + // Keep the app alive AND pump messages so WinEvent callbacks can be delivered + unsafe { + let mut msg: MSG = MSG::default(); + // GetMessageW returns >0 until WM_QUIT; 0 on WM_QUIT; <0 on error. + while GetMessageW(&mut msg, None, 0, 0).into() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + // TODO Unreachable - Need to implement unhook on shutdown + #[allow(unreachable_code)] + unsafe { + if !hook_create.0.is_null() { + let _ = UnhookWinEvent(hook_create); + } + if !hook_show.0.is_null() { + let _ = UnhookWinEvent(hook_show); + } + if !hook_hide.0.is_null() { + let _ = UnhookWinEvent(hook_hide); + } + if !hook_destroy.0.is_null() { + let _ = UnhookWinEvent(hook_destroy); + } + if !hook_cloaked.0.is_null() { + let _ = UnhookWinEvent(hook_cloaked); + } + if !hook_uncloaked.0.is_null() { + let _ = UnhookWinEvent(hook_uncloaked); + } + } + + Ok(()) +} + +/// Name: is_application_frame_window +/// Purpose: Returns 'true' if the given window belongs to the UWP ApplicationFrameHost. This is +/// important to check, as UWP applications run under ApplicationFrameHost and need to be +/// enumerated differently. +/// Input: Window Handle (HWND) +/// Output: Boolean (True if UWP application, false if not) +fn is_application_frame_window(hwnd: HWND) -> bool { + let mut buf = [0u16; 128]; + unsafe { + let len = GetClassNameW(hwnd, &mut buf) as usize; + if len == 0 { + return false; + } + let class_name = String::from_utf16_lossy(&buf[..len]); + class_name == "ApplicationFrameWindow" + } +} + +/// Name: is_candidate_window +/// Purpose: Given a window handle, this function decides (using a few heuristics) whether the +/// window is likely to be a user-facing main/top-level application window, excluding +/// tool palettes, dialogs, popups, hidden windows, etc. +/// Input: Window Handle (HWND) +/// Output: Boolean (True if window of interest, false if not) +fn is_candidate_window(hwnd: HWND) -> bool { + unsafe { + // Exclude windows that are not visible + // Note: Minimised windows are considered visible - use IsIconic instead to exclude them + if IsWindowVisible(hwnd).as_bool() == false { + return false; + } + + // Exclude tool windows (palette/floaters) + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32; + if (ex & WS_EX_TOOLWINDOW.0) != 0 { + return false; + } + + // Detect common UWP host frame class + let is_uwp_frame = is_application_frame_window(hwnd); + + // Prefer top-level (no owner) to exclude popups/dialogs attached to a parent window, BUT: + // - Allow windows that explicitly ask for taskbar presence (WS_EX_APPWINDOW) + // - Allow UWP frames even if they have an owner + let root_owner: HWND = GetAncestor(hwnd, GA_ROOTOWNER); + let has_appwindow: bool = (ex & WS_EX_APPWINDOW.0) != 0; + if root_owner != hwnd && !has_appwindow && !is_uwp_frame { + return false; + } + + // Skip “hidden by owner” popups (Raymond Chen heuristic), + // but keep UWP frames which can have quirky popup chains. + let last_active_popup: HWND = GetLastActivePopup(hwnd); + if !is_uwp_frame + && last_active_popup != hwnd + && !IsWindowVisible(last_active_popup).as_bool() + { + return false; + } + + // Skip cloaked windows + let mut cloaked: u32 = 0; + let _ = DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + &mut cloaked as *mut _ as *mut _, + size_of::() as u32, + ); + if cloaked != 0 { + return false; + } + } + true +} + +/// Name: seed_open_windows +/// Purpose: Enumerate current top-level windows and add them to WINDOWS. +fn seed_open_windows() { + unsafe { + // Write straight into the global map + let _ = EnumWindows(Some(enum_windows_seed_proc), LPARAM(0)); + } +} + +/// Name: enum_windows_seed_proc +/// Purpose: Enumerate current top-level windows and add them to WINDOWS. +extern "system" fn enum_windows_seed_proc(hwnd: HWND, _lparam: LPARAM) -> BOOL { + // Apply the same filters used in the hook callback + if !is_candidate_window(hwnd) { + return BOOL(1); + } + + // Store title + let title = window_title(hwnd); + + // Resolve true PID (handles ApplicationFrameWindow/UWP) + let pid = if is_application_frame_window(hwnd) { + uwp_pid(hwnd) + } else { + let mut p = 0u32; + unsafe { + GetWindowThreadProcessId(hwnd, Some(&mut p)); + } + p + }; + if pid == 0 { + return BOOL(1); + } + + // Insert into the map keyed by hWnd + add_window(hwnd.0 as isize, pid, title); + + BOOL(1) // Continue enumeration +} + +/// Name: enum_child_find_different_pid +/// Purpose: Discover the real/true process ID behind a 'modern' UWP window. +extern "system" fn enum_child_find_different_pid(hwnd: HWND, lparam: LPARAM) -> BOOL { + unsafe { + let ctx: &mut UwpPidSearch = &mut *(lparam.0 as *mut UwpPidSearch); + let mut child_pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut child_pid)); + + if child_pid != 0 && child_pid != ctx.frame_pid { + ctx.found_pid.set(child_pid); + return BOOL(0); // Stop enumeration + } + } + BOOL(1) // Continue +} + +/// Name: uwp_pid +/// Rationale: The top-level frame window for 'modern' UWP applications is owned by the host process +/// 'ApplicationFrameWindow'. Additional logic is required to identify the 'true' process +/// ID since the application content lives within a child window belonging to a different +/// process. +fn uwp_pid(hwnd: HWND) -> u32 { + unsafe { + let mut frame_pid: u32 = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut frame_pid)); + let found: Cell = Cell::new(0u32); + let mut ctx = UwpPidSearch { + frame_pid, + found_pid: &found, + }; + + let _ = EnumChildWindows( + Some(hwnd), + Some(enum_child_find_different_pid), + LPARAM(&mut ctx as *mut _ as isize), + ); + + let pid: u32 = found.get(); + if pid != 0 { pid } else { frame_pid } + } +} + +/// Name: truncate +/// Purpose: For tidy console output. +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut t = s.chars().take(max.saturating_sub(1)).collect::(); + t.push('…'); + t + } +} + +/// Name: icon_from_ico +/// Purpose: Embed and prepare the system tray icon for use. +fn icon_from_ico() -> Icon { + // Embed ICO at compile time + let bytes = include_bytes!("../assets/icons/system_tray_icon.ico"); + + // Process .ico file -> Pick the highest resolution -> Convert to RGBA + let mut cursor = std::io::Cursor::new(&bytes[..]); + let dir = ico::IconDir::read(&mut cursor).expect("Invalid .ico"); + let best = dir + .entries() + .iter() + .max_by_key(|e| (e.width(), e.height(), e.bits_per_pixel())) + .expect("No entries in .ico"); + let image = best.decode().expect("Failed to decode .ico image"); + let width = image.width(); + let height = image.height(); + let rgba = image.rgba_data().to_vec(); // RGBA8 + + // Return the RGBA Icon + Icon::from_rgba(rgba, width, height).expect("tray_icon::Icon::from_rgba failed") +} diff --git a/winapps-monitor/src/svg2ico.py b/winapps-monitor/src/svg2ico.py new file mode 100644 index 00000000..6663d1bd --- /dev/null +++ b/winapps-monitor/src/svg2ico.py @@ -0,0 +1,20 @@ +# conda install pillow +# conda install -c conda-forge cairosvg +from PIL import Image +import io +import cairosvg + +# File Paths +svg_path = "../assets/icons/system_tray_icon.svg" +ico_path = "../assets/icons/system_tray_icon.ico" + +# Convert SVG file to PNG bytes +with open(svg_path, "rb") as f: + svg_data = f.read() +png_bytes = cairosvg.svg2png(bytestring=svg_data, output_width=256, output_height=256) + +# Open PNG bytes as Pillow image +image = Image.open(io.BytesIO(png_bytes)) + +# Save as ICO with multiple sizes +image.save(ico_path, format="ICO", sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)]) diff --git a/winapps/src/freerdp.rs b/winapps/src/freerdp.rs index b88936aa..cc51e1d2 100644 --- a/winapps/src/freerdp.rs +++ b/winapps/src/freerdp.rs @@ -2,7 +2,7 @@ pub mod freerdp_back { use std::process::{Command, Stdio}; use tracing::{info, warn}; - use crate::{unwrap_or_exit, Config, RemoteClient}; + use crate::{Config, RemoteClient, unwrap_or_exit}; pub struct Freerdp {} diff --git a/winapps/src/quickemu.rs b/winapps/src/quickemu.rs index d2cb54cd..aed7175e 100644 --- a/winapps/src/quickemu.rs +++ b/winapps/src/quickemu.rs @@ -1,4 +1,4 @@ -use crate::{get_data_dir, save_config, unwrap_or_exit, Config}; +use crate::{Config, get_data_dir, save_config, unwrap_or_exit}; use std::fs; use std::process::Command; use tracing::info;