From 531c252a3c7c6f064dd96c85e20313f3548f9dcf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:36:57 +0000 Subject: [PATCH 1/2] feat: add the File New Task dialog Replace the system Run dialog with a Task Manager dialog that takes a command line and an optional administrative-privileges checkbox. The launch uses ShellExecuteEx off the UI thread, with runas for elevation, and reports failure immediately without claiming the task is still alive. Co-authored-by: jameslincn --- localization/de.toml | 13 +- localization/en_us.toml | 13 +- localization/es.toml | 13 +- localization/fr.toml | 13 +- localization/pt.toml | 13 +- localization/ru.toml | 13 +- localization/zh_cn.toml | 13 +- localization/zh_tw.toml | 13 +- src/app/mod.rs | 27 +- src/app/new_task.rs | 682 ++++++++++++++++++++++++++++++++ src/ui/dialogs.rs | 97 ++++- src/ui/localization/text_key.rs | 7 + src/ui/localization/ui.rs | 8 + src/ui/menus.rs | 16 + src/ui/resource_ids.rs | 6 + 15 files changed, 901 insertions(+), 46 deletions(-) create mode 100644 src/app/new_task.rs diff --git a/localization/de.toml b/localization/de.toml index 7aab88e..a1aa4ab 100644 --- a/localization/de.toml +++ b/localization/de.toml @@ -132,8 +132,15 @@ Windows = "&Fenster" Help = "&Hilfe" UpdateSpeed = "&Aktualisierungsgeschwindigkeit" CpuHistory = "&CPU-Verlauf" -NewTaskMenu = "&Ausführen..." -NewTaskButton = "&Ausführen..." +NewTaskMenu = "Neue &Aufgabe..." +NewTaskButton = "Neue &Aufgabe..." +NewTaskTitle = "Neue Aufgabe erstellen" +NewTaskAdmin = "Diese Aufgabe mit Administratorrechten erstellen" +NewTaskBrowse = "&Durchsuchen..." +NewTaskProgramFilter = "Programme" +NewTaskAllFilesFilter = "Alle Dateien" +NewTaskFailed = "Diese Aufgabe konnte nicht erstellt werden." +NewTaskEmpty = "Geben Sie den Namen eines Programms, Ordners, Dokuments oder einer Internetressource ein." ExitTaskManager = "Task-Manager be&enden" AlwaysOnTop = "&Immer im Vordergrund" MinimizeOnUse = "Nach Verwendung &minimieren" @@ -322,7 +329,7 @@ ServiceStateUnchanged = "Der Dienst hat den angeforderten Zustand nicht erreicht ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "Öffnet das Dialogfeld „Neue Aufgabe“, um ein Programm auszuführen" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/en_us.toml b/localization/en_us.toml index 5755c36..b2cb880 100644 --- a/localization/en_us.toml +++ b/localization/en_us.toml @@ -139,8 +139,15 @@ Windows = "&Windows" Help = "&Help" UpdateSpeed = "&Update Speed" CpuHistory = "&CPU History" -NewTaskMenu = "&Run..." -NewTaskButton = "&Run..." +NewTaskMenu = "New &Task..." +NewTaskButton = "New &Task..." +NewTaskTitle = "Create New Task" +NewTaskAdmin = "Create this task with administrative privileges" +NewTaskBrowse = "&Browse..." +NewTaskProgramFilter = "Programs" +NewTaskAllFilesFilter = "All Files" +NewTaskFailed = "Unable to create this task." +NewTaskEmpty = "Type the name of a program, folder, document, or Internet resource." ExitTaskManager = "E&xit Task Manager" AlwaysOnTop = "&Always On Top" MinimizeOnUse = "&Minimize On Use" @@ -329,7 +336,7 @@ ServiceStateUnchanged = "The service did not enter the requested state." ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "Opens the New Task dialog to run a program" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/es.toml b/localization/es.toml index aece701..aeea51a 100644 --- a/localization/es.toml +++ b/localization/es.toml @@ -132,8 +132,15 @@ Windows = "&Ventanas" Help = "Ay&uda" UpdateSpeed = "&Velocidad de actualización" CpuHistory = "&Historial de CPU" -NewTaskMenu = "&Ejecutar..." -NewTaskButton = "&Ejecutar..." +NewTaskMenu = "Nueva &tarea..." +NewTaskButton = "Nueva &tarea..." +NewTaskTitle = "Crear nueva tarea" +NewTaskAdmin = "Crear esta tarea con privilegios administrativos" +NewTaskBrowse = "&Examinar..." +NewTaskProgramFilter = "Programas" +NewTaskAllFilesFilter = "Todos los archivos" +NewTaskFailed = "No se puede crear esta tarea." +NewTaskEmpty = "Escriba el nombre de un programa, carpeta, documento o recurso de Internet." ExitTaskManager = "&Salir del Administrador de tareas" AlwaysOnTop = "&Siempre visible" MinimizeOnUse = "&Minimizar al usar" @@ -322,7 +329,7 @@ ServiceStateUnchanged = "El servicio no entró en el estado solicitado." ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "Abre el cuadro de diálogo Nueva tarea para ejecutar un programa" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/fr.toml b/localization/fr.toml index a4077f1..f5634f5 100644 --- a/localization/fr.toml +++ b/localization/fr.toml @@ -132,8 +132,15 @@ Windows = "&Fenêtres" Help = "&Aide" UpdateSpeed = "&Vitesse de mise à jour" CpuHistory = "&Historique CPU" -NewTaskMenu = "&Exécuter..." -NewTaskButton = "&Exécuter..." +NewTaskMenu = "Nouvelle &tâche..." +NewTaskButton = "Nouvelle &tâche..." +NewTaskTitle = "Créer une nouvelle tâche" +NewTaskAdmin = "Créer cette tâche avec des privilèges d'administration" +NewTaskBrowse = "&Parcourir..." +NewTaskProgramFilter = "Programmes" +NewTaskAllFilesFilter = "Tous les fichiers" +NewTaskFailed = "Impossible de créer cette tâche." +NewTaskEmpty = "Tapez le nom d’un programme, d’un dossier, d’un document ou d’une ressource Internet." ExitTaskManager = "&Quitter le Gestionnaire des tâches" AlwaysOnTop = "&Toujours visible" MinimizeOnUse = "&Réduire après utilisation" @@ -322,7 +329,7 @@ ServiceStateUnchanged = "Le service n'a pas atteint l'état demandé." ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "Ouvre la boîte de dialogue Nouvelle tâche pour exécuter un programme" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/pt.toml b/localization/pt.toml index 2ca1980..7574ab2 100644 --- a/localization/pt.toml +++ b/localization/pt.toml @@ -132,8 +132,15 @@ Windows = "&Janelas" Help = "A&juda" UpdateSpeed = "&Velocidade de atualização" CpuHistory = "&Histórico da CPU" -NewTaskMenu = "&Executar..." -NewTaskButton = "&Executar..." +NewTaskMenu = "Nova &tarefa..." +NewTaskButton = "Nova &tarefa..." +NewTaskTitle = "Criar nova tarefa" +NewTaskAdmin = "Criar esta tarefa com privilégios administrativos" +NewTaskBrowse = "&Procurar..." +NewTaskProgramFilter = "Programas" +NewTaskAllFilesFilter = "Todos os arquivos" +NewTaskFailed = "Não foi possível criar esta tarefa." +NewTaskEmpty = "Digite o nome de um programa, pasta, documento ou recurso da Internet." ExitTaskManager = "Sai&r do Gerenciador de Tarefas" AlwaysOnTop = "&Sempre visível" MinimizeOnUse = "&Minimizar ao usar" @@ -322,7 +329,7 @@ ServiceStateUnchanged = "O serviço não entrou no estado solicitado." ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "Abre a caixa de diálogo Nova tarefa para executar um programa" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/ru.toml b/localization/ru.toml index ab7be1d..552281b 100644 --- a/localization/ru.toml +++ b/localization/ru.toml @@ -132,8 +132,15 @@ Windows = "&Окна" Help = "&Справка" UpdateSpeed = "&Скорость обновления" CpuHistory = "&Журнал CPU" -NewTaskMenu = "&Выполнить..." -NewTaskButton = "&Выполнить..." +NewTaskMenu = "Новая &задача..." +NewTaskButton = "Новая &задача..." +NewTaskTitle = "Создать задачу" +NewTaskAdmin = "Создать эту задачу с правами администратора" +NewTaskBrowse = "&Обзор..." +NewTaskProgramFilter = "Программы" +NewTaskAllFilesFilter = "Все файлы" +NewTaskFailed = "Не удалось создать задачу." +NewTaskEmpty = "Введите имя программы, папки, документа или интернет-ресурса." ExitTaskManager = "&Выход из диспетчера задач" AlwaysOnTop = "&Поверх всех окон" MinimizeOnUse = "&Сворачивать после использования" @@ -322,7 +329,7 @@ ServiceStateUnchanged = "Служба не перешла в запрошенн ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "Открывает диалог «Новая задача» для запуска программы" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/zh_cn.toml b/localization/zh_cn.toml index bfdb7c4..c2c547f 100644 --- a/localization/zh_cn.toml +++ b/localization/zh_cn.toml @@ -132,8 +132,15 @@ Windows = "窗口(&W)" Help = "帮助(&H)" UpdateSpeed = "更新速度(&U)" CpuHistory = "CPU 历史记录(&C)" -NewTaskMenu = "运行(&R)..." -NewTaskButton = "运行(&R)..." +NewTaskMenu = "新建任务(&T)..." +NewTaskButton = "新建任务(&T)..." +NewTaskTitle = "创建新任务" +NewTaskAdmin = "以管理权限创建此任务" +NewTaskBrowse = "浏览(&B)..." +NewTaskProgramFilter = "程序" +NewTaskAllFilesFilter = "所有文件" +NewTaskFailed = "无法创建此任务。" +NewTaskEmpty = "请输入程序、文件夹、文档或 Internet 资源的名称。" ExitTaskManager = "退出任务管理器(&X)" AlwaysOnTop = "总在最前(&A)" MinimizeOnUse = "使用后最小化(&M)" @@ -319,7 +326,7 @@ ServiceStateUnchanged = "该服务没有进入请求的状态。" ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "打开“新建任务”对话框以运行程序" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/localization/zh_tw.toml b/localization/zh_tw.toml index f6a0d26..96e4111 100644 --- a/localization/zh_tw.toml +++ b/localization/zh_tw.toml @@ -132,8 +132,15 @@ Windows = "視窗(&W)" Help = "說明(&H)" UpdateSpeed = "更新速度(&U)" CpuHistory = "CPU 歷程記錄(&C)" -NewTaskMenu = "執行(&R)..." -NewTaskButton = "執行(&R)..." +NewTaskMenu = "新增工作(&T)..." +NewTaskButton = "新增工作(&T)..." +NewTaskTitle = "建立新工作" +NewTaskAdmin = "以系統管理權限建立此工作" +NewTaskBrowse = "瀏覽(&B)..." +NewTaskProgramFilter = "程式" +NewTaskAllFilesFilter = "所有檔案" +NewTaskFailed = "無法建立此工作。" +NewTaskEmpty = "請輸入程式、資料夾、文件或 Internet 資源的名稱。" ExitTaskManager = "結束工作管理員(&X)" AlwaysOnTop = "永遠在最上層(&A)" MinimizeOnUse = "使用後最小化(&M)" @@ -319,7 +326,7 @@ ServiceStateUnchanged = "服務沒有進入要求的狀態。" ServiceProcessPid = "PID" [menu_status_help] -IDM_RUN = "Runs a new program" +IDM_RUN = "開啟「新增工作」對話方塊以執行程式" IDM_EXIT = "Exits the Task Manager application" IDM_ALWAYSONTOP = "Task Manager remains in front of all other windows unless minimized" IDM_MINIMIZEONUSE = "Task Manager is minimized when a SwitchTo operation is performed" diff --git a/src/app/mod.rs b/src/app/mod.rs index cf42102..a0a8ae2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod controllers; mod diagnostics_dialog; +mod new_task; pub(crate) mod page_host; pub(crate) mod page_registry; mod single_instance; @@ -23,10 +24,7 @@ use std::mem::{size_of, zeroed}; use std::ptr::{NonNull, null, null_mut}; use std::sync::OnceLock; -use windows::Win32::System::Com::{ - CLSCTX_ALL, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, -}; -use windows::Win32::UI::Shell::{IShellDispatch, Shell}; +use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize}; use windows_sys::Win32::Foundation::{ CloseHandle, ERROR_ALREADY_EXISTS, ERROR_GEN_FAILURE, ERROR_INVALID_PARAMETER, ERROR_TIMEOUT, GetLastError, HANDLE, HINSTANCE, HWND, LPARAM, POINT, RECT, SetLastError, TRUE, WAIT_TIMEOUT, @@ -1131,6 +1129,8 @@ impl App { } sanitize_task_manager_menu(menu, self.stats.processor_count); + // New Task stays enabled on every page unless the shell Run policy removed the item. + self.enable_menu_item(menu, IDM_RUN, true); // 安全性: all calls mutate only the menu handle retrieved from this main window. unsafe { @@ -2053,14 +2053,6 @@ impl App { } } - fn show_run_dialog(&self) -> Result<(), i32> { - let _apartment = ComApartment::initialize()?; - let shell: IShellDispatch = unsafe { CoCreateInstance(&Shell, None, CLSCTX_ALL) } - .map_err(|error| error.code().0)?; - // IShellDispatch::FileRun is the documented Shell automation API for the system dialog. - unsafe { shell.FileRun() }.map_err(|error| error.code().0) - } - fn show_hresult_failure(&self, title: &str, error: i32) { let title = to_wide_null(title); let body = to_wide_null(&format!("HRESULT: 0x{:08X}", error as u32)); @@ -2308,9 +2300,14 @@ impl App { } } IDM_RUN => { - if let Err(error) = self.show_run_dialog() { - record_hresult_error("system Run dialog", error); - self.show_hresult_failure(text(TextKey::RunTitle), error); + if let Err(error) = new_task::show(self.hinstance, hwnd) { + record_win32_error("new task dialog", error); + let title = to_wide_null(text(TextKey::NewTaskTitle)); + let message = to_wide_null(&format!( + "{} ({error:#010X})", + text(TextKey::NewTaskFailed) + )); + MessageBoxW(hwnd, message.as_ptr(), title.as_ptr(), MB_OK | MB_ICONSTOP); } } IDM_HELP => { diff --git a/src/app/new_task.rs b/src/app/new_task.rs new file mode 100644 index 0000000..a0a2142 --- /dev/null +++ b/src/app/new_task.rs @@ -0,0 +1,682 @@ +// +------------------------------------------------------------------------- +// +// taskmgr-rs - 新建任务对话框 +// +// 文件: src/app/new_task.rs +// +// 日期: 2026年10月20日 +// 环境: Linux 6.12.94+ x86_64;Rust 1.99.1(58cf8f9c3,2026-10-13) +// 作者: Grok 4.7 +// -------------------------------------------------------------------------- + +//! “文件 -> 新建任务”模态对话框。 +//! +//! 对话框收集命令行,以及是否通过标准提升路径启动。`ShellExecuteExW` 在独立线程 +//! 执行,避免 UAC 同意界面卡住主消息循环。成功只表示外壳接受了请求,不表示新 +//! 任务仍然存活;失败立刻在这个对话框上报告。 + +use std::ffi::OsString; +use std::mem::{size_of, zeroed}; +use std::os::windows::ffi::OsStringExt; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr::null; +use std::sync::{Arc, Mutex}; +use std::thread; + +use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize}; +use windows_sys::Win32::Foundation::{ + ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, + GetLastError, HINSTANCE, HWND, LPARAM, SetLastError, WPARAM, +}; +use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW; +use windows_sys::Win32::UI::Controls::Dialogs::{ + CommDlgExtendedError, GetOpenFileNameW, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_HIDEREADONLY, + OFN_PATHMUSTEXIST, OPENFILENAMEW, +}; +use windows_sys::Win32::UI::Controls::{BST_CHECKED, EM_SETLIMITTEXT, IsDlgButtonChecked}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, SetFocus}; +use windows_sys::Win32::UI::Shell::{ + SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SHELLEXECUTEINFOW, ShellExecuteExW, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + EndDialog, GetDlgItem, GetWindowTextLengthW, GetWindowTextW, IDCANCEL, IDOK, + MB_ICONEXCLAMATION, MB_ICONSTOP, MB_OK, MessageBoxW, PostMessageW, SW_SHOWNORMAL, + SetDlgItemTextW, WM_CLOSE, WM_COMMAND, WM_INITDIALOG, +}; + +use crate::infrastructure::diagnostics::{self, Field, Level}; +use crate::infrastructure::native::{ + get_window_userdata, loword, record_hresult_error, record_win32_error, + record_win32_error_with_fields, set_window_userdata, to_wide_null, +}; +use crate::ui::dialogs::dialog_box; +use crate::ui::localization::{TextKey, localize_dialog, text}; +use crate::ui::resource_ids::*; + +const PROGRAM_PATTERN: &str = "*.exe;*.com;*.bat;*.cmd;*.msc"; +const ALL_FILES_PATTERN: &str = "*.*"; +const MAX_EXPANDED_COMMAND: usize = 32_768; +const COMMAND_LIMIT: usize = 32_767; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NewTaskError { + Win32(u32), + Shell(i32), + Hresult(i32), +} + +#[derive(Debug, Eq, PartialEq)] +struct ShellTarget { + file: String, + parameters: String, +} + +struct NewTaskDialog { + result: Arc>>>, + busy: bool, +} + +struct WorkerCom; + +impl WorkerCom { + fn initialize() -> Result { + // A fresh worker thread has no apartment. S_FALSE still owns one CoUninitialize. + let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + if result.is_ok() { + Ok(Self) + } else { + Err(result.0) + } + } +} + +impl Drop for WorkerCom { + fn drop(&mut self) { + unsafe { CoUninitialize() }; + } +} + +pub(super) fn show(hinstance: HINSTANCE, parent: HWND) -> Result<(), u32> { + let mut context = NewTaskDialog { + result: Arc::new(Mutex::new(None)), + busy: false, + }; + // DialogBox runs a nested loop. Drop the ambient command id so timers are not attributed + // to the menu command that opened this dialog. + diagnostics::without_operation_id(|| { + dialog_box( + hinstance, + IDD_NEWTASK, + parent, + Some(new_task_dialog_proc), + &mut context as *mut NewTaskDialog as LPARAM, + ) + })?; + Ok(()) +} + +unsafe extern "system" fn new_task_dialog_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> isize { + // Safety: the context pointer is the stack value owned by `show` for the modal call. + unsafe { + match message { + WM_INITDIALOG => { + set_window_userdata(hwnd, lparam); + localize_dialog(hwnd, IDD_NEWTASK); + let edit = GetDlgItem(hwnd, IDC_NEWTASK_COMMAND); + if !edit.is_null() { + limit_command_text(edit); + } + 1 + } + WM_CLOSE => { + if context(hwnd).busy { + 1 + } else { + 0 + } + } + WM_COMMAND => { + if context(hwnd).busy { + return 1; + } + match i32::from(loword(wparam)) { + IDCANCEL => { + EndDialog(hwnd, IDCANCEL as isize); + 1 + } + IDOK => { + begin_launch(hwnd); + 1 + } + IDC_NEWTASK_BROWSE => { + browse_for_program(hwnd); + 1 + } + _ => 0, + } + } + PWM_NEWTASK_COMPLETE => { + complete_launch(hwnd); + 1 + } + _ => 0, + } + } +} + +fn limit_command_text(edit: HWND) { + // Safety: `edit` is the live command control. EM_SETLIMITTEXT only stores a length. + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW( + edit, + EM_SETLIMITTEXT, + COMMAND_LIMIT, + 0, + ); + } +} + +fn begin_launch(hwnd: HWND) { + let command = match control_text(hwnd, IDC_NEWTASK_COMMAND) { + Ok(command) => command, + Err(error) => { + record_win32_error("new task command", error); + show_stop(hwnd, &failure_text(NewTaskError::Win32(error))); + return; + } + }; + if command.trim().is_empty() { + show_exclamation(hwnd, text(TextKey::NewTaskEmpty)); + focus_command(hwnd); + return; + } + let administrative = unsafe { IsDlgButtonChecked(hwnd, IDC_NEWTASK_ADMIN) } == BST_CHECKED; + let slot = { + let dialog = context(hwnd); + dialog.busy = true; + Arc::clone(&dialog.result) + }; + set_controls_enabled(hwnd, false); + let command = command.trim().to_string(); + let window = hwnd as isize; + let spawn = thread::Builder::new() + .name("taskmgr-rs-new-task".to_string()) + .spawn(move || { + let owner = window as HWND; + let outcome = match catch_unwind(AssertUnwindSafe(|| { + launch_new_task(&command, administrative, owner) + })) { + Ok(outcome) => outcome, + Err(_) => { + record_win32_error("new task worker panic", ERROR_GEN_FAILURE); + Err(NewTaskError::Win32(ERROR_GEN_FAILURE)) + } + }; + if let Ok(mut guard) = slot.lock() { + *guard = Some(outcome); + } + // Safety: the HWND is an opaque target. A failure means the dialog is already gone. + unsafe { + PostMessageW(window as HWND, PWM_NEWTASK_COMPLETE, 0, 0); + } + }); + if let Err(error) = spawn { + context(hwnd).busy = false; + set_controls_enabled(hwnd, true); + let code = error + .raw_os_error() + .unwrap_or(ERROR_NOT_ENOUGH_MEMORY as i32) as u32; + record_win32_error("new task worker", code); + show_stop(hwnd, &failure_text(NewTaskError::Win32(code))); + focus_command(hwnd); + } +} + +fn complete_launch(hwnd: HWND) { + let outcome = { + let dialog = context(hwnd); + dialog.busy = false; + match dialog.result.lock() { + Ok(mut slot) => slot.take(), + Err(poisoned) => poisoned.into_inner().take(), + } + }; + match outcome { + Some(Ok(())) => { + // Acceptance is not evidence that a process is still running. + unsafe { EndDialog(hwnd, IDOK as isize) }; + } + Some(Err(error)) => { + set_controls_enabled(hwnd, true); + show_stop(hwnd, &failure_text(error)); + focus_command(hwnd); + } + None => { + set_controls_enabled(hwnd, true); + show_stop(hwnd, &failure_text(NewTaskError::Win32(ERROR_GEN_FAILURE))); + focus_command(hwnd); + } + } +} + +fn launch_new_task( + command_line: &str, + administrative: bool, + owner: HWND, +) -> Result<(), NewTaskError> { + let _apartment = WorkerCom::initialize().map_err(|error| { + record_hresult_error("new task COM initialization", error); + NewTaskError::Hresult(error) + })?; + let expanded = expand_environment(command_line).map_err(|error| { + record_win32_error("new task environment expansion", error); + NewTaskError::Win32(error) + })?; + let Some(target) = split_shell_command(&expanded) else { + record_win32_error("new task command line", ERROR_INVALID_PARAMETER); + return Err(NewTaskError::Win32(ERROR_INVALID_PARAMETER)); + }; + let outcome = shell_execute(&target, administrative, owner); + match outcome { + Ok(()) => { + diagnostics::event( + Level::Info, + "new_task.launch_accepted", + "new-task", + "the shell accepted the new task; its lifetime is not tracked", + &[Field::boolean("administrative", administrative)], + ); + Ok(()) + } + Err(NewTaskError::Win32(code)) => { + record_win32_error_with_fields( + "new task launch", + code, + &[Field::boolean("administrative", administrative)], + ); + Err(NewTaskError::Win32(code)) + } + Err(NewTaskError::Shell(code)) => { + diagnostics::event( + Level::Error, + "new_task.launch_failed", + "new-task", + "ShellExecuteExW returned a documented failure code", + &[ + Field::boolean("administrative", administrative), + Field::signed("shell_execute_code", i64::from(code)), + ], + ); + Err(NewTaskError::Shell(code)) + } + Err(error) => Err(error), + } +} + +fn shell_execute( + target: &ShellTarget, + administrative: bool, + owner: HWND, +) -> Result<(), NewTaskError> { + let verb = to_wide_null(if administrative { "runas" } else { "open" }); + let file = to_wide_null(&target.file); + let parameters = to_wide_null(&target.parameters); + let mut info = unsafe { zeroed::() }; + info.cbSize = size_of::() as u32; + // NOASYNC keeps the call on this worker, which has no message loop. NO_UI suppresses the + // shell's own error dialog so this task dialog can report the failure itself. The call does + // not request a process handle and does not wait for the target to exit. `owner` is only the + // elevation UI owner. + info.fMask = SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + info.hwnd = owner; + info.lpVerb = verb.as_ptr(); + info.lpFile = file.as_ptr(); + info.lpParameters = if target.parameters.is_empty() { + null() + } else { + parameters.as_ptr() + }; + info.nShow = SW_SHOWNORMAL; + let (returned, last_error, instance) = unsafe { + SetLastError(0); + let returned = ShellExecuteExW(&mut info); + let last_error = if returned == 0 { GetLastError() } else { 0 }; + // hInstApp is a legacy status code, not an owned module handle. + (returned != 0, last_error, info.hInstApp as isize) + }; + classify_shell_execute(returned, instance, last_error) +} + +fn expand_environment(command: &str) -> Result { + let source = to_wide_null(command); + let mut capacity = 1024usize; + loop { + let mut buffer = vec![0u16; capacity]; + let written = unsafe { + ExpandEnvironmentStringsW(source.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) + }; + if written == 0 { + let error = unsafe { GetLastError() }; + return Err(if error == 0 { ERROR_GEN_FAILURE } else { error }); + } + let written = written as usize; + if written <= buffer.len() { + let end = written.saturating_sub(1); + return Ok(String::from_utf16_lossy(&buffer[..end])); + } + if written > MAX_EXPANDED_COMMAND { + return Err(ERROR_INSUFFICIENT_BUFFER); + } + capacity = written; + } +} + +fn browse_for_program(hwnd: HWND) { + match choose_program(hwnd) { + Ok(Some(path)) => { + set_control_text(hwnd, IDC_NEWTASK_COMMAND, &command_text_for_path(&path)); + focus_command(hwnd); + } + Ok(None) => {} + Err(error) => { + record_win32_error("new task browse", error); + show_stop(hwnd, &failure_text(NewTaskError::Win32(error))); + } + } +} + +fn choose_program(hwnd: HWND) -> Result, u32> { + let mut file_buffer = vec![0u16; MAX_EXPANDED_COMMAND]; + let filter = browse_filter(); + let title = to_wide_null(text(TextKey::NewTaskTitle)); + let mut dialog = unsafe { zeroed::() }; + dialog.lStructSize = size_of::() as u32; + dialog.hwndOwner = hwnd; + dialog.lpstrFilter = filter.as_ptr(); + dialog.nFilterIndex = 1; + dialog.lpstrFile = file_buffer.as_mut_ptr(); + dialog.nMaxFile = file_buffer.len() as u32; + dialog.lpstrTitle = title.as_ptr(); + dialog.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY; + + if unsafe { GetOpenFileNameW(&mut dialog) } == 0 { + let error = unsafe { CommDlgExtendedError() }; + return if error == 0 { Ok(None) } else { Err(error) }; + } + let length = file_buffer + .iter() + .position(|unit| *unit == 0) + .unwrap_or(file_buffer.len()); + if length == 0 { + return Err(ERROR_GEN_FAILURE); + } + Ok(Some( + OsString::from_wide(&file_buffer[..length]) + .to_string_lossy() + .into_owned(), + )) +} + +fn browse_filter() -> Vec { + let mut filter = Vec::new(); + append_cstr( + &mut filter, + &format!( + "{} ({PROGRAM_PATTERN})", + text(TextKey::NewTaskProgramFilter) + ), + ); + append_cstr(&mut filter, PROGRAM_PATTERN); + append_cstr( + &mut filter, + &format!( + "{} ({ALL_FILES_PATTERN})", + text(TextKey::NewTaskAllFilesFilter) + ), + ); + append_cstr(&mut filter, ALL_FILES_PATTERN); + filter.push(0); + filter +} + +fn append_cstr(buffer: &mut Vec, value: &str) { + buffer.extend(value.encode_utf16()); + buffer.push(0); +} + +fn set_controls_enabled(hwnd: HWND, enabled: bool) { + let enabled = i32::from(enabled); + unsafe { + for id in [ + IDC_NEWTASK_COMMAND, + IDC_NEWTASK_BROWSE, + IDC_NEWTASK_ADMIN, + IDOK, + IDCANCEL, + ] { + EnableWindow(GetDlgItem(hwnd, id), enabled); + } + } +} + +fn focus_command(hwnd: HWND) { + let edit = unsafe { GetDlgItem(hwnd, IDC_NEWTASK_COMMAND) }; + if !edit.is_null() { + unsafe { SetFocus(edit) }; + } +} + +fn control_text(hwnd: HWND, control_id: i32) -> Result { + let control = unsafe { GetDlgItem(hwnd, control_id) }; + if control.is_null() { + return Err(ERROR_GEN_FAILURE); + } + unsafe { SetLastError(0) }; + let length = unsafe { GetWindowTextLengthW(control) }; + if length <= 0 { + let error = unsafe { GetLastError() }; + return if error == 0 { + Ok(String::new()) + } else { + Err(error) + }; + } + let mut buffer = vec![0u16; usize::try_from(length).unwrap_or(0).saturating_add(1)]; + let copied = unsafe { GetWindowTextW(control, buffer.as_mut_ptr(), buffer.len() as i32) }; + if copied <= 0 { + let error = unsafe { GetLastError() }; + return if error == 0 { + Ok(String::new()) + } else { + Err(error) + }; + } + let copied = usize::try_from(copied).unwrap_or(0).min(buffer.len()); + Ok(String::from_utf16_lossy(&buffer[..copied])) +} + +fn set_control_text(hwnd: HWND, control_id: i32, value: &str) { + let wide = to_wide_null(value); + unsafe { SetDlgItemTextW(hwnd, control_id, wide.as_ptr()) }; +} + +fn show_stop(hwnd: HWND, body: &str) { + show_message(hwnd, body, MB_ICONSTOP); +} + +fn show_exclamation(hwnd: HWND, body: &str) { + show_message(hwnd, body, MB_ICONEXCLAMATION); +} + +fn show_message(hwnd: HWND, body: &str, icon: u32) { + let title = to_wide_null(text(TextKey::NewTaskTitle)); + let body = to_wide_null(body); + unsafe { + MessageBoxW(hwnd, body.as_ptr(), title.as_ptr(), MB_OK | icon); + } +} + +fn failure_text(error: NewTaskError) -> String { + let detail = match error { + NewTaskError::Win32(code) => format!("{code:#010X}"), + NewTaskError::Shell(code) => format!("ShellExecuteEx {code}"), + NewTaskError::Hresult(code) => format!("HRESULT {code:#010X}"), + }; + format!("{} ({detail})", text(TextKey::NewTaskFailed)) +} + +fn context<'a>(hwnd: HWND) -> &'a mut NewTaskDialog { + // Safety: WM_INITDIALOG stored the pointer from `show`, and that stack frame outlives the + // modal dialog. Callers drop the reference before EndDialog or MessageBox can re-enter. + unsafe { &mut *(get_window_userdata(hwnd) as *mut NewTaskDialog) } +} + +/// Splits a command the way `PathGetArgs` / `PathUnquoteSpaces` do. +/// +/// Spaces inside quotes stay with the program path. The returned file has one surrounding quote +/// pair removed. Parameters keep the user's quoting. +fn split_shell_command(command_line: &str) -> Option { + let trimmed = command_line.trim(); + if trimmed.is_empty() { + return None; + } + let mut in_quotes = false; + let mut split_at = None; + for (index, ch) in trimmed.char_indices() { + if ch == '"' { + in_quotes = !in_quotes; + } else if ch == ' ' && !in_quotes { + split_at = Some(index); + break; + } + } + let (file, parameters) = match split_at { + Some(index) => (&trimmed[..index], trimmed[index..].trim_start()), + None => (trimmed, ""), + }; + let file = unquote_spaces(file); + if file.is_empty() { + None + } else { + Some(ShellTarget { + file, + parameters: parameters.to_string(), + }) + } +} + +fn unquote_spaces(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + trimmed[1..trimmed.len() - 1].to_string() + } else { + trimmed.to_string() + } +} + +fn command_text_for_path(path: &str) -> String { + let trimmed = path.trim(); + let already_quoted = trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"'); + if !already_quoted && trimmed.contains([' ', '\t']) { + format!("\"{trimmed}\"") + } else { + trimmed.to_string() + } +} + +/// `Ok(())` means the shell accepted the launch. It carries no process handle or pid. +fn classify_shell_execute( + api_succeeded: bool, + instance_code: isize, + last_error: u32, +) -> Result<(), NewTaskError> { + // hInstApp values 1 through 32 are the documented shell errors. A successful BOOL with no + // such code is acceptance only: this result has no process id or handle. + let shell_error = i32::try_from(instance_code) + .ok() + .filter(|code| (1..=32).contains(code)); + if api_succeeded && shell_error.is_none() { + return Ok(()); + } + if last_error != 0 { + return Err(NewTaskError::Win32(last_error)); + } + match shell_error { + Some(code) => Err(NewTaskError::Shell(code)), + None => Err(NewTaskError::Win32(ERROR_GEN_FAILURE)), + } +} + +#[cfg(test)] +mod tests { + use super::{NewTaskError, classify_shell_execute, command_text_for_path, split_shell_command}; + + #[test] + fn command_lines_split_on_unquoted_spaces() { + assert!(split_shell_command(" ").is_none()); + assert!(split_shell_command("\"\"").is_none()); + + let simple = split_shell_command("notepad").unwrap(); + assert_eq!(simple.file, "notepad"); + assert!(simple.parameters.is_empty()); + + let args = split_shell_command(" notepad file.txt ").unwrap(); + assert_eq!(args.file, "notepad"); + assert_eq!(args.parameters, "file.txt"); + + let quoted = + split_shell_command(r#""C:\Program Files\app.exe" /foo "my file.txt""#).unwrap(); + assert_eq!(quoted.file, r"C:\Program Files\app.exe"); + assert_eq!(quoted.parameters, r#"/foo "my file.txt""#); + + let quoted_only = split_shell_command(r#""C:\Program Files\app.exe""#).unwrap(); + assert_eq!(quoted_only.file, r"C:\Program Files\app.exe"); + assert!(quoted_only.parameters.is_empty()); + } + + #[test] + fn browse_paths_quote_only_when_needed() { + assert_eq!( + command_text_for_path(r"C:\Windows\notepad.exe"), + r"C:\Windows\notepad.exe" + ); + assert_eq!( + command_text_for_path(r"C:\Program Files\app.exe"), + r#""C:\Program Files\app.exe""# + ); + assert_eq!( + command_text_for_path(r#""C:\Program Files\app.exe""#), + r#""C:\Program Files\app.exe""# + ); + } + + #[test] + fn shell_acceptance_does_not_describe_a_live_process() { + assert_eq!(classify_shell_execute(true, 42, 0), Ok(())); + assert_eq!(classify_shell_execute(true, 0, 0), Ok(())); + assert_eq!(classify_shell_execute(true, 33, 5), Ok(())); + assert_eq!( + classify_shell_execute(true, 32, 0), + Err(NewTaskError::Shell(32)) + ); + assert_eq!( + classify_shell_execute(false, 42, 1223), + Err(NewTaskError::Win32(1223)) + ); + assert_eq!( + classify_shell_execute(false, 31, 0), + Err(NewTaskError::Shell(31)) + ); + assert_eq!( + classify_shell_execute(false, 33, 0), + Err(NewTaskError::Win32( + windows_sys::Win32::Foundation::ERROR_GEN_FAILURE + )) + ); + } +} diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 397a290..d7d7cc2 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -18,9 +18,9 @@ use windows_sys::Win32::Foundation::{ use windows_sys::Win32::UI::Controls::{LVS_OWNERDATA, LVS_REPORT, LVS_SINGLESEL}; use windows_sys::Win32::UI::WindowsAndMessaging::{ BS_AUTOCHECKBOX, BS_DEFPUSHBUTTON, BS_GROUPBOX, BS_OWNERDRAW, CBS_DROPDOWNLIST, - CreateDialogIndirectParamW, DialogBoxIndirectParamW, ES_AUTOVSCROLL, ES_MULTILINE, SBS_VERT, - WS_BORDER, WS_CAPTION, WS_CHILD, WS_DISABLED, WS_POPUP, WS_SYSMENU, WS_TABSTOP, WS_THICKFRAME, - WS_VISIBLE, WS_VSCROLL, + CreateDialogIndirectParamW, DialogBoxIndirectParamW, ES_AUTOHSCROLL, ES_AUTOVSCROLL, + ES_MULTILINE, SBS_VERT, WS_BORDER, WS_CAPTION, WS_CHILD, WS_DISABLED, WS_POPUP, WS_SYSMENU, + WS_TABSTOP, WS_THICKFRAME, WS_VISIBLE, WS_VSCROLL, }; use crate::ui::resource_ids::*; @@ -29,6 +29,7 @@ type DialogProc = Option i const DS_SETFONT: u32 = 0x40; const DS_MODALFRAME: u32 = 0x80; +const DS_CENTER: u32 = 0x0800; const DS_3DLOOK: u32 = 0x0004; const DS_CONTROL: u32 = 0x0400; const WS_EX_NOPARENTNOTIFY: u32 = 0x0000_0004; @@ -38,6 +39,7 @@ const BS_AUTOCHECKBOX_STYLE: u32 = BS_AUTOCHECKBOX as u32; const BS_GROUPBOX_STYLE: u32 = BS_GROUPBOX as u32; const BS_DEFPUSHBUTTON_STYLE: u32 = BS_DEFPUSHBUTTON as u32; const ES_MULTILINE_STYLE: u32 = ES_MULTILINE as u32; +const ES_AUTOHSCROLL_STYLE: u32 = ES_AUTOHSCROLL as u32; const ES_AUTOVSCROLL_STYLE: u32 = ES_AUTOVSCROLL as u32; const SBS_VERT_STYLE: u32 = SBS_VERT as u32; const CBS_DROPDOWNLIST_STYLE: u32 = CBS_DROPDOWNLIST as u32; @@ -1141,6 +1143,60 @@ fn build_message_dialog() -> DialogSpec<'static> { } } +fn build_new_task_dialog() -> DialogSpec<'static> { + DialogSpec { + style: DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU, + ex_style: 0, + x: 20, + y: 20, + cx: 320, + cy: 128, + title: "Create New Task", + font_name: DIALOG_FONT_NAME, + font_size: DIALOG_FONT_SIZE, + controls: vec![ + static_text( + "Type the name of a program, folder, document, or Internet resource, and Windows will open it for you.", + IDC_NEWTASK_PROMPT, + 0, + 8, + 8, + 304, + 36, + ), + edit_text(IDC_NEWTASK_COMMAND, ES_AUTOHSCROLL_STYLE, 8, 48, 230, 14), + button( + "&Browse...", + IDC_NEWTASK_BROWSE, + WS_TABSTOP, + 244, + 48, + 68, + 14, + ), + button( + "Create this task with administrative privileges", + IDC_NEWTASK_ADMIN, + BS_AUTOCHECKBOX_STYLE | WS_TABSTOP, + 8, + 70, + 304, + 12, + ), + button( + "OK", + 1, + BS_DEFPUSHBUTTON_STYLE | WS_TABSTOP, + 192, + 102, + 56, + 14, + ), + button("Cancel", 2, WS_TABSTOP, 254, 102, 56, 14), + ], + } +} + fn build_diagnostics_dialog() -> DialogSpec<'static> { DialogSpec { style: DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU, @@ -1283,6 +1339,7 @@ fn dialog_spec(dialog_id: u16) -> Option> { IDD_SERVICESPAGE => build_services_dialog(), IDD_MESSAGE => build_message_dialog(), IDD_DIAGNOSTICS => build_diagnostics_dialog(), + IDD_NEWTASK => build_new_task_dialog(), _ => return None, }) } @@ -1358,10 +1415,11 @@ pub fn dialog_box( mod tests { use super::{DialogTemplateBuilder, dialog_spec}; use crate::ui::resource_ids::{ - IDC_AFFINITY_GROUP_SELECTOR, IDC_NICTOTALS, IDC_PROCLIST, IDC_SERVICELIST, IDC_TASKLIST, - IDD_AFFINITY, IDD_CPUPAGE, IDD_DIAGNOSTICS, IDD_GPUPAGE, IDD_MAINWND, IDD_MESSAGE, - IDD_NETPAGE, IDD_PERFPAGE, IDD_PROCPAGE, IDD_SELECTPROCCOLS, IDD_SERVICESPAGE, - IDD_TASKPAGE, IDD_USERSPAGE, + IDC_AFFINITY_GROUP_SELECTOR, IDC_NEWTASK_ADMIN, IDC_NEWTASK_BROWSE, IDC_NEWTASK_COMMAND, + IDC_NICTOTALS, IDC_PROCLIST, IDC_SERVICELIST, IDC_TASKLIST, IDD_AFFINITY, IDD_CPUPAGE, + IDD_DIAGNOSTICS, IDD_GPUPAGE, IDD_MAINWND, IDD_MESSAGE, IDD_NETPAGE, IDD_NEWTASK, + IDD_PERFPAGE, IDD_PROCPAGE, IDD_SELECTPROCCOLS, IDD_SERVICESPAGE, IDD_TASKPAGE, + IDD_USERSPAGE, }; use windows_sys::Win32::UI::Controls::LVS_OWNERDATA; @@ -1381,6 +1439,7 @@ mod tests { IDD_SERVICESPAGE, IDD_MESSAGE, IDD_DIAGNOSTICS, + IDD_NEWTASK, ] { let template = DialogTemplateBuilder::new().build(dialog_spec(dialog_id).unwrap()); assert!(!template.is_empty()); @@ -1411,6 +1470,30 @@ mod tests { } } + #[test] + fn new_task_dialog_collects_a_command_and_admin_checkbox() { + let spec = dialog_spec(IDD_NEWTASK).unwrap(); + let admin = spec + .controls + .iter() + .find(|control| control.id == IDC_NEWTASK_ADMIN as u16) + .unwrap(); + let command = spec + .controls + .iter() + .find(|control| control.id == IDC_NEWTASK_COMMAND as u16) + .unwrap(); + assert_ne!(admin.style & super::BS_AUTOCHECKBOX_STYLE, 0); + assert_eq!(command.class_name, "Edit"); + assert!( + spec.controls + .iter() + .any(|control| control.id == IDC_NEWTASK_BROWSE as u16) + ); + assert!(spec.controls.iter().any(|control| control.id == 1)); + assert!(spec.controls.iter().any(|control| control.id == 2)); + } + #[test] fn affinity_dialog_exposes_a_processor_group_selector() { let spec = dialog_spec(IDD_AFFINITY).unwrap(); diff --git a/src/ui/localization/text_key.rs b/src/ui/localization/text_key.rs index c6ef46f..1755f71 100644 --- a/src/ui/localization/text_key.rs +++ b/src/ui/localization/text_key.rs @@ -149,6 +149,13 @@ pub enum TextKey { CpuHistory, NewTaskMenu, NewTaskButton, + NewTaskTitle, + NewTaskAdmin, + NewTaskBrowse, + NewTaskProgramFilter, + NewTaskAllFilesFilter, + NewTaskFailed, + NewTaskEmpty, ExitTaskManager, AlwaysOnTop, MinimizeOnUse, diff --git a/src/ui/localization/ui.rs b/src/ui/localization/ui.rs index aaa762c..a3dc86a 100644 --- a/src/ui/localization/ui.rs +++ b/src/ui/localization/ui.rs @@ -168,6 +168,14 @@ pub fn localize_dialog(hwnd: HWND, dialog_id: u16) { set_dialog_item_text(hwnd, IDC_MESSAGE_TITLE_LABEL, TextKey::MessageTitleLabel); set_dialog_item_text(hwnd, IDC_MESSAGE_BODY_LABEL, TextKey::MessageLabel); } + IDD_NEWTASK => { + set_window_text(hwnd, TextKey::NewTaskTitle); + set_dialog_item_text(hwnd, IDC_NEWTASK_PROMPT, TextKey::RunPrompt); + set_dialog_item_text(hwnd, IDC_NEWTASK_BROWSE, TextKey::NewTaskBrowse); + set_dialog_item_text(hwnd, IDC_NEWTASK_ADMIN, TextKey::NewTaskAdmin); + set_dialog_item_text(hwnd, IDOK, TextKey::Ok); + set_dialog_item_text(hwnd, IDCANCEL, TextKey::Cancel); + } IDD_DIAGNOSTICS => { set_window_text(hwnd, TextKey::DiagnosticLogsTitle); set_dialog_item_text( diff --git a/src/ui/menus.rs b/src/ui/menus.rs index 06c72c2..6b2a295 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -406,6 +406,22 @@ mod tests { } } + #[test] + fn file_menu_new_task_is_enabled() { + let menu = build_file_menu().expect("file menu should build"); + unsafe { + assert_eq!(GetMenuItemID(menu.as_raw(), 0), u32::from(IDM_RUN)); + let mut info = zeroed::(); + info.cbSize = size_of::() as u32; + info.fMask = windows_sys::Win32::UI::WindowsAndMessaging::MIIM_STATE; + assert_ne!(GetMenuItemInfoW(menu.as_raw(), 0, 1, &mut info), 0); + assert_eq!( + info.fState & windows_sys::Win32::UI::WindowsAndMessaging::MFS_GRAYED, + 0 + ); + } + } + #[test] fn diagnostics_is_between_help_topics_and_the_about_separator() { let menu = build_common_help_menu().expect("help menu should build"); diff --git a/src/ui/resource_ids.rs b/src/ui/resource_ids.rs index 148cc25..137fafb 100644 --- a/src/ui/resource_ids.rs +++ b/src/ui/resource_ids.rs @@ -34,6 +34,7 @@ pub const PWM_CPU_FIRMWARE_WORKER_COMPLETE: u32 = 0x0400 + 22; pub const PWM_GPU_METADATA_WORKER_COMPLETE: u32 = 0x0400 + 23; pub const PWM_DIAGNOSTIC_EXPORT_COMPLETE: u32 = 0x0400 + 24; pub const PWM_SERVICE_WORKER_COMPLETE: u32 = 0x0400 + 25; +pub const PWM_NEWTASK_COMPLETE: u32 = 0x0400 + 26; // 顶层窗口、菜单和对话框模板 ID。 pub const IDD_MAINWND: u16 = 105; @@ -68,6 +69,7 @@ pub const IDD_DIAGNOSTICS: u16 = 4200; pub const IDD_SERVICESPAGE: u16 = 4300; pub const IDR_MAINMENU_SERVICE: u16 = 4301; pub const IDR_SERVICE_CONTEXT: u16 = 4302; +pub const IDD_NEWTASK: u16 = 4400; pub const IDI_TRAY_CPU_00: u16 = 127; pub const IDI_TRAY_CPU_01: u16 = 128; @@ -246,6 +248,10 @@ pub const IDC_DIAGNOSTIC_RESTART: i32 = 5410; pub const IDC_DIAGNOSTIC_OPEN_FOLDER: i32 = 5411; pub const IDC_DIAGNOSTIC_EXPORT: i32 = 5412; pub const IDC_DIAGNOSTIC_EXPORT_STATUS: i32 = 5413; +pub const IDC_NEWTASK_PROMPT: i32 = 5500; +pub const IDC_NEWTASK_COMMAND: i32 = 5501; +pub const IDC_NEWTASK_BROWSE: i32 = 5502; +pub const IDC_NEWTASK_ADMIN: i32 = 5503; pub const IDC_SERVICELIST: i32 = 4303; pub const IDC_MESSAGE_TITLE: i32 = 251; pub const IDC_MESSAGE_MESSAGE: i32 = 252; From 8969974ab1b4773c0a47b4a854cc134b231bca23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:50:52 +0000 Subject: [PATCH 2/2] fix: keep spaced paths whole and honor REST_NORUN Resolve the whole New Task input as one shell resource before ShellExecuteExW, so unquoted paths such as C:\Program Files are not cut at the first space. Apply SHRestricted(REST_NORUN) to every New Task entry, including the Applications-page button and view popup. Co-authored-by: jameslincn --- src/app/mod.rs | 10 +- src/app/new_task.rs | 160 ++++++++++++++++++++++++++++++- src/infrastructure/native/mod.rs | 2 +- src/infrastructure/native/ui.rs | 6 ++ src/pages/applications/mod.rs | 34 +++++-- 5 files changed, 194 insertions(+), 18 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index a0a8ae2..8d5d1e8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -87,9 +87,9 @@ use crate::config::options::{Options, update_speed_timer_interval}; use crate::infrastructure::diagnostics::{self, Field, Level}; use crate::infrastructure::native::{ OwnedIcon, call_window_proc, enable_debug_privilege, format_resource_string, height, hiword, - loword, process_is_elevated, record_hresult_error, record_startup_timing, record_win32_error, - sanitize_task_manager_menu, set_dialog_msg_result, set_style, set_window_userdata_ptr, - to_wide_null, width, window_userdata_non_null, + loword, new_task_allowed, process_is_elevated, record_hresult_error, record_startup_timing, + record_win32_error, sanitize_task_manager_menu, set_dialog_msg_result, set_style, + set_window_userdata_ptr, to_wide_null, width, window_userdata_non_null, }; use crate::system::cpu_topology::CpuTopologyError; use crate::system::process_identity::{ProcIdentity, find_proc_identity}; @@ -2300,7 +2300,9 @@ impl App { } } IDM_RUN => { - if let Err(error) = new_task::show(self.hinstance, hwnd) { + if !new_task_allowed() { + MessageBeep(0); + } else if let Err(error) = new_task::show(self.hinstance, hwnd) { record_win32_error("new task dialog", error); let title = to_wide_null(text(TextKey::NewTaskTitle)); let message = to_wide_null(&format!( diff --git a/src/app/new_task.rs b/src/app/new_task.rs index a0a2142..efe2b36 100644 --- a/src/app/new_task.rs +++ b/src/app/new_task.rs @@ -19,7 +19,7 @@ use std::ffi::OsString; use std::mem::{size_of, zeroed}; use std::os::windows::ffi::OsStringExt; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::ptr::null; +use std::ptr::{null, null_mut}; use std::sync::{Arc, Mutex}; use std::thread; @@ -28,6 +28,9 @@ use windows_sys::Win32::Foundation::{ ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, GetLastError, HINSTANCE, HWND, LPARAM, SetLastError, WPARAM, }; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, GetFileAttributesW, INVALID_FILE_ATTRIBUTES, SearchPathW, +}; use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW; use windows_sys::Win32::UI::Controls::Dialogs::{ CommDlgExtendedError, GetOpenFileNameW, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_HIDEREADONLY, @@ -277,7 +280,7 @@ fn launch_new_task( record_win32_error("new task environment expansion", error); NewTaskError::Win32(error) })?; - let Some(target) = split_shell_command(&expanded) else { + let Some(target) = resolve_shell_target(&expanded, probe_shell_resource) else { record_win32_error("new task command line", ERROR_INVALID_PARAMETER); return Err(NewTaskError::Win32(ERROR_INVALID_PARAMETER)); }; @@ -536,6 +539,121 @@ fn context<'a>(hwnd: HWND) -> &'a mut NewTaskDialog { unsafe { &mut *(get_window_userdata(hwnd) as *mut NewTaskDialog) } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShellResource { + Missing, + File, + Directory, +} + +/// Resolves a command before `ShellExecuteExW` sees it. +/// +/// An unquoted value that is itself a file or directory, including `C:\Program Files` and an +/// expanded `%ProgramFiles%\App\app.exe`, stays one shell resource. Arguments are split off only +/// after the longest existing file prefix is known. A path that does not exist is still passed +/// whole, so the first space is not treated as the end of the program name. +fn resolve_shell_target( + command_line: &str, + mut probe: impl FnMut(&str) -> ShellResource, +) -> Option { + let trimmed = command_line.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.starts_with('"') { + return split_shell_command(trimmed); + } + match probe(trimmed) { + ShellResource::File | ShellResource::Directory => { + return Some(ShellTarget { + file: trimmed.to_string(), + parameters: String::new(), + }); + } + ShellResource::Missing => {} + } + if let Some(target) = longest_existing_file(trimmed, &mut probe) { + return Some(target); + } + if looks_like_filesystem_path(trimmed) { + return Some(ShellTarget { + file: trimmed.to_string(), + parameters: String::new(), + }); + } + split_shell_command(trimmed) +} + +fn longest_existing_file( + command_line: &str, + probe: &mut impl FnMut(&str) -> ShellResource, +) -> Option { + let mut search_end = command_line.len(); + while let Some(space) = command_line[..search_end].rfind(' ') { + let file = command_line[..space].trim_end(); + let parameters = command_line[space + 1..].trim_start(); + if !file.is_empty() && probe(file) == ShellResource::File { + return Some(ShellTarget { + file: file.to_string(), + parameters: parameters.to_string(), + }); + } + search_end = space; + } + None +} + +fn looks_like_filesystem_path(value: &str) -> bool { + value.contains(['\\', '/']) +} + +fn probe_shell_resource(candidate: &str) -> ShellResource { + if candidate.is_empty() { + return ShellResource::Missing; + } + let wide = to_wide_null(candidate); + let attributes = unsafe { GetFileAttributesW(wide.as_ptr()) }; + if attributes != INVALID_FILE_ATTRIBUTES { + return if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + ShellResource::Directory + } else { + ShellResource::File + }; + } + if !candidate.contains([' ', '\\', '/']) && search_path_finds_file(wide.as_ptr()) { + ShellResource::File + } else { + ShellResource::Missing + } +} + +fn search_path_finds_file(file_name: *const u16) -> bool { + let mut buffer = vec![0u16; 1024]; + loop { + let written = unsafe { + SearchPathW( + null(), + file_name, + null(), + buffer.len() as u32, + buffer.as_mut_ptr(), + null_mut(), + ) + }; + if written == 0 { + return false; + } + let written = written as usize; + if written < buffer.len() { + return true; + } + if written > MAX_EXPANDED_COMMAND { + return false; + } + buffer.resize(written, 0); + } +} + /// Splits a command the way `PathGetArgs` / `PathUnquoteSpaces` do. /// /// Spaces inside quotes stay with the program path. The returned file has one surrounding quote @@ -614,7 +732,10 @@ fn classify_shell_execute( #[cfg(test)] mod tests { - use super::{NewTaskError, classify_shell_execute, command_text_for_path, split_shell_command}; + use super::{ + NewTaskError, ShellResource, classify_shell_execute, command_text_for_path, + resolve_shell_target, split_shell_command, + }; #[test] fn command_lines_split_on_unquoted_spaces() { @@ -639,6 +760,39 @@ mod tests { assert!(quoted_only.parameters.is_empty()); } + #[test] + fn unquoted_paths_with_spaces_stay_one_shell_resource() { + let mut resources = |candidate: &str| match candidate { + r"C:\Program Files" => ShellResource::Directory, + r"C:\Program Files\App\app.exe" => ShellResource::File, + r"C:\Program Files\app.exe" => ShellResource::File, + _ => ShellResource::Missing, + }; + + let folder = resolve_shell_target(r"C:\Program Files", &mut resources).unwrap(); + assert_eq!(folder.file, r"C:\Program Files"); + assert!(folder.parameters.is_empty()); + + let expanded = + resolve_shell_target(r"C:\Program Files\App\app.exe", &mut resources).unwrap(); + assert_eq!(expanded.file, r"C:\Program Files\App\app.exe"); + assert!(expanded.parameters.is_empty()); + + let with_args = + resolve_shell_target(r"C:\Program Files\app.exe /foo", &mut resources).unwrap(); + assert_eq!(with_args.file, r"C:\Program Files\app.exe"); + assert_eq!(with_args.parameters, "/foo"); + + let missing = + resolve_shell_target(r"C:\Program Files\missing.exe", &mut resources).unwrap(); + assert_eq!(missing.file, r"C:\Program Files\missing.exe"); + assert!(missing.parameters.is_empty()); + + let command = resolve_shell_target("notepad file.txt", &mut resources).unwrap(); + assert_eq!(command.file, "notepad"); + assert_eq!(command.parameters, "file.txt"); + } + #[test] fn browse_paths_quote_only_when_needed() { assert_eq!( diff --git a/src/infrastructure/native/mod.rs b/src/infrastructure/native/mod.rs index 3834603..b0eacae 100644 --- a/src/infrastructure/native/mod.rs +++ b/src/infrastructure/native/mod.rs @@ -32,7 +32,7 @@ pub use safety::{enable_debug_privilege, process_is_elevated}; pub(crate) use ui::append_architecture_suffix; pub use ui::{ call_window_proc, copy_text_to_callback_buffer, finish_list_view_update, - format_resource_string, get_window_userdata, height, hiword, loword, + format_resource_string, get_window_userdata, height, hiword, loword, new_task_allowed, pause_redraw_for_visible_windows, redraw_window_tree, resume_redraw_for_windows, sanitize_task_manager_menu, set_dialog_msg_result, set_style, set_window_userdata, set_window_userdata_ptr, subclass_list_view, to_wide_null, widestr_ptr_to_string, width, diff --git a/src/infrastructure/native/ui.rs b/src/infrastructure/native/ui.rs index 8ea0c29..17103f4 100644 --- a/src/infrastructure/native/ui.rs +++ b/src/infrastructure/native/ui.rs @@ -214,6 +214,12 @@ pub fn hiword(value: usize) -> u16 { ((value >> 16) & 0xFFFF) as u16 } +pub fn new_task_allowed() -> bool { + // The same shell policy that removes File → New Task applies to every other entry point. + // Safety: SHRestricted is a process-local policy query with no pointer inputs. + unsafe { SHRestricted(REST_NORUN) == 0 } +} + pub fn sanitize_task_manager_menu(menu: HMENU, processor_count: usize) { // 某些菜单项是否可见由系统策略和 CPU 数量决定。 // 这里在每次加载菜单后做一次裁剪,避免资源文件里维护多套变体。 diff --git a/src/pages/applications/mod.rs b/src/pages/applications/mod.rs index 538221b..5abac78 100644 --- a/src/pages/applications/mod.rs +++ b/src/pages/applications/mod.rs @@ -51,13 +51,14 @@ use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ EnableWindow, GetKeyState, SetFocus, VK_CONTROL, }; use windows_sys::Win32::UI::WindowsAndMessaging::{ - BeginDeferWindowPos, CascadeWindows, CheckMenuRadioItem, DeferWindowPos, DrawMenuBar, - EnableMenuItem, EndDeferWindowPos, GetClientRect, GetDesktopWindow, GetDlgItem, GetWindowLongW, - GetWindowThreadProcessId, IsIconic, IsWindow, MB_ICONERROR, MB_OK, MDITILE_HORIZONTAL, - MDITILE_VERTICAL, MF_BYCOMMAND, MF_DISABLED, MF_GRAYED, MessageBoxW, SW_MAXIMIZE, SW_MINIMIZE, - SW_RESTORE, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SendMessageW, - SetForegroundWindow, SetMenuDefaultItem, SetWindowLongW, SetWindowPos, ShowWindow, - ShowWindowAsync, TPM_RETURNCMD, TileWindows, TrackPopupMenuEx, WM_COMMAND, WM_SETREDRAW, + BeginDeferWindowPos, CascadeWindows, CheckMenuRadioItem, DeferWindowPos, DeleteMenu, + DrawMenuBar, EnableMenuItem, EndDeferWindowPos, GetClientRect, GetDesktopWindow, GetDlgItem, + GetWindowLongW, GetWindowThreadProcessId, IsIconic, IsWindow, MB_ICONERROR, MB_OK, + MDITILE_HORIZONTAL, MDITILE_VERTICAL, MF_BYCOMMAND, MF_DISABLED, MF_GRAYED, MessageBoxW, + SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, + SendMessageW, SetForegroundWindow, SetMenuDefaultItem, SetWindowLongW, SetWindowPos, + ShowWindow, ShowWindowAsync, TPM_RETURNCMD, TileWindows, TrackPopupMenuEx, WM_COMMAND, + WM_SETREDRAW, }; use windows_sys::core::BOOL; @@ -69,8 +70,8 @@ use self::sampler::{TaskSamplerCache, TaskWorkerResult, collect_tasks_worker}; use crate::config::options::{Options, ViewMode}; use crate::infrastructure::native::{ ProcessArchitecture, append_architecture_suffix, copy_text_to_callback_buffer, - finish_list_view_update, record_win32_error, subclass_list_view, to_wide_null, - window_rect_relative_to_page, + finish_list_view_update, new_task_allowed, record_win32_error, subclass_list_view, + to_wide_null, window_rect_relative_to_page, }; use crate::infrastructure::worker::{SingleFlightWorker, keep_pending}; use crate::system::process_identity::{ @@ -299,6 +300,7 @@ impl TaskPageState { SetFocus(list_hwnd); } } + self.update_ui_state(); 0 } @@ -417,9 +419,14 @@ impl TaskPageState { // Win32 enumeration or this page's own controls. unsafe { match command_id { - IDM_LARGEICONS | IDM_SMALLICONS | IDM_DETAILS | IDM_RUN => { + IDM_LARGEICONS | IDM_SMALLICONS | IDM_DETAILS => { SendMessageW(self.main_hwnd, WM_COMMAND, command_id as usize, 0); } + IDM_RUN => { + if new_task_allowed() { + SendMessageW(self.main_hwnd, WM_COMMAND, command_id as usize, 0); + } + } id if id == IDM_TASK_SWITCHTO || id == IDC_SWITCHTO as u16 => { if let Some(hwnd) = self.selected_hwnds(true).first().copied() { if IsIconic(hwnd) != 0 { @@ -552,6 +559,9 @@ impl TaskPageState { u32::from(checked_id), MF_BYCOMMAND, ); + if !new_task_allowed() { + DeleteMenu(popup_handle, u32::from(IDM_RUN), MF_BYCOMMAND); + } } else { SetMenuDefaultItem(popup_handle, u32::from(IDM_TASK_SWITCHTO), 0); if selected_hwnds.is_empty() { @@ -1214,6 +1224,10 @@ impl TaskPageState { EnableWindow(hwnd, i32::from(enabled)); } } + let run_button = GetDlgItem(self.hwnd_page, i32::from(IDM_RUN)); + if !run_button.is_null() { + EnableWindow(run_button, i32::from(new_task_allowed())); + } } }