diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 328024f..383431d 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -258,7 +258,14 @@ async fn main() { } else { display::info_line("✅", "config:", "saved"); } - c + // Self-restart after first-time setup so all config takes effect cleanly + println!(); + display::info_line("🔄", "restart:", "Config saved. Restarting..."); + release_instance_lock(); + let exe = std::env::current_exe().expect("Failed to get current exe path"); + let args: Vec = std::env::args().skip(1).collect(); + let _ = std::process::Command::new(exe).args(&args).spawn(); + std::process::exit(0); } }; diff --git a/crates/client/src/windows_mgmt.rs b/crates/client/src/windows_mgmt.rs index 2a67373..810ebad 100644 --- a/crates/client/src/windows_mgmt.rs +++ b/crates/client/src/windows_mgmt.rs @@ -1,4 +1,5 @@ use helios_common::protocol::{sanitize_label, WindowInfo}; +use std::collections::HashMap; // ── Windows implementation ────────────────────────────────────────────────── @@ -7,12 +8,38 @@ mod win_impl { use super::*; use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; use windows::Win32::UI::WindowsAndMessaging::{ - BringWindowToTop, EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow, - ShowWindow, SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, + BringWindowToTop, EnumWindows, GetWindowLong, GetWindowTextW, IsIconic, + IsWindowVisible, SetForegroundWindow, ShowWindow, GWL_EXSTYLE, + SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, WS_EX_TOOLWINDOW, }; use windows::Win32::UI::Input::KeyboardAndMouse::{ keybd_event, KEYEVENTF_KEYUP, VK_MENU, }; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + use windows::Win32::System::ProcessStatus::GetModuleBaseNameW; + + /// Process names that are always excluded (ghost/system windows). + const GHOST_PROCESSES: &[&str] = &[ + "textinputhost", + "shellexperiencehost", + "searchhost", + "startmenuexperiencehost", + "lockapp", + "systemsettings", + "widgets", + ]; + + /// Process names excluded unless the window has a "real" (non-empty) title. + const GHOST_UNLESS_TITLED: &[&str] = &[ + "applicationframehost", + ]; + + /// Window titles that are always excluded. + const GHOST_TITLES: &[&str] = &[ + "program manager", + ]; unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL { let list = &mut *(lparam.0 as *mut Vec); @@ -37,25 +64,110 @@ mod win_impl { String::from_utf16_lossy(&buf[..len as usize]) } - /// Generate a human-readable label from a window title. - /// E.g. "Google Chrome" -> "google_chrome", "Discord" -> "discord" - fn window_label(title: &str) -> String { - sanitize_label(title) + /// Get the process name (exe without extension) for a window handle. + fn hwnd_process_name(hwnd: HWND) -> Option { + unsafe { + let mut pid: u32 = 0; + windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid == 0 { + return None; + } + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut buf = [0u16; 260]; + let len = GetModuleBaseNameW(handle, None, &mut buf); + let _ = windows::Win32::Foundation::CloseHandle(handle); + if len == 0 { + return None; + } + let name = String::from_utf16_lossy(&buf[..len as usize]); + // Strip .exe extension + Some(name.strip_suffix(".exe").or(name.strip_suffix(".EXE")).unwrap_or(&name).to_string()) + } + } + + /// Check if a window is a ghost/invisible window that should be filtered out. + fn is_ghost_window(hwnd: HWND, title: &str, process_name: &str) -> bool { + let title_lower = title.to_lowercase(); + let proc_lower = process_name.to_lowercase(); + + // Exclude by title + if GHOST_TITLES.iter().any(|&t| title_lower == t) { + return true; + } + + // Exclude always-ghost processes + if GHOST_PROCESSES.iter().any(|&p| proc_lower == p) { + return true; + } + + // Exclude ghost-unless-titled processes (title == process name means no real title) + if GHOST_UNLESS_TITLED.iter().any(|&p| proc_lower == p) { + // If the title is just the process name or very generic, filter it + if title_lower == proc_lower || title.trim().is_empty() { + return true; + } + } + + // Exclude tool windows (WS_EX_TOOLWINDOW) + let ex_style = unsafe { GetWindowLong(hwnd, GWL_EXSTYLE) } as u32; + if ex_style & WS_EX_TOOLWINDOW.0 != 0 { + return true; + } + + false } pub fn list_windows() -> Result, String> { let hwnds = get_all_hwnds(); + + // First pass: collect valid windows with their process names + let mut raw_windows: Vec<(HWND, String, String)> = Vec::new(); // (hwnd, title, process_name) + for hwnd in &hwnds { + let visible = unsafe { IsWindowVisible(*hwnd).as_bool() }; + if !visible { + continue; + } + let title = hwnd_title(*hwnd); + if title.is_empty() { + continue; + } + // Skip minimized-to-nothing windows (iconic) + let iconic = unsafe { IsIconic(*hwnd).as_bool() }; + if iconic { + continue; + } + let process_name = hwnd_process_name(*hwnd).unwrap_or_default(); + if process_name.is_empty() { + continue; + } + if is_ghost_window(*hwnd, &title, &process_name) { + continue; + } + raw_windows.push((*hwnd, title, process_name)); + } + + // Second pass: generate labels from process names with dedup numbering + let mut label_counts: HashMap = HashMap::new(); + // First count how many of each process name we have + for (_, _, proc_name) in &raw_windows { + let base = sanitize_label(proc_name); + *label_counts.entry(base).or_insert(0) += 1; + } + + let mut label_index: HashMap = HashMap::new(); let mut windows = Vec::new(); - for hwnd in hwnds { - let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; - let title = hwnd_title(hwnd); - if !visible || title.is_empty() { - continue; - } - let label = window_label(&title); - if label.is_empty() { + for (hwnd, title, proc_name) in raw_windows { + let base = sanitize_label(&proc_name); + if base.is_empty() { continue; } + let idx = label_index.entry(base.clone()).or_insert(0); + *idx += 1; + let label = if *idx == 1 { + base.clone() + } else { + format!("{}{}", base, idx) + }; windows.push(WindowInfo { id: hwnd.0 as u64, title,