use std::collections::{BTreeMap, HashSet};
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use windows::Win32::Foundation::{CloseHandle, HANDLE, HWND, LPARAM, RECT, TRUE};
use windows::Win32::Graphics::Dwm::{DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS, DwmGetWindowAttribute};
use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, IPersistFile, STGM_READ};
use windows::Win32::System::Threading::{
GetProcessId, OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
use windows::Win32::UI::Shell::{
IShellLinkW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW, ShellLink,
};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GW_OWNER, GWL_EXSTYLE, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect,
GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible, SW_SHOWNOACTIVATE,
WS_EX_TOOLWINDOW,
};
use windows::core::{BOOL, HSTRING, PWSTR, w};
use super::WinResultExt as _;
use crate::app::AppInfo;
use crate::error::{Error, Result};
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) fn list_apps() -> Vec<AppInfo> {
let mut apps = running_apps();
apps.extend(installed_apps());
apps
}
fn running_apps() -> Vec<AppInfo> {
let foreground = foreground_window().map(window_pid);
let mut by_pid: BTreeMap<u32, AppInfo> = BTreeMap::new();
for hwnd in top_level_windows() {
if !is_app_window(hwnd) {
continue;
}
let pid = window_pid(hwnd);
if pid == 0 || by_pid.contains_key(&pid) {
continue;
}
let path = process_path(pid);
let stem = path.as_deref().and_then(Path::file_stem).map(|s| s.to_string_lossy().into_owned());
let name = stem.unwrap_or_else(|| window_title(hwnd));
let mut app = AppInfo::new(name)
.with_pid(pid)
.with_active(foreground == Some(pid))
.with_window_title(main_window(pid).map(window_title).unwrap_or_default());
if let Some(path) = path {
if let Some(file) = path.file_name() {
app = app.with_bundle_id(file.to_string_lossy());
}
app = app.with_path(path);
}
by_pid.insert(pid, app);
}
by_pid.into_values().collect()
}
fn installed_apps() -> Vec<AppInfo> {
let mut shortcuts = Vec::new();
for var in ["ProgramData", "APPDATA"] {
if let Some(base) = std::env::var_os(var) {
let dir = PathBuf::from(base).join(r"Microsoft\Windows\Start Menu\Programs");
collect_shortcuts(&dir, 0, &mut shortcuts);
}
}
let mut seen = HashSet::new();
shortcuts
.into_iter()
.filter_map(|path| {
let name = path.file_stem()?.to_string_lossy().into_owned();
let key = name.to_lowercase();
(!key.starts_with("uninstall") && seen.insert(key)).then(|| AppInfo::new(name).with_path(path))
})
.collect()
}
fn collect_shortcuts(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
const MAX_DEPTH: usize = 4;
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() && depth < MAX_DEPTH {
collect_shortcuts(&path, depth + 1, out);
} else if kind.is_file() && path.extension().is_some_and(|e| e.eq_ignore_ascii_case("lnk")) {
out.push(path);
}
}
}
pub(super) fn launch(app: &AppInfo) -> Result<AppInfo> {
let target: OsString = app
.path
.as_ref()
.map(|p| p.as_os_str().to_owned())
.or_else(|| app.bundle_id.as_ref().map(OsString::from))
.unwrap_or_else(|| OsString::from(&app.name));
let expected = expected_executable(app);
let before: HashSet<u32> = running_apps().iter().filter_map(|a| a.pid).collect();
let launched = shell_execute(&HSTRING::from(target.as_os_str()))?;
let deadline = Instant::now() + LAUNCH_TIMEOUT;
loop {
let running = running_apps();
let found = running
.iter()
.find(|a| launched.is_some() && a.pid == launched)
.or_else(|| {
let exe = expected.as_deref()?;
running.iter().find(|a| a.bundle_id.as_deref().is_some_and(|b| b.eq_ignore_ascii_case(exe)))
})
.or_else(|| {
running
.iter()
.find(|a| a.pid.is_some_and(|pid| !before.contains(&pid)) && a.match_score(&app.name).is_some())
});
if let Some(found) = found {
return Ok(found.clone());
}
if Instant::now() >= deadline {
return Err(Error::action(format!("{} did not open a window within {LAUNCH_TIMEOUT:?}", app.name)));
}
std::thread::sleep(Duration::from_millis(200));
}
}
fn expected_executable(app: &AppInfo) -> Option<String> {
let from_path = app.path.as_deref().and_then(|path| {
let is_link = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("lnk"));
let exe = if is_link { shortcut_target(path)? } else { path.to_path_buf() };
Some(exe.file_name()?.to_string_lossy().into_owned())
});
from_path.or_else(|| app.bundle_id.clone()).filter(|name| name.to_ascii_lowercase().ends_with(".exe"))
}
fn shell_execute(file: &HSTRING) -> Result<Option<u32>> {
let mut info = SHELLEXECUTEINFOW {
cbSize: size_u32::<SHELLEXECUTEINFOW>(),
fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI,
lpVerb: w!("open"),
lpFile: windows::core::PCWSTR(file.as_ptr()),
nShow: SW_SHOWNOACTIVATE.0,
..Default::default()
};
unsafe { ShellExecuteExW(&raw mut info) }.or_action("ShellExecuteExW failed")?;
if info.hProcess.is_invalid() {
return Ok(None);
}
let process = OwnedHandle(info.hProcess);
let pid = unsafe { GetProcessId(process.0) };
Ok((pid != 0).then_some(pid))
}
fn shortcut_target(link: &Path) -> Option<PathBuf> {
let shell_link: IShellLinkW = unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }.ok()?;
let file: IPersistFile = windows::core::Interface::cast(&shell_link).ok()?;
com!(file.Load(&HSTRING::from(link.as_os_str()), STGM_READ)).ok()?;
let mut buffer = [0u16; 1024];
com!(shell_link.GetPath(&mut buffer, std::ptr::null_mut(), 0)).ok()?;
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
(len > 0).then(|| PathBuf::from(OsString::from_wide(&buffer[..len])))
}
pub(super) fn main_window(pid: u32) -> Option<HWND> {
if let Some(fg) = foreground_window()
&& window_pid(fg) == pid
&& is_visible_window(fg)
{
return Some(fg);
}
let candidates: Vec<HWND> =
top_level_windows().into_iter().filter(|&h| window_pid(h) == pid && is_visible_window(h)).collect();
let area = |h: &HWND| window_frame(*h).map_or(0, |r| i64::from(r.right - r.left) * i64::from(r.bottom - r.top));
candidates
.iter()
.copied()
.filter(|&h| !has_owner(h))
.max_by_key(area)
.or_else(|| candidates.iter().copied().max_by_key(area))
}
pub(super) fn app_windows(pid: u32) -> Vec<HWND> {
top_level_windows().into_iter().filter(|&h| window_pid(h) == pid && is_visible_window(h)).collect()
}
pub(super) fn window_frame(hwnd: HWND) -> Option<RECT> {
let mut rect = RECT::default();
let dwm =
unsafe { DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, (&raw mut rect).cast(), size_u32::<RECT>()) };
if dwm.is_err() || is_empty(&rect) {
rect = window_rect(hwnd)?;
}
(!is_empty(&rect)).then_some(rect)
}
pub(super) fn window_rect(hwnd: HWND) -> Option<RECT> {
let mut rect = RECT::default();
unsafe { GetWindowRect(hwnd, &raw mut rect) }.ok()?;
Some(rect)
}
pub(super) fn window_title(hwnd: HWND) -> String {
let len = unsafe { GetWindowTextLengthW(hwnd) };
let Ok(len) = usize::try_from(len) else {
return String::new();
};
if len == 0 {
return String::new();
}
let mut buffer = vec![0u16; len + 1];
let copied = unsafe { GetWindowTextW(hwnd, &mut buffer) };
let copied = usize::try_from(copied).unwrap_or(0).min(buffer.len());
String::from_utf16_lossy(&buffer[..copied])
}
pub(super) fn window_pid(hwnd: HWND) -> u32 {
let mut pid = 0u32;
unsafe { GetWindowThreadProcessId(hwnd, Some(&raw mut pid)) };
pid
}
pub(super) fn foreground_window() -> Option<HWND> {
let hwnd = unsafe { GetForegroundWindow() };
(!hwnd.is_invalid()).then_some(hwnd)
}
pub(super) fn top_level_windows() -> Vec<HWND> {
let mut windows: Vec<HWND> = Vec::new();
let lparam = LPARAM(std::ptr::from_mut(&mut windows).expose_provenance().cast_signed());
let _ = unsafe { EnumWindows(Some(collect_window), lparam) };
windows
}
unsafe extern "system" fn collect_window(hwnd: HWND, lparam: LPARAM) -> BOOL {
let windows = std::ptr::with_exposed_provenance_mut::<Vec<HWND>>(lparam.0.cast_unsigned());
unsafe { (*windows).push(hwnd) };
TRUE
}
fn is_visible_window(hwnd: HWND) -> bool {
if !unsafe { IsWindowVisible(hwnd) }.as_bool() || is_cloaked(hwnd) {
return false;
}
let ex_style = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) };
if isize::try_from(WS_EX_TOOLWINDOW.0).is_ok_and(|flag| ex_style & flag != 0) {
return false;
}
window_frame(hwnd).is_some()
}
fn is_app_window(hwnd: HWND) -> bool {
is_visible_window(hwnd) && !has_owner(hwnd) && !window_title(hwnd).is_empty()
}
fn has_owner(hwnd: HWND) -> bool {
unsafe { GetWindow(hwnd, GW_OWNER) }.is_ok_and(|owner| !owner.is_invalid())
}
fn is_cloaked(hwnd: HWND) -> bool {
let mut cloaked = 0u32;
let result = unsafe { DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, (&raw mut cloaked).cast(), size_u32::<u32>()) };
result.is_ok() && cloaked != 0
}
fn process_path(pid: u32) -> Option<PathBuf> {
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?;
let handle = OwnedHandle(handle);
let mut buffer = vec![0u16; 4096];
let mut size = u32::try_from(buffer.len()).ok()?;
unsafe { QueryFullProcessImageNameW(handle.0, PROCESS_NAME_WIN32, PWSTR(buffer.as_mut_ptr()), &raw mut size) }
.ok()?;
let len = usize::try_from(size).ok()?.min(buffer.len());
Some(PathBuf::from(OsString::from_wide(&buffer[..len])))
}
struct OwnedHandle(HANDLE);
impl Drop for OwnedHandle {
fn drop(&mut self) {
let _ = unsafe { CloseHandle(self.0) };
}
}
const fn is_empty(rect: &RECT) -> bool {
rect.right <= rect.left || rect.bottom <= rect.top
}
pub(super) fn size_u32<T>() -> u32 {
u32::try_from(size_of::<T>()).unwrap_or(u32::MAX)
}