diff --git a/Cargo.toml b/Cargo.toml index da4acee..055743f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ windows-sys = { version = "0.61.2", features = [ "Win32_System_Performance", "Win32_System_Registry", "Win32_System_RemoteDesktop", + "Win32_System_Services", "Win32_System_Shutdown", "Win32_System_StationsAndDesktops", "Win32_System_SystemInformation", diff --git a/README.md b/README.md index ceeb27a..089db7a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This is not a copy of the modern Task Manager. CPU and GPU details come from sys - **CPU** shows the processor name, effective frequency, topology, caches, virtualization, ISA features, system rates, and a 60-second utilization graph. - **GPU** handles multiple adapters and engines, dedicated and shared memory, temperature, driver details, and DirectX information. - **Networking and Users** show adapter throughput history and Windows session state. +- **Services** lists Win32 services with description, status, startup type, and the owning process. Start, stop, and Go to Process run only when the current row allows them. Go to Process requires a process id plus creation time. ## Download @@ -163,7 +164,7 @@ The project remains one Rust crate. Directories follow ownership boundaries rath - `src/ui` contains reusable chart, dialog, menu, drawing, resource, and localization code. - `src/config` owns the versioned registry options format. -Workers publish complete snapshots from one source at a time. The UI rejects stale generations and keeps the last valid snapshot when a refresh fails. Expensive Win32, PDH, DXGI, WMI, WTS, and icon work stays off the UI thread. +Workers publish complete snapshots from one source at a time. The UI rejects stale generations and keeps the last valid snapshot when a refresh fails. Expensive Win32, PDH, DXGI, WMI, WTS, Service Control Manager, and icon work stays off the UI thread. ## Image resources diff --git a/README.zh-CN.md b/README.zh-CN.md index a0ebaeb..ab51ce2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,6 +16,7 @@ - **CPU**:处理器型号、有效频率、拓扑、缓存、虚拟化、ISA、系统速率计数和 60 秒总利用率历史。 - **GPU**:多适配器、多引擎、专用/共享显存、温度、驱动和 DirectX 信息。 - **网络与用户**:网卡吞吐历史、会话状态和常用会话操作。 +- **服务**:列出 Win32 服务的名称、描述、状态、启动类型和所属进程。启动、停止和转到进程只在当前行允许时可用。转到进程同时要求进程 ID 和创建时间。 ## 下载 @@ -154,7 +155,7 @@ CPU 拓扑来自 `GetLogicalProcessorInformationEx`,动态频率和系统速 - `src/ui` 放图表、对话框、菜单、绘制、资源和本地化等通用 UI 代码。 - `src/config` 负责带版本的注册表配置格式。 -后台 worker 每次只提交一个来源的完整快照。UI 会拒绝过期 generation;刷新失败时保留上一份有效结果。Win32、PDH、DXGI、WMI、WTS 和图标采集等可能阻塞的工作不会放到 UI 线程。 +后台 worker 每次只提交一个来源的完整快照。UI 会拒绝过期 generation;刷新失败时保留上一份有效结果。Win32、PDH、DXGI、WMI、WTS、服务控制管理器和图标采集等可能阻塞的工作不会放到 UI 线程。 ## 图像资源 diff --git a/build_support/localization.rs b/build_support/localization.rs index 83941fa..22ded5c 100644 --- a/build_support/localization.rs +++ b/build_support/localization.rs @@ -102,6 +102,18 @@ const MENU_STATUS_HELP_KEYS: &[(&str, &str)] = &[ "IDM_TASK_BRINGTOFRONT", "crate::ui::resource_ids::IDM_TASK_BRINGTOFRONT", ), + ( + "IDM_SERVICE_START", + "crate::ui::resource_ids::IDM_SERVICE_START", + ), + ( + "IDM_SERVICE_STOP", + "crate::ui::resource_ids::IDM_SERVICE_STOP", + ), + ( + "IDM_SERVICE_GOTOPROCESS", + "crate::ui::resource_ids::IDM_SERVICE_GOTOPROCESS", + ), ]; pub(crate) fn generate() { diff --git a/localization/de.toml b/localization/de.toml index 9b88918..a0110c5 100644 --- a/localization/de.toml +++ b/localization/de.toml @@ -294,6 +294,29 @@ ConfirmDisconnectSelectedUsers = "Moechten Sie die ausgewaehlten Benutzer wirkli SelectedUserCouldNotBeLoggedOff = "Der ausgewaehlte Benutzer konnte nicht abgemeldet werden." SelectedUserCouldNotBeDisconnected = "Der ausgewaehlte Benutzer konnte nicht getrennt werden." Win32ErrorPrefix = "Win32-Fehler:" +ServicesPageTitle = "Dienste" +ServicesMenu = "&Dienste" +ServiceStart = "&Starten" +ServiceStop = "S&toppen" +ServiceColumnName = "Name" +ServiceColumnDescription = "Beschreibung" +ServiceColumnStartupType = "Starttyp" +ServiceColumnProcess = "Prozess" +ServiceStatusStopped = "Beendet" +ServiceStatusStartPending = "Wird gestartet" +ServiceStatusStopPending = "Wird beendet" +ServiceStatusContinuePending = "Wird fortgesetzt" +ServiceStatusPausePending = "Wird angehalten" +ServiceStatusPaused = "Angehalten" +ServiceStartupBoot = "Boot" +ServiceStartupSystem = "System" +ServiceStartupAutomatic = "Automatisch" +ServiceStartupManual = "Manuell" +ServiceStartupDisabled = "Deaktiviert" +ServiceStartFailed = "Der Dienst konnte nicht gestartet werden." +ServiceStopFailed = "Der Dienst konnte nicht beendet werden." +ServiceStateUnchanged = "Der Dienst hat den angeforderten Zustand nicht erreicht." +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -326,3 +349,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "Startet den ausgewählten Dienst" +IDM_SERVICE_STOP = "Beendet den ausgewählten Dienst" +IDM_SERVICE_GOTOPROCESS = "Wählt den Prozess des Diensts auf der Seite Prozesse aus" diff --git a/localization/en_us.toml b/localization/en_us.toml index c6e5f60..1975f4a 100644 --- a/localization/en_us.toml +++ b/localization/en_us.toml @@ -301,6 +301,29 @@ ConfirmDisconnectSelectedUsers = "Are you sure you want to disconnect the select SelectedUserCouldNotBeLoggedOff = "The selected user could not be logged off." SelectedUserCouldNotBeDisconnected = "The selected user could not be disconnected." Win32ErrorPrefix = "Win32 error:" +ServicesPageTitle = "Services" +ServicesMenu = "&Services" +ServiceStart = "&Start" +ServiceStop = "&Stop" +ServiceColumnName = "Name" +ServiceColumnDescription = "Description" +ServiceColumnStartupType = "Startup Type" +ServiceColumnProcess = "Process" +ServiceStatusStopped = "Stopped" +ServiceStatusStartPending = "Start Pending" +ServiceStatusStopPending = "Stop Pending" +ServiceStatusContinuePending = "Continue Pending" +ServiceStatusPausePending = "Pause Pending" +ServiceStatusPaused = "Paused" +ServiceStartupBoot = "Boot" +ServiceStartupSystem = "System" +ServiceStartupAutomatic = "Automatic" +ServiceStartupManual = "Manual" +ServiceStartupDisabled = "Disabled" +ServiceStartFailed = "Unable to start the service." +ServiceStopFailed = "Unable to stop the service." +ServiceStateUnchanged = "The service did not enter the requested state." +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -333,3 +356,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "Starts the selected service" +IDM_SERVICE_STOP = "Stops the selected service" +IDM_SERVICE_GOTOPROCESS = "Selects the service process on the Processes page" diff --git a/localization/es.toml b/localization/es.toml index 9916cca..ea81720 100644 --- a/localization/es.toml +++ b/localization/es.toml @@ -294,6 +294,29 @@ ConfirmDisconnectSelectedUsers = "¿Seguro que desea desconectar a los usuarios SelectedUserCouldNotBeLoggedOff = "No se pudo cerrar la sesion del usuario seleccionado." SelectedUserCouldNotBeDisconnected = "No se pudo desconectar al usuario seleccionado." Win32ErrorPrefix = "Error de Win32:" +ServicesPageTitle = "Servicios" +ServicesMenu = "&Servicios" +ServiceStart = "&Iniciar" +ServiceStop = "&Detener" +ServiceColumnName = "Nombre" +ServiceColumnDescription = "Descripción" +ServiceColumnStartupType = "Tipo de inicio" +ServiceColumnProcess = "Proceso" +ServiceStatusStopped = "Detenido" +ServiceStatusStartPending = "Inicio pendiente" +ServiceStatusStopPending = "Detención pendiente" +ServiceStatusContinuePending = "Reanudación pendiente" +ServiceStatusPausePending = "Pausa pendiente" +ServiceStatusPaused = "En pausa" +ServiceStartupBoot = "Arranque" +ServiceStartupSystem = "Sistema" +ServiceStartupAutomatic = "Automático" +ServiceStartupManual = "Manual" +ServiceStartupDisabled = "Deshabilitado" +ServiceStartFailed = "No se pudo iniciar el servicio." +ServiceStopFailed = "No se pudo detener el servicio." +ServiceStateUnchanged = "El servicio no entró en el estado solicitado." +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -326,3 +349,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "Inicia el servicio seleccionado" +IDM_SERVICE_STOP = "Detiene el servicio seleccionado" +IDM_SERVICE_GOTOPROCESS = "Selecciona el proceso del servicio en la página Procesos" diff --git a/localization/fr.toml b/localization/fr.toml index 90c2401..c42955e 100644 --- a/localization/fr.toml +++ b/localization/fr.toml @@ -294,6 +294,29 @@ ConfirmDisconnectSelectedUsers = "Voulez-vous vraiment deconnecter les utilisate SelectedUserCouldNotBeLoggedOff = "Impossible de fermer la session de l'utilisateur selectionne." SelectedUserCouldNotBeDisconnected = "Impossible de deconnecter l'utilisateur selectionne." Win32ErrorPrefix = "Erreur Win32 :" +ServicesPageTitle = "Services" +ServicesMenu = "&Services" +ServiceStart = "&Démarrer" +ServiceStop = "&Arrêter" +ServiceColumnName = "Nom" +ServiceColumnDescription = "Description" +ServiceColumnStartupType = "Type de démarrage" +ServiceColumnProcess = "Processus" +ServiceStatusStopped = "Arrêté" +ServiceStatusStartPending = "Démarrage en cours" +ServiceStatusStopPending = "Arrêt en cours" +ServiceStatusContinuePending = "Reprise en cours" +ServiceStatusPausePending = "Mise en pause" +ServiceStatusPaused = "Suspendu" +ServiceStartupBoot = "Démarrage" +ServiceStartupSystem = "Système" +ServiceStartupAutomatic = "Automatique" +ServiceStartupManual = "Manuel" +ServiceStartupDisabled = "Désactivé" +ServiceStartFailed = "Impossible de démarrer le service." +ServiceStopFailed = "Impossible d'arrêter le service." +ServiceStateUnchanged = "Le service n'a pas atteint l'état demandé." +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -326,3 +349,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "Démarre le service sélectionné" +IDM_SERVICE_STOP = "Arrête le service sélectionné" +IDM_SERVICE_GOTOPROCESS = "Sélectionne le processus du service dans la page Processus" diff --git a/localization/pt.toml b/localization/pt.toml index e299fe8..847ce6b 100644 --- a/localization/pt.toml +++ b/localization/pt.toml @@ -294,6 +294,29 @@ ConfirmDisconnectSelectedUsers = "Tem certeza de que deseja desconectar os usuar SelectedUserCouldNotBeLoggedOff = "Nao foi possivel fazer logoff do usuario selecionado." SelectedUserCouldNotBeDisconnected = "Nao foi possivel desconectar o usuario selecionado." Win32ErrorPrefix = "Erro do Win32:" +ServicesPageTitle = "Serviços" +ServicesMenu = "&Serviços" +ServiceStart = "&Iniciar" +ServiceStop = "&Parar" +ServiceColumnName = "Nome" +ServiceColumnDescription = "Descrição" +ServiceColumnStartupType = "Tipo de inicialização" +ServiceColumnProcess = "Processo" +ServiceStatusStopped = "Parado" +ServiceStatusStartPending = "Iniciando" +ServiceStatusStopPending = "Parando" +ServiceStatusContinuePending = "Continuando" +ServiceStatusPausePending = "Pausando" +ServiceStatusPaused = "Pausado" +ServiceStartupBoot = "Inicialização" +ServiceStartupSystem = "Sistema" +ServiceStartupAutomatic = "Automático" +ServiceStartupManual = "Manual" +ServiceStartupDisabled = "Desativado" +ServiceStartFailed = "Não foi possível iniciar o serviço." +ServiceStopFailed = "Não foi possível parar o serviço." +ServiceStateUnchanged = "O serviço não entrou no estado solicitado." +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -326,3 +349,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "Inicia o serviço selecionado" +IDM_SERVICE_STOP = "Para o serviço selecionado" +IDM_SERVICE_GOTOPROCESS = "Seleciona o processo do serviço na página Processos" diff --git a/localization/ru.toml b/localization/ru.toml index 6cfd0e1..ae9df1b 100644 --- a/localization/ru.toml +++ b/localization/ru.toml @@ -294,6 +294,29 @@ ConfirmDisconnectSelectedUsers = "Вы действительно хотите SelectedUserCouldNotBeLoggedOff = "Не удалось завершить сеанс выбранного пользователя." SelectedUserCouldNotBeDisconnected = "Не удалось отключить выбранного пользователя." Win32ErrorPrefix = "Ошибка Win32:" +ServicesPageTitle = "Службы" +ServicesMenu = "&Службы" +ServiceStart = "&Запустить" +ServiceStop = "&Остановить" +ServiceColumnName = "Имя" +ServiceColumnDescription = "Описание" +ServiceColumnStartupType = "Тип запуска" +ServiceColumnProcess = "Процесс" +ServiceStatusStopped = "Остановлена" +ServiceStatusStartPending = "Запускается" +ServiceStatusStopPending = "Останавливается" +ServiceStatusContinuePending = "Продолжается" +ServiceStatusPausePending = "Приостанавливается" +ServiceStatusPaused = "Приостановлена" +ServiceStartupBoot = "Загрузка" +ServiceStartupSystem = "Система" +ServiceStartupAutomatic = "Авто" +ServiceStartupManual = "Вручную" +ServiceStartupDisabled = "Отключена" +ServiceStartFailed = "Не удалось запустить службу." +ServiceStopFailed = "Не удалось остановить службу." +ServiceStateUnchanged = "Служба не перешла в запрошенное состояние." +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -326,3 +349,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "Запускает выбранную службу" +IDM_SERVICE_STOP = "Останавливает выбранную службу" +IDM_SERVICE_GOTOPROCESS = "Выбирает процесс службы на странице процессов" diff --git a/localization/zh_cn.toml b/localization/zh_cn.toml index a1842f2..77b2fbe 100644 --- a/localization/zh_cn.toml +++ b/localization/zh_cn.toml @@ -291,6 +291,29 @@ ConfirmDisconnectSelectedUsers = "确实要断开所选用户吗?" SelectedUserCouldNotBeLoggedOff = "无法注销所选用户。" SelectedUserCouldNotBeDisconnected = "无法断开所选用户。" Win32ErrorPrefix = "Win32 错误:" +ServicesPageTitle = "服务" +ServicesMenu = "服务(&S)" +ServiceStart = "启动(&S)" +ServiceStop = "停止(&T)" +ServiceColumnName = "名称" +ServiceColumnDescription = "描述" +ServiceColumnStartupType = "启动类型" +ServiceColumnProcess = "进程" +ServiceStatusStopped = "已停止" +ServiceStatusStartPending = "正在启动" +ServiceStatusStopPending = "正在停止" +ServiceStatusContinuePending = "正在继续" +ServiceStatusPausePending = "正在暂停" +ServiceStatusPaused = "已暂停" +ServiceStartupBoot = "引导" +ServiceStartupSystem = "系统" +ServiceStartupAutomatic = "自动" +ServiceStartupManual = "手动" +ServiceStartupDisabled = "禁用" +ServiceStartFailed = "无法启动该服务。" +ServiceStopFailed = "无法停止该服务。" +ServiceStateUnchanged = "该服务没有进入请求的状态。" +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -323,3 +346,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "启动所选服务" +IDM_SERVICE_STOP = "停止所选服务" +IDM_SERVICE_GOTOPROCESS = "在进程页选中该服务的进程" diff --git a/localization/zh_tw.toml b/localization/zh_tw.toml index 5cae9aa..70843bf 100644 --- a/localization/zh_tw.toml +++ b/localization/zh_tw.toml @@ -291,6 +291,29 @@ ConfirmDisconnectSelectedUsers = "確定要中斷選取的使用者嗎?" SelectedUserCouldNotBeLoggedOff = "無法將選取的使用者登出。" SelectedUserCouldNotBeDisconnected = "無法中斷選取的使用者。" Win32ErrorPrefix = "Win32 錯誤:" +ServicesPageTitle = "服務" +ServicesMenu = "服務(&S)" +ServiceStart = "啟動(&S)" +ServiceStop = "停止(&T)" +ServiceColumnName = "名稱" +ServiceColumnDescription = "描述" +ServiceColumnStartupType = "啟動類型" +ServiceColumnProcess = "處理程序" +ServiceStatusStopped = "已停止" +ServiceStatusStartPending = "正在啟動" +ServiceStatusStopPending = "正在停止" +ServiceStatusContinuePending = "正在繼續" +ServiceStatusPausePending = "正在暫停" +ServiceStatusPaused = "已暫停" +ServiceStartupBoot = "開機" +ServiceStartupSystem = "系統" +ServiceStartupAutomatic = "自動" +ServiceStartupManual = "手動" +ServiceStartupDisabled = "已停用" +ServiceStartFailed = "無法啟動服務。" +ServiceStopFailed = "無法停止服務。" +ServiceStateUnchanged = "服務沒有進入要求的狀態。" +ServiceProcessPid = "PID" [menu_status_help] IDM_RUN = "Runs a new program" @@ -323,3 +346,6 @@ IDM_TASK_CASCADE = "Cascades the windows diagonally on the desktop" IDM_TASK_TILEHORZ = "Tiles the windows horizontally on the desktop" IDM_TASK_TILEVERT = "Tiles the windows vertically on the desktop" IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" +IDM_SERVICE_START = "啟動選取的服務" +IDM_SERVICE_STOP = "停止選取的服務" +IDM_SERVICE_GOTOPROCESS = "在處理程序頁選取該服務的處理程序" diff --git a/src/app/mod.rs b/src/app/mod.rs index 7a44795..8345545 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1172,6 +1172,14 @@ impl App { .unwrap_or(false), ); } + if self.options.current_page == PageId::Services.persisted() { + let commands = self.pages[PageId::Services.index()] + .service_command_state() + .unwrap_or_default(); + self.enable_menu_item(menu, IDM_SERVICE_START, commands.start); + self.enable_menu_item(menu, IDM_SERVICE_STOP, commands.stop); + self.enable_menu_item(menu, IDM_SERVICE_GOTOPROCESS, commands.go_to_process); + } // 安全性: same menu handle as above. unsafe { @@ -1188,6 +1196,17 @@ impl App { } } + fn enable_menu_item(&self, menu: HMENU, item_id: u16, enabled: bool) { + // 安全性: caller passes a menu handle currently owned by the main window or popup menu. + unsafe { + EnableMenuItem( + menu, + u32::from(item_id), + MF_BYCOMMAND | if enabled { MF_ENABLED } else { MF_GRAYED }, + ); + } + } + fn check_menu(&self, menu: HMENU, item_id: u16, checked: bool) { // 安全性: caller passes a menu handle currently owned by the main window or popup menu. unsafe { @@ -1979,6 +1998,8 @@ impl App { fn on_init_menu(&mut self) -> isize { // 菜单刚弹出时先标记“暂时不能隐藏窗口”,避免右键隐藏逻辑误触发。 + // 服务命令的可用状态跟随当前选择,在菜单打开时重新计算。 + self.update_menu_states(); self.menu.mark_menu_opened(); 0 } @@ -2258,6 +2279,17 @@ impl App { MessageBeep(0); } } + IDM_SERVICE_START | IDM_SERVICE_STOP | IDM_SERVICE_GOTOPROCESS => { + if self.options.current_page == PageId::Services.persisted() { + let handled = + self.pages[PageId::Services.index()].handle_service_command(command_id); + if handled { + self.update_menu_states(); + } + } else { + MessageBeep(0); + } + } IDM_RUN => { if let Err(error) = self.show_run_dialog() { record_hresult_error("system Run dialog", error); diff --git a/src/app/page_host.rs b/src/app/page_host.rs index 30f1610..bab377d 100644 --- a/src/app/page_host.rs +++ b/src/app/page_host.rs @@ -47,6 +47,7 @@ use crate::pages::gpu::GpuPageState; use crate::pages::network::NetworkPageState; use crate::pages::performance::PerformancePageState; use crate::pages::processes::ProcessPageState; +use crate::pages::services::ServicePageState; use crate::pages::users::UserPageState; use crate::system::process_identity::ProcIdentity; use crate::system::sampler::SystemSample; @@ -55,10 +56,10 @@ use crate::ui::localization::{localize_dialog, text}; use crate::ui::menus::build_main_menu; use crate::ui::resource_ids::{ IDC_CPU_DETAIL_GRAPH, IDC_CPUMETER, IDC_GPU_SELECTOR, IDC_MEMGRAPH, IDC_MEMMETER, - IDC_NICTOTALS, IDC_PROCLIST, IDC_TASKLIST, IDC_USERLIST, PWM_CPU_FIRMWARE_WORKER_COMPLETE, - PWM_CPU_WORKER_COMPLETE, PWM_GPU_METADATA_WORKER_COMPLETE, PWM_GPU_WORKER_COMPLETE, - PWM_NET_WORKER_COMPLETE, PWM_PROC_WORKER_COMPLETE, PWM_TASK_WORKER_COMPLETE, - PWM_USER_WORKER_COMPLETE, + IDC_NICTOTALS, IDC_PROCLIST, IDC_SERVICELIST, IDC_TASKLIST, IDC_USERLIST, + PWM_CPU_FIRMWARE_WORKER_COMPLETE, PWM_CPU_WORKER_COMPLETE, PWM_GPU_METADATA_WORKER_COMPLETE, + PWM_GPU_WORKER_COMPLETE, PWM_NET_WORKER_COMPLETE, PWM_PROC_WORKER_COMPLETE, + PWM_SERVICE_WORKER_COMPLETE, PWM_TASK_WORKER_COMPLETE, PWM_USER_WORKER_COMPLETE, }; use crate::ui::runtime_menu::MenuBar; @@ -93,6 +94,7 @@ enum PageState { Gpu(Box), Network(Box), Users(Box), + Services(Box), } impl PageState { @@ -124,6 +126,7 @@ impl PageState { Self::Gpu(_) => IDC_GPU_SELECTOR, Self::Network(_) => IDC_NICTOTALS, Self::Users(_) => IDC_USERLIST, + Self::Services(_) => IDC_SERVICELIST, }; if unsafe { GetDlgItem(hwnd, required_control) }.is_null() { return Err(ERROR_INVALID_WINDOW_HANDLE); @@ -141,6 +144,7 @@ impl PageState { state.initialize(hwnd, main_hwnd, hwnd_tabs)?; } Self::Users(state) => state.initialize(hwnd)?, + Self::Services(state) => state.initialize(hwnd, main_hwnd)?, } Ok(()) } @@ -191,6 +195,10 @@ impl PageState { state.apply_options(options); false } + Self::Services(state) => { + state.apply_options(options); + false + } }; if redraw_owner_draw_page { redraw_window_tree(hwnd); @@ -219,6 +227,10 @@ impl PageState { state.apply_options(options); state.timer_event(); } + Self::Services(state) => { + state.apply_options(options); + state.timer_event(); + } } } @@ -237,6 +249,7 @@ impl PageState { Self::Gpu(state) => state.destroy(), Self::Network(state) => state.destroy(), Self::Users(state) => state.destroy(), + Self::Services(state) => state.destroy(), } } @@ -276,6 +289,20 @@ impl PageState { } } + fn handle_service_command(&mut self, command_id: u16) -> bool { + match self { + Self::Services(state) => state.handle_command(command_id), + _ => false, + } + } + + fn service_command_state(&self) -> Option { + match self { + Self::Services(state) => Some(state.command_state()), + _ => None, + } + } + fn user_show_domain_names(&self) -> Option { match self { Self::Users(state) => Some(state.show_domain_names()), @@ -299,6 +326,7 @@ impl PageState { Self::Gpu(state) => state.no_title(), Self::Network(state) => state.no_title(), Self::Users(state) => state.no_title(), + Self::Services(state) => state.no_title(), } } @@ -314,6 +342,7 @@ impl PageState { Self::Process(_) => 1, Self::Network(_) => 1, Self::Users(_) => 1, + Self::Services(_) => 1, Self::Performance(_) => 1, Self::Cpu(_) => 1, Self::Gpu(_) => 1, @@ -331,6 +360,7 @@ impl PageState { 1 } Self::Users(state) => isize::from(state.handle_command(command_id)), + Self::Services(state) => isize::from(state.handle_command(command_id)), _ => 0, } } @@ -347,6 +377,7 @@ impl PageState { Self::Process(state) => unsafe { state.handle_notify(lparam) }, Self::Cpu(state) => unsafe { state.handle_notify(lparam) }, Self::Users(state) => unsafe { state.handle_notify(lparam) }, + Self::Services(state) => unsafe { state.handle_notify(lparam) }, _ => 0, } } @@ -374,6 +405,15 @@ impl PageState { ); Some(1) } + Self::Services(state) + if wparam as HWND == unsafe { GetDlgItem(hwnd, IDC_SERVICELIST) } => + { + state.show_context_menu( + i32::from((lparam & 0xFFFF) as i16), + i32::from(((lparam >> 16) & 0xFFFF) as i16), + ); + Some(1) + } _ => None, } } @@ -402,6 +442,10 @@ impl PageState { state.size_page(); false } + Self::Services(state) => { + state.size_page(); + false + } }; if redraw_owner_draw_page { redraw_window_tree(hwnd); @@ -420,7 +464,7 @@ impl PageState { match self { Self::Process(_) | Self::Network(_) => WS_CLIPCHILDREN, Self::Performance(_) | Self::Cpu(_) | Self::Gpu(_) => WS_CLIPCHILDREN | WS_CLIPSIBLINGS, - Self::Task(_) | Self::Users(_) => 0, + Self::Task(_) | Self::Users(_) | Self::Services(_) => 0, } } @@ -462,15 +506,17 @@ impl PageState { match msg { WM_COMMAND => match self { Self::Gpu(state) => Some(state.handle_command(wparam)), - Self::Task(_) | Self::Process(_) | Self::Users(_) => { + Self::Task(_) | Self::Process(_) | Self::Users(_) | Self::Services(_) => { Some(self.handle_page_command((wparam & 0xFFFF) as u16)) } _ => None, }, WM_NOTIFY => match self { - Self::Task(_) | Self::Process(_) | Self::Cpu(_) | Self::Users(_) => { - Some(unsafe { self.handle_notify(lparam) }) - } + Self::Task(_) + | Self::Process(_) + | Self::Cpu(_) + | Self::Users(_) + | Self::Services(_) => Some(unsafe { self.handle_notify(lparam) }), _ => None, }, WM_CONTEXTMENU => self.handle_context_menu(hwnd, wparam, lparam), @@ -604,6 +650,13 @@ impl PageState { } _ => None, }, + PWM_SERVICE_WORKER_COMPLETE => match self { + Self::Services(state) => { + state.handle_worker_completion(); + Some(0) + } + _ => None, + }, _ => None, } } @@ -632,6 +685,7 @@ impl DialogPage { PageId::Gpu => PageState::Gpu(Box::default()), PageId::Network => PageState::Network(Box::new(NetworkPageState::new())), PageId::Users => PageState::Users(Box::new(UserPageState::new())), + PageId::Services => PageState::Services(Box::new(ServicePageState::new())), }; Self { hinstance: null_mut(), @@ -850,6 +904,14 @@ impl DialogPage { self.state.handle_user_command(command_id) } + pub fn handle_service_command(&mut self, command_id: u16) -> bool { + self.state.handle_service_command(command_id) + } + + pub fn service_command_state(&self) -> Option { + self.state.service_command_state() + } + pub fn user_show_domain_names(&self) -> Option { self.state.user_show_domain_names() } diff --git a/src/app/page_registry.rs b/src/app/page_registry.rs index 5d5a2ad..830e74f 100644 --- a/src/app/page_registry.rs +++ b/src/app/page_registry.rs @@ -15,10 +15,10 @@ use crate::ui::localization::TextKey; use crate::ui::resource_ids::{ - IDC_GPU_SELECTOR, IDC_NICTOTALS, IDC_TASKLIST, IDC_USERLIST, IDD_CPUPAGE, IDD_GPUPAGE, - IDD_NETPAGE, IDD_PERFPAGE, IDD_PROCPAGE, IDD_TASKPAGE, IDD_USERSPAGE, IDR_MAINMENU_CPU, - IDR_MAINMENU_GPU, IDR_MAINMENU_NET, IDR_MAINMENU_PERF, IDR_MAINMENU_PROC, IDR_MAINMENU_TASK, - IDR_MAINMENU_USER, + IDC_GPU_SELECTOR, IDC_NICTOTALS, IDC_SERVICELIST, IDC_TASKLIST, IDC_USERLIST, IDD_CPUPAGE, + IDD_GPUPAGE, IDD_NETPAGE, IDD_PERFPAGE, IDD_PROCPAGE, IDD_SERVICESPAGE, IDD_TASKPAGE, + IDD_USERSPAGE, IDR_MAINMENU_CPU, IDR_MAINMENU_GPU, IDR_MAINMENU_NET, IDR_MAINMENU_PERF, + IDR_MAINMENU_PROC, IDR_MAINMENU_SERVICE, IDR_MAINMENU_TASK, IDR_MAINMENU_USER, }; #[repr(i32)] @@ -31,10 +31,11 @@ pub(crate) enum PageId { Gpu = 4, Network = 5, Users = 6, + Services = 7, } impl PageId { - pub(crate) const ALL: [Self; 7] = [ + pub(crate) const ALL: [Self; 8] = [ Self::Applications, Self::Processes, Self::Performance, @@ -42,6 +43,7 @@ impl PageId { Self::Gpu, Self::Network, Self::Users, + Self::Services, ]; pub(crate) const COUNT: usize = Self::ALL.len(); @@ -145,6 +147,14 @@ pub(crate) const PAGE_DESCRIPTORS: [PageDescriptor; PageId::COUNT] = [ initial_focus: PageFocus::Control(IDC_USERLIST), minimum_size: MinimumSizePolicy::CompactWhenBorderless, }, + PageDescriptor { + id: PageId::Services, + title_key: TextKey::ServicesPageTitle, + dialog_id: IDD_SERVICESPAGE, + menu_id: IDR_MAINMENU_SERVICE, + initial_focus: PageFocus::Control(IDC_SERVICELIST), + minimum_size: MinimumSizePolicy::CompactWhenBorderless, + }, ]; #[cfg(test)] diff --git a/src/pages/mod.rs b/src/pages/mod.rs index b0b3ce5..2477730 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -16,4 +16,5 @@ pub(crate) mod gpu; pub(crate) mod network; pub(crate) mod performance; pub(crate) mod processes; +pub(crate) mod services; pub(crate) mod users; diff --git a/src/pages/services/mod.rs b/src/pages/services/mod.rs new file mode 100644 index 0000000..b38d1c3 --- /dev/null +++ b/src/pages/services/mod.rs @@ -0,0 +1,758 @@ +// +------------------------------------------------------------------------- +// +// taskmgr-rs - 服务页面 +// +// 文件: src/pages/services/mod.rs +// +// 日期: 2026年10月19日 +// 环境: Linux 6.12.94+ x86_64;Rust 1.99.1 +// 作者: OpenAI Codex +// -------------------------------------------------------------------------- + +mod model; +mod sampler; + +// 服务页列出本机 Win32 服务,并在后台 worker 上启动或停止服务。 +// UI 线程只提交请求、维护列表选择和绘制。刷新保留服务名对应的选择、焦点和滚动位置。 +// “转到进程”只使用带创建时间的 ProcIdentity;没有校验过的进程时该命令保持禁用。 + +use std::mem::zeroed; + +use windows_sys::Win32::Foundation::{ERROR_BROKEN_PIPE, HWND, LPARAM, RECT}; +use windows_sys::Win32::System::Diagnostics::Debug::MessageBeep; +use windows_sys::Win32::UI::Controls::{ + LVCF_FMT, LVCF_SUBITEM, LVCF_TEXT, LVCF_WIDTH, LVCFMT_LEFT, LVCOLUMNW, LVIF_STATE, LVIF_TEXT, + LVIS_FOCUSED, LVIS_SELECTED, LVITEMW, LVM_DELETECOLUMN, LVM_GETCOUNTPERPAGE, LVM_GETITEMCOUNT, + LVM_GETNEXTITEM, LVM_GETTOPINDEX, LVM_INSERTCOLUMNW, LVM_REDRAWITEMS, LVM_SETITEMCOUNT, + LVM_SETITEMSTATE, LVN_COLUMNCLICK, LVN_GETDISPINFOW, LVN_ITEMCHANGED, LVNI_SELECTED, + LVS_EX_FULLROWSELECT, LVS_SHOWSELALWAYS, LVSICF_NOINVALIDATEALL, LVSICF_NOSCROLL, NMHDR, + NMLISTVIEW, NMLVDISPINFOW, +}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + BeginDeferWindowPos, DeferWindowPos, EndDeferWindowPos, GWL_STYLE, GetClientRect, + GetDialogBaseUnits, GetDlgItem, GetWindowLongW, HMENU, MB_ICONERROR, MB_OK, MF_BYCOMMAND, + MF_ENABLED, MF_GRAYED, MessageBoxW, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, + SendMessageW, SetWindowLongW, TPM_RETURNCMD, TrackPopupMenuEx, WM_SETREDRAW, +}; + +use crate::config::options::Options; +use crate::infrastructure::diagnostics::Field; +use crate::infrastructure::native::{ + copy_text_to_callback_buffer, finish_list_view_update, loword, record_win32_error, + record_win32_error_with_fields, subclass_list_view, to_wide_null, window_rect_relative_to_page, +}; +use crate::infrastructure::worker::SingleFlightWorker; +use crate::system::process_identity::{FindProcPayload, ProcIdentity, find_proc_lparam}; +use crate::ui::localization::{TextKey, service_column_titles, text}; +use crate::ui::menus::build_popup_menu; +use crate::ui::resource_ids::{ + IDC_SERVICELIST, IDM_SERVICE_GOTOPROCESS, IDM_SERVICE_START, IDM_SERVICE_STOP, + IDR_SERVICE_CONTEXT, PWM_SERVICE_WORKER_COMPLETE, WM_FINDPROC, +}; + +pub(crate) use self::model::ServiceCommandState; +use self::model::{ + ServiceActionFailure, ServiceCommandInput, ServiceControlAction, ServiceRequest, + ServiceRunState, ServiceScrollPolicy, ServiceStartupType, merge_service_requests, + owning_process_label, refresh_scroll_policy, retained_service_name, +}; +use self::sampler::{ServiceCollection, ServiceWorkerCompletion, perform}; + +const DEFSPACING_BASE: i32 = 3; +const DLG_SCALE_X: i32 = 4; +const COLUMN_COUNT: usize = 5; + +struct ServiceRow { + service_name: String, + name_sort: String, + description: String, + description_sort: String, + status: ServiceRunState, + status_text: String, + status_sort: String, + startup: ServiceStartupType, + startup_text: String, + startup_sort: String, + process: Option, + process_text: String, + process_sort: String, +} + +pub struct ServicePageState { + hwnd: HWND, + main_hwnd: HWND, + no_title: bool, + rows: Vec, + selected_service: Option, + sort_column: usize, + sort_ascending: bool, + updating_list: bool, + worker: Option>, + last_refresh_error: Option, + last_row_errors: Vec, +} + +impl Default for ServicePageState { + fn default() -> Self { + Self { + hwnd: std::ptr::null_mut(), + main_hwnd: std::ptr::null_mut(), + no_title: false, + rows: Vec::new(), + selected_service: None, + sort_column: 0, + sort_ascending: true, + updating_list: false, + worker: None, + last_refresh_error: None, + last_row_errors: Vec::new(), + } + } +} + +impl ServicePageState { + pub fn new() -> Self { + Self::default() + } + + pub fn initialize(&mut self, hwnd: HWND, main_hwnd: HWND) -> Result<(), u32> { + self.hwnd = hwnd; + self.main_hwnd = main_hwnd; + self.start_worker()?; + // SAFETY: initialization runs on the UI thread against this page dialog and its list. + unsafe { + let list = self.list_hwnd(); + if list.is_null() { + return Err(windows_sys::Win32::Foundation::ERROR_INVALID_WINDOW_HANDLE); + } + subclass_list_view(list); + let extended = LVS_EX_FULLROWSELECT; + SendMessageW( + list, + windows_sys::Win32::UI::Controls::LVM_SETEXTENDEDLISTVIEWSTYLE, + extended as usize, + extended as isize, + ); + let style = GetWindowLongW(list, GWL_STYLE) as u32; + SetWindowLongW(list, GWL_STYLE, (style | LVS_SHOWSELALWAYS) as i32); + self.configure_columns(); + self.size_page(); + self.update_ui_state(); + } + Ok(()) + } + + pub fn apply_options(&mut self, options: &Options) { + self.no_title = options.no_title(); + } + + pub fn no_title(&self) -> bool { + self.no_title + } + + pub fn timer_event(&mut self) { + self.refresh(); + } + + pub fn destroy(&mut self) { + self.worker = None; + self.rows.clear(); + } + + pub fn command_state(&self) -> ServiceCommandState { + model::command_state(self.selected_input()) + } + + pub fn handle_command(&mut self, command_id: u16) -> bool { + match command_id { + IDM_SERVICE_START => { + self.request_control(ServiceControlAction::Start); + true + } + IDM_SERVICE_STOP => { + self.request_control(ServiceControlAction::Stop); + true + } + IDM_SERVICE_GOTOPROCESS => { + self.go_to_process(); + true + } + _ => false, + } + } + + /// Handles a notification for the services list. + /// + /// # Safety + /// + /// `lparam` must be the live `WM_NOTIFY` payload for this page. `LVN_GETDISPINFOW` requires + /// the text buffer in that payload to be writable for this synchronous call. + pub unsafe fn handle_notify(&mut self, lparam: LPARAM) -> isize { + unsafe { + let Some(header) = (lparam as *const NMHDR).as_ref() else { + return 0; + }; + if header.idFrom != IDC_SERVICELIST as usize || header.hwndFrom != self.list_hwnd() { + return 0; + } + match header.code { + code if code == LVN_GETDISPINFOW => { + let display = &mut *(lparam as *mut NMLVDISPINFOW); + self.fill_display_info(&mut display.item); + 1 + } + code if code == LVN_ITEMCHANGED => { + if !self.updating_list { + let notify = &*(lparam as *const NMLISTVIEW); + if (notify.uChanged & LVIF_STATE) != 0 { + self.selected_service = self.current_selected_service(); + self.update_ui_state(); + } + } + 1 + } + code if code == LVN_COLUMNCLICK => { + let notify = &*(lparam as *const NMLISTVIEW); + let column = usize::try_from(notify.iSubItem).unwrap_or(0); + if column >= COLUMN_COUNT { + return 1; + } + if self.sort_column == column { + self.sort_ascending = !self.sort_ascending; + } else { + self.sort_column = column; + self.sort_ascending = true; + } + self.sort_rows(); + self.rebuild_list(refresh_scroll_policy()); + 1 + } + _ => 0, + } + } + } + + pub fn show_context_menu(&mut self, x: i32, y: i32) { + if self.selected_service.is_none() { + return; + } + let popup = match build_popup_menu(IDR_SERVICE_CONTEXT) { + Ok(popup) => popup, + Err(error) => { + record_win32_error("service popup menu creation", error); + return; + } + }; + let state = self.command_state(); + // SAFETY: the popup is owned for this call and the page HWND is the menu owner. + unsafe { + enable_command(popup.as_raw(), IDM_SERVICE_START, state.start); + enable_command(popup.as_raw(), IDM_SERVICE_STOP, state.stop); + enable_command(popup.as_raw(), IDM_SERVICE_GOTOPROCESS, state.go_to_process); + let command = TrackPopupMenuEx( + popup.as_raw(), + TPM_RETURNCMD, + x, + y, + self.hwnd, + std::ptr::null_mut(), + ); + if command != 0 { + self.handle_command(command as u16); + } + } + } + + pub fn size_page(&self) { + // SAFETY: layout queries and moves child controls of this page HWND. + unsafe { + if self.hwnd.is_null() { + return; + } + let mut parent_rect = zeroed::(); + GetClientRect(self.hwnd, &mut parent_rect); + let units = GetDialogBaseUnits() as usize; + let def_spacing = (DEFSPACING_BASE * i32::from(loword(units))) / DLG_SCALE_X; + let mut hdwp = BeginDeferWindowPos(8); + if hdwp.is_null() { + return; + } + let anchor = GetDlgItem(self.hwnd, i32::from(IDM_SERVICE_GOTOPROCESS)); + let list = self.list_hwnd(); + if anchor.is_null() || list.is_null() { + EndDeferWindowPos(hdwp); + return; + } + let anchor_rect = window_rect_relative_to_page(anchor, self.hwnd); + let dx = (parent_rect.right - def_spacing * 2) - anchor_rect.right; + let dy = (parent_rect.bottom - def_spacing * 2) - anchor_rect.bottom; + let list_rect = window_rect_relative_to_page(list, self.hwnd); + hdwp = DeferWindowPos( + hdwp, + list, + std::ptr::null_mut(), + 0, + 0, + (anchor_rect.right - list_rect.left + dx).max(0), + (anchor_rect.top - list_rect.top + dy - def_spacing).max(0), + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + ); + for control_id in [ + i32::from(IDM_SERVICE_START), + i32::from(IDM_SERVICE_STOP), + i32::from(IDM_SERVICE_GOTOPROCESS), + ] { + let control = GetDlgItem(self.hwnd, control_id); + if control.is_null() { + continue; + } + let control_rect = window_rect_relative_to_page(control, self.hwnd); + hdwp = DeferWindowPos( + hdwp, + control, + std::ptr::null_mut(), + control_rect.left + dx, + control_rect.top + dy, + 0, + 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + ); + } + EndDeferWindowPos(hdwp); + } + } + + pub fn handle_worker_completion(&mut self) { + self.drain_worker(); + } + + fn start_worker(&mut self) -> Result<(), u32> { + if self.worker.is_some() { + return Ok(()); + } + self.worker = Some(SingleFlightWorker::spawn( + "taskmgr-rs-service-sampler", + PWM_SERVICE_WORKER_COMPLETE, + merge_service_requests, + perform, + )?); + Ok(()) + } + + fn refresh(&mut self) { + self.drain_worker(); + self.submit(ServiceRequest::Refresh); + } + + fn request_control(&mut self, action: ServiceControlAction) { + let state = self.command_state(); + let allowed = match action { + ServiceControlAction::Start => state.start, + ServiceControlAction::Stop => state.stop, + }; + let Some(service_name) = self.selected_service.clone() else { + self.beep(); + return; + }; + if !allowed { + self.beep(); + return; + } + self.submit(ServiceRequest::Control { + service_name, + action, + }); + } + + fn submit(&mut self, request: ServiceRequest) { + let action = match &request { + ServiceRequest::Control { action, .. } => Some(*action), + ServiceRequest::Refresh => None, + }; + let Some(worker) = self.worker.as_mut() else { + self.report_submit_failure(ERROR_BROKEN_PIPE, action); + return; + }; + if let Err(error) = worker.request(request, self.hwnd) { + self.report_submit_failure(error, action); + } + } + + fn report_submit_failure(&mut self, error: u32, action: Option) { + if let Some(action) = action { + self.show_action_failure(action, ServiceActionFailure::Api(error)); + } else { + self.set_refresh_error(error); + } + } + + fn drain_worker(&mut self) { + let drained = match self.worker.as_mut() { + Some(worker) => worker.drain(self.hwnd), + None => return, + }; + for completion in drained.completions { + crate::infrastructure::diagnostics::with_operation_id(completion.operation_id, || { + self.apply_completion(completion.value); + }); + } + if let Some(error) = drained.error { + self.set_refresh_error(error); + } + } + + fn apply_completion(&mut self, completion: ServiceWorkerCompletion) { + match completion { + ServiceWorkerCompletion::Snapshot(result) => match result { + Ok(collection) => self.apply_collection(collection), + Err(error) => self.set_refresh_error(error), + }, + ServiceWorkerCompletion::Action(report) => { + if let Err(failure) = report.outcome { + self.show_action_failure(report.action, failure); + } + self.submit(ServiceRequest::Refresh); + } + } + } + + fn apply_collection(&mut self, collection: ServiceCollection) { + self.last_refresh_error = None; + for error in &collection.row_errors { + if !self.last_row_errors.contains(error) { + record_win32_error_with_fields( + "service row metadata", + error.code, + &[ + Field::text("service", &error.service_name), + Field::text("field", error.field), + ], + ); + } + } + self.last_row_errors = collection.row_errors; + let previous = self.selected_service.clone(); + let pid_label = text(TextKey::ServiceProcessPid); + self.rows = collection + .services + .into_iter() + .map(|record| service_row(record, pid_label)) + .collect(); + self.sort_rows(); + self.selected_service = retained_service_name( + previous.as_deref(), + self.rows.iter().map(|row| row.service_name.as_str()), + ) + .map(str::to_string); + self.rebuild_list(refresh_scroll_policy()); + } + + fn sort_rows(&mut self) { + let column = self.sort_column; + let ascending = self.sort_ascending; + self.rows.sort_by(|left, right| { + let ordering = match column { + 1 => left.description_sort.cmp(&right.description_sort), + 2 => left.status_sort.cmp(&right.status_sort), + 3 => left.startup_sort.cmp(&right.startup_sort), + 4 => left.process_sort.cmp(&right.process_sort), + _ => left.name_sort.cmp(&right.name_sort), + }; + if ascending { + ordering + } else { + ordering.reverse() + } + }); + } + + fn rebuild_list(&mut self, scroll_policy: ServiceScrollPolicy) { + // SAFETY: the list belongs to this page. Item count uses LVSICF_NOSCROLL so a refresh does + // not move the viewport. Selection is restored without LVM_ENSUREVISIBLE. + let ServiceScrollPolicy::PreserveViewport = scroll_policy; + unsafe { + let list = self.list_hwnd(); + if list.is_null() { + return; + } + let selected_index = self.selected_service.as_deref().and_then(|name| { + self.rows + .iter() + .position(|row| row.service_name.eq_ignore_ascii_case(name)) + }); + let existing = SendMessageW(list, LVM_GETITEMCOUNT, 0, 0).max(0) as usize; + let count_changed = existing != self.rows.len(); + self.updating_list = true; + if count_changed { + SendMessageW(list, WM_SETREDRAW, 0, 0); + SendMessageW( + list, + LVM_SETITEMCOUNT, + self.rows.len(), + (LVSICF_NOINVALIDATEALL | LVSICF_NOSCROLL) as LPARAM, + ); + } + let mut item = LVITEMW { + stateMask: LVIS_SELECTED | LVIS_FOCUSED, + state: 0, + ..zeroed() + }; + SendMessageW( + list, + LVM_SETITEMSTATE, + usize::MAX, + &mut item as *mut _ as LPARAM, + ); + if let Some(index) = selected_index { + item.state = LVIS_SELECTED | LVIS_FOCUSED; + SendMessageW(list, LVM_SETITEMSTATE, index, &mut item as *mut _ as LPARAM); + } else { + self.selected_service = None; + } + redraw_visible(list, self.rows.len()); + if count_changed { + finish_list_view_update(list); + } + self.updating_list = false; + self.update_ui_state(); + } + } + + fn fill_display_info(&self, item: &mut LVITEMW) { + if (item.mask & LVIF_TEXT) == 0 || item.pszText.is_null() || item.cchTextMax <= 0 { + return; + } + let Some(row) = usize::try_from(item.iItem) + .ok() + .and_then(|index| self.rows.get(index)) + else { + // SAFETY: the caller provided a writable callback buffer of `cchTextMax` units. + unsafe { *item.pszText = 0 }; + return; + }; + let text = match item.iSubItem { + 1 => row.description.as_str(), + 2 => row.status_text.as_str(), + 3 => row.startup_text.as_str(), + 4 => row.process_text.as_str(), + _ => row.service_name.as_str(), + }; + // SAFETY: LVN_GETDISPINFOW guarantees `pszText` is writable for `cchTextMax` units. + unsafe { copy_text_to_callback_buffer(item.pszText, item.cchTextMax as usize, text) }; + } + + fn configure_columns(&self) { + // SAFETY: column setup targets this page's list on the UI thread. + unsafe { + let list = self.list_hwnd(); + if list.is_null() { + return; + } + while SendMessageW(list, LVM_DELETECOLUMN, 0, 0) != 0 {} + let titles = service_column_titles(); + let widths = [140, 220, 96, 110, 120]; + for (index, (title, width)) in titles.iter().zip(widths).enumerate() { + let mut title_wide = to_wide_null(title); + let mut column = LVCOLUMNW { + mask: LVCF_FMT | LVCF_TEXT | LVCF_WIDTH | LVCF_SUBITEM, + fmt: LVCFMT_LEFT, + cx: width, + pszText: title_wide.as_mut_ptr(), + cchTextMax: title_wide.len() as i32, + iSubItem: index as i32, + ..zeroed() + }; + SendMessageW( + list, + LVM_INSERTCOLUMNW, + index, + &mut column as *mut _ as isize, + ); + } + } + } + + fn current_selected_service(&self) -> Option { + // SAFETY: selection query targets this page's list. + unsafe { + let list = self.list_hwnd(); + if list.is_null() { + return None; + } + let index = SendMessageW(list, LVM_GETNEXTITEM, usize::MAX, LVNI_SELECTED as isize); + let index = usize::try_from(index).ok()?; + self.rows.get(index).map(|row| row.service_name.clone()) + } + } + + fn selected_input(&self) -> Option { + let name = self.selected_service.as_deref()?; + let row = self + .rows + .iter() + .find(|row| row.service_name.eq_ignore_ascii_case(name))?; + Some(ServiceCommandInput { + status: row.status, + startup: row.startup, + process: row.process, + }) + } + + fn update_ui_state(&self) { + let state = self.command_state(); + enable_button(self.hwnd, IDM_SERVICE_START, state.start); + enable_button(self.hwnd, IDM_SERVICE_STOP, state.stop); + enable_button(self.hwnd, IDM_SERVICE_GOTOPROCESS, state.go_to_process); + } + + fn go_to_process(&self) { + let state = self.command_state(); + let Some(identity) = self + .selected_input() + .and_then(|input| input.process.filter(|identity| identity.is_verified())) + else { + self.beep(); + return; + }; + if !state.go_to_process || self.main_hwnd.is_null() { + self.beep(); + return; + } + let payload = FindProcPayload { + pid: identity.pid, + creation_time_100ns: identity.creation_time_100ns, + }; + // SAFETY: the payload lives until the synchronous find-process message returns. The main + // window reads both fields through that pointer and does not keep it. + unsafe { + SendMessageW( + self.main_hwnd, + WM_FINDPROC, + identity.pid as usize, + find_proc_lparam(&payload), + ); + } + } + + fn show_action_failure(&self, action: ServiceControlAction, failure: ServiceActionFailure) { + let summary = match action { + ServiceControlAction::Start => text(TextKey::ServiceStartFailed), + ServiceControlAction::Stop => text(TextKey::ServiceStopFailed), + }; + let body = match failure { + ServiceActionFailure::Api(code) => { + format!("{summary}\n\n{} {code}", text(TextKey::Win32ErrorPrefix)) + } + ServiceActionFailure::StateUnchanged { .. } => { + format!("{summary}\n\n{}", text(TextKey::ServiceStateUnchanged)) + } + }; + // SAFETY: both strings are NUL-terminated and live for the modal message box. + unsafe { + let body_wide = to_wide_null(&body); + let title_wide = to_wide_null(text(TextKey::AppTitle)); + MessageBoxW( + self.hwnd, + body_wide.as_ptr(), + title_wide.as_ptr(), + MB_OK | MB_ICONERROR, + ); + } + } + + fn set_refresh_error(&mut self, error: u32) { + if self.last_refresh_error != Some(error) { + record_win32_error("service refresh", error); + } + self.last_refresh_error = Some(error); + } + + fn beep(&self) { + // SAFETY: MessageBeep has no pointer inputs. + unsafe { MessageBeep(0) }; + } + + fn list_hwnd(&self) -> HWND { + // SAFETY: this queries a child of the page dialog. Null means the control is absent. + unsafe { GetDlgItem(self.hwnd, IDC_SERVICELIST) } + } +} + +fn service_row(record: self::sampler::ServiceRecord, pid_label: &str) -> ServiceRow { + let status_text = status_text(record.status).to_string(); + let startup_text = startup_text(record.startup).to_string(); + let process_text = owning_process_label(record.process.as_ref(), pid_label); + let process = record + .process + .as_ref() + .and_then(|process| process.identity.is_verified().then_some(process.identity)); + ServiceRow { + name_sort: record.service_name.to_lowercase(), + service_name: record.service_name, + description_sort: record.description.to_lowercase(), + description: record.description, + status: record.status, + status_sort: status_text.to_lowercase(), + status_text, + startup: record.startup, + startup_sort: startup_text.to_lowercase(), + startup_text, + process_sort: process_text.to_lowercase(), + process_text, + process, + } +} + +fn status_text(status: ServiceRunState) -> &'static str { + match status { + ServiceRunState::Stopped => text(TextKey::ServiceStatusStopped), + ServiceRunState::StartPending => text(TextKey::ServiceStatusStartPending), + ServiceRunState::StopPending => text(TextKey::ServiceStatusStopPending), + ServiceRunState::Running => text(TextKey::Running), + ServiceRunState::ContinuePending => text(TextKey::ServiceStatusContinuePending), + ServiceRunState::PausePending => text(TextKey::ServiceStatusPausePending), + ServiceRunState::Paused => text(TextKey::ServiceStatusPaused), + ServiceRunState::Unknown(_) => text(TextKey::Unknown), + } +} + +fn startup_text(startup: ServiceStartupType) -> &'static str { + match startup { + ServiceStartupType::Boot => text(TextKey::ServiceStartupBoot), + ServiceStartupType::System => text(TextKey::ServiceStartupSystem), + ServiceStartupType::Automatic => text(TextKey::ServiceStartupAutomatic), + ServiceStartupType::Manual => text(TextKey::ServiceStartupManual), + ServiceStartupType::Disabled => text(TextKey::ServiceStartupDisabled), + ServiceStartupType::Unknown(_) => text(TextKey::Unknown), + } +} + +fn enable_button(hwnd: HWND, command_id: u16, enabled: bool) { + let control = unsafe { GetDlgItem(hwnd, i32::from(command_id)) }; + if !control.is_null() { + unsafe { EnableWindow(control, i32::from(enabled)) }; + } +} + +fn enable_command(menu: HMENU, command_id: u16, enabled: bool) { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::EnableMenuItem( + menu, + u32::from(command_id), + MF_BYCOMMAND | if enabled { MF_ENABLED } else { MF_GRAYED }, + ); + } +} + +fn redraw_visible(list: HWND, count: usize) { + if count == 0 { + return; + } + let top = unsafe { SendMessageW(list, LVM_GETTOPINDEX, 0, 0).max(0) as usize }; + let visible = unsafe { SendMessageW(list, LVM_GETCOUNTPERPAGE, 0, 0).max(0) as usize }; + let end = top.saturating_add(visible).saturating_add(1).min(count); + if top < end { + unsafe { SendMessageW(list, LVM_REDRAWITEMS, top, (end - 1) as LPARAM) }; + } +} diff --git a/src/pages/services/model.rs b/src/pages/services/model.rs new file mode 100644 index 0000000..205c575 --- /dev/null +++ b/src/pages/services/model.rs @@ -0,0 +1,430 @@ +// +------------------------------------------------------------------------- +// +// taskmgr-rs - 服务页纯模型 +// +// 文件: src/pages/services/model.rs +// +// 日期: 2026年10月19日 +// 环境: Linux 6.12.94+ x86_64;Rust 1.99.1 +// 作者: OpenAI Codex +// -------------------------------------------------------------------------- + +//! Service-page decisions that do not touch the Service Control Manager. +//! +//! Startup and status values use the documented SCM constants. A process is actionable only when +//! its `ProcIdentity` carries a non-zero creation time. PID-only values stay invisible and cannot +//! enable Go to Process. + +use crate::system::process_identity::ProcIdentity; + +pub(crate) const SERVICE_STOPPED: u32 = 1; +pub(crate) const SERVICE_START_PENDING: u32 = 2; +pub(crate) const SERVICE_STOP_PENDING: u32 = 3; +pub(crate) const SERVICE_RUNNING: u32 = 4; +pub(crate) const SERVICE_CONTINUE_PENDING: u32 = 5; +pub(crate) const SERVICE_PAUSE_PENDING: u32 = 6; +pub(crate) const SERVICE_PAUSED: u32 = 7; + +pub(crate) const SERVICE_BOOT_START: u32 = 0; +pub(crate) const SERVICE_SYSTEM_START: u32 = 1; +pub(crate) const SERVICE_AUTO_START: u32 = 2; +pub(crate) const SERVICE_DEMAND_START: u32 = 3; +pub(crate) const SERVICE_DISABLED: u32 = 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServiceRunState { + Stopped, + StartPending, + StopPending, + Running, + ContinuePending, + PausePending, + Paused, + Unknown(u32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServiceStartupType { + Boot, + System, + Automatic, + Manual, + Disabled, + Unknown(u32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServiceControlAction { + Start, + Stop, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ServiceRequest { + Refresh, + Control { + service_name: String, + action: ServiceControlAction, + }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ServiceCommandState { + pub(crate) start: bool, + pub(crate) stop: bool, + pub(crate) go_to_process: bool, +} + +impl ServiceCommandState { + pub(crate) const fn disabled() -> Self { + Self { + start: false, + stop: false, + go_to_process: false, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ServiceCommandInput { + pub(crate) status: ServiceRunState, + pub(crate) startup: ServiceStartupType, + pub(crate) process: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OwningProcess { + pub(crate) identity: ProcIdentity, + pub(crate) image_name: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServiceScrollPolicy { + PreserveViewport, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ServiceActionFailure { + Api(u32), + StateUnchanged { observed: ServiceRunState }, +} + +pub(crate) fn state_from_scm(value: u32) -> ServiceRunState { + match value { + SERVICE_STOPPED => ServiceRunState::Stopped, + SERVICE_START_PENDING => ServiceRunState::StartPending, + SERVICE_STOP_PENDING => ServiceRunState::StopPending, + SERVICE_RUNNING => ServiceRunState::Running, + SERVICE_CONTINUE_PENDING => ServiceRunState::ContinuePending, + SERVICE_PAUSE_PENDING => ServiceRunState::PausePending, + SERVICE_PAUSED => ServiceRunState::Paused, + other => ServiceRunState::Unknown(other), + } +} + +pub(crate) fn startup_from_scm(value: u32) -> ServiceStartupType { + match value { + SERVICE_BOOT_START => ServiceStartupType::Boot, + SERVICE_SYSTEM_START => ServiceStartupType::System, + SERVICE_AUTO_START => ServiceStartupType::Automatic, + SERVICE_DEMAND_START => ServiceStartupType::Manual, + SERVICE_DISABLED => ServiceStartupType::Disabled, + other => ServiceStartupType::Unknown(other), + } +} + +pub(crate) fn command_state(selected: Option) -> ServiceCommandState { + let Some(selected) = selected else { + return ServiceCommandState::disabled(); + }; + ServiceCommandState { + start: selected.status == ServiceRunState::Stopped + && selected.startup != ServiceStartupType::Disabled, + stop: matches!( + selected.status, + ServiceRunState::Running | ServiceRunState::Paused + ), + go_to_process: selected.process.is_some_and(ProcIdentity::is_verified), + } +} + +pub(crate) fn control_reached_requested_state( + action: ServiceControlAction, + state: ServiceRunState, +) -> bool { + match action { + ServiceControlAction::Start => matches!( + state, + ServiceRunState::Running | ServiceRunState::StartPending + ), + ServiceControlAction::Stop => matches!( + state, + ServiceRunState::Stopped | ServiceRunState::StopPending + ), + } +} + +pub(crate) fn classify_control_result( + action: ServiceControlAction, + api_result: Result, +) -> Result { + match api_result { + Err(code) => Err(ServiceActionFailure::Api(code)), + Ok(state) if control_reached_requested_state(action, state) => Ok(state), + Ok(state) => Err(ServiceActionFailure::StateUnchanged { observed: state }), + } +} + +/// Keeps a pending start or stop ahead of a coalesced refresh. +pub(crate) fn merge_service_requests(pending: &mut ServiceRequest, incoming: ServiceRequest) { + match (&*pending, &incoming) { + (ServiceRequest::Control { .. }, ServiceRequest::Refresh) => {} + _ => *pending = incoming, + } +} + +pub(crate) const fn refresh_scroll_policy() -> ServiceScrollPolicy { + ServiceScrollPolicy::PreserveViewport +} + +pub(crate) fn retained_service_name<'a>( + previous: Option<&str>, + names: impl IntoIterator, +) -> Option<&'a str> { + let previous = previous?; + if previous.is_empty() { + return None; + } + names + .into_iter() + .find(|name| name.eq_ignore_ascii_case(previous)) +} + +pub(crate) fn file_name_from_win32_path(path: &str) -> &str { + path.rsplit(['\\', '/']) + .find(|part| !part.is_empty()) + .unwrap_or(path) +} + +pub(crate) fn owning_process_label(process: Option<&OwningProcess>, pid_label: &str) -> String { + let Some(process) = process else { + return String::new(); + }; + if !process.identity.is_verified() { + return String::new(); + } + if let Some(name) = process + .image_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + { + return file_name_from_win32_path(name).to_string(); + } + format!("{pid_label} {}", process.identity.pid) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input( + status: ServiceRunState, + startup: ServiceStartupType, + process: Option, + ) -> ServiceCommandInput { + ServiceCommandInput { + status, + startup, + process, + } + } + + #[test] + fn documented_scm_values_map_without_collapsing_unknowns() { + assert_eq!(state_from_scm(SERVICE_STOPPED), ServiceRunState::Stopped); + assert_eq!(state_from_scm(SERVICE_RUNNING), ServiceRunState::Running); + assert_eq!(state_from_scm(SERVICE_PAUSED), ServiceRunState::Paused); + assert_eq!(state_from_scm(0), ServiceRunState::Unknown(0)); + assert_eq!(state_from_scm(99), ServiceRunState::Unknown(99)); + + assert_eq!( + startup_from_scm(SERVICE_BOOT_START), + ServiceStartupType::Boot + ); + assert_eq!( + startup_from_scm(SERVICE_DEMAND_START), + ServiceStartupType::Manual + ); + assert_eq!( + startup_from_scm(SERVICE_DISABLED), + ServiceStartupType::Disabled + ); + assert_eq!(startup_from_scm(9), ServiceStartupType::Unknown(9)); + } + + #[test] + fn commands_require_a_real_selection_and_a_verified_process() { + assert_eq!(command_state(None), ServiceCommandState::disabled()); + + let stopped = command_state(Some(input( + ServiceRunState::Stopped, + ServiceStartupType::Manual, + None, + ))); + assert!(stopped.start); + assert!(!stopped.stop); + assert!(!stopped.go_to_process); + + let disabled = command_state(Some(input( + ServiceRunState::Stopped, + ServiceStartupType::Disabled, + None, + ))); + assert!(!disabled.start); + + let running = command_state(Some(input( + ServiceRunState::Running, + ServiceStartupType::Automatic, + Some(ProcIdentity::pid_only(44)), + ))); + assert!(!running.start); + assert!(running.stop); + assert!(!running.go_to_process); + + let verified = command_state(Some(input( + ServiceRunState::Paused, + ServiceStartupType::Automatic, + Some(ProcIdentity::new(44, 100)), + ))); + assert!(verified.stop); + assert!(verified.go_to_process); + + let pending = command_state(Some(input( + ServiceRunState::StartPending, + ServiceStartupType::Automatic, + Some(ProcIdentity::new(44, 100)), + ))); + assert!(!pending.start); + assert!(!pending.stop); + assert!(pending.go_to_process); + } + + #[test] + fn control_success_requires_the_requested_state() { + assert!(control_reached_requested_state( + ServiceControlAction::Start, + ServiceRunState::StartPending + )); + assert!(control_reached_requested_state( + ServiceControlAction::Start, + ServiceRunState::Running + )); + assert!(!control_reached_requested_state( + ServiceControlAction::Start, + ServiceRunState::Stopped + )); + assert!(control_reached_requested_state( + ServiceControlAction::Stop, + ServiceRunState::StopPending + )); + assert!(!control_reached_requested_state( + ServiceControlAction::Stop, + ServiceRunState::Running + )); + + assert_eq!( + classify_control_result(ServiceControlAction::Start, Err(5)), + Err(ServiceActionFailure::Api(5)) + ); + assert_eq!( + classify_control_result(ServiceControlAction::Start, Ok(ServiceRunState::Stopped)), + Err(ServiceActionFailure::StateUnchanged { + observed: ServiceRunState::Stopped + }) + ); + assert_eq!( + classify_control_result(ServiceControlAction::Stop, Ok(ServiceRunState::Stopped)), + Ok(ServiceRunState::Stopped) + ); + } + + #[test] + fn refresh_does_not_replace_a_pending_control() { + let mut pending = ServiceRequest::Control { + service_name: "wuauserv".to_string(), + action: ServiceControlAction::Start, + }; + merge_service_requests(&mut pending, ServiceRequest::Refresh); + assert_eq!( + pending, + ServiceRequest::Control { + service_name: "wuauserv".to_string(), + action: ServiceControlAction::Start, + } + ); + + merge_service_requests( + &mut pending, + ServiceRequest::Control { + service_name: "wuauserv".to_string(), + action: ServiceControlAction::Stop, + }, + ); + assert_eq!( + pending, + ServiceRequest::Control { + service_name: "wuauserv".to_string(), + action: ServiceControlAction::Stop, + } + ); + + let mut refresh = ServiceRequest::Refresh; + merge_service_requests( + &mut refresh, + ServiceRequest::Control { + service_name: "eventlog".to_string(), + action: ServiceControlAction::Start, + }, + ); + assert!(matches!(refresh, ServiceRequest::Control { .. })); + } + + #[test] + fn selection_and_scroll_survive_a_refresh() { + assert_eq!( + refresh_scroll_policy(), + ServiceScrollPolicy::PreserveViewport + ); + let names = ["EventLog", "wuauserv"]; + assert_eq!( + retained_service_name(Some("WUAUSERV"), names), + Some("wuauserv") + ); + assert_eq!(retained_service_name(Some("gone"), names), None); + assert_eq!(retained_service_name(None, names), None); + assert_eq!(retained_service_name(Some(""), names), None); + } + + #[test] + fn owning_process_text_hides_unverified_identities() { + assert_eq!(owning_process_label(None, "PID"), ""); + let pid_only = OwningProcess { + identity: ProcIdentity::pid_only(90), + image_name: Some(r"C:\Windows\System32\svchost.exe".to_string()), + }; + assert_eq!(owning_process_label(Some(&pid_only), "PID"), ""); + + let named = OwningProcess { + identity: ProcIdentity::new(90, 7), + image_name: Some(r"C:\Windows\System32\svchost.exe".to_string()), + }; + assert_eq!(owning_process_label(Some(&named), "PID"), "svchost.exe"); + + let unnamed = OwningProcess { + identity: ProcIdentity::new(90, 7), + image_name: None, + }; + assert_eq!(owning_process_label(Some(&unnamed), "PID"), "PID 90"); + } +} diff --git a/src/pages/services/sampler.rs b/src/pages/services/sampler.rs new file mode 100644 index 0000000..677b60c --- /dev/null +++ b/src/pages/services/sampler.rs @@ -0,0 +1,533 @@ +// +------------------------------------------------------------------------- +// +// taskmgr-rs - 服务控制管理器采样 +// +// 文件: src/pages/services/sampler.rs +// +// 日期: 2026年10月19日 +// 环境: Linux 6.12.94+ x86_64;Rust 1.99.1 +// 作者: OpenAI Codex +// -------------------------------------------------------------------------- + +//! Service Control Manager queries and start/stop calls. +//! +//! The page worker owns every SCM handle. Enumeration asks only for connect and enumerate rights. +//! Each service is opened with query rights, and a start or stop opens that one service with only +//! the right the action needs plus status query so the result can be checked. + +use std::collections::HashMap; +use std::mem::{size_of, zeroed}; + +use windows_sys::Win32::Foundation::{ + ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, + GetLastError, +}; +use windows_sys::Win32::System::Services::{ + CloseServiceHandle, ControlService, ENUM_SERVICE_STATUS_PROCESSW, EnumServicesStatusExW, + OpenSCManagerW, OpenServiceW, QueryServiceConfig2W, QueryServiceConfigW, QueryServiceStatusEx, + SC_ENUM_PROCESS_INFO, SC_MANAGER_CONNECT, SC_MANAGER_ENUMERATE_SERVICE, SC_STATUS_PROCESS_INFO, + SERVICE_CONFIG_DESCRIPTION, SERVICE_CONTROL_STOP, SERVICE_DESCRIPTIONW, SERVICE_QUERY_CONFIG, + SERVICE_QUERY_STATUS, SERVICE_START, SERVICE_STATE_ALL, SERVICE_STATUS, SERVICE_STATUS_PROCESS, + SERVICE_STOP, SERVICE_WIN32, StartServiceW, +}; +use windows_sys::Win32::System::Threading::{ + PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; + +use crate::infrastructure::native::to_wide_null; +use crate::system::process_identity::{ + ProcIdentity, open_process_for_identity, query_process_identity_for_pid, +}; + +use super::model::{ + OwningProcess, ServiceActionFailure, ServiceControlAction, ServiceRequest, ServiceRunState, + ServiceStartupType, classify_control_result, file_name_from_win32_path, startup_from_scm, + state_from_scm, +}; + +const ENUM_ATTEMPT_LIMIT: usize = 10_000; +const CONFIG_GROW_LIMIT: usize = 8; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ServiceRecord { + pub(super) service_name: String, + pub(super) description: String, + pub(super) status: ServiceRunState, + pub(super) startup: ServiceStartupType, + pub(super) process: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ServiceRowError { + pub(super) service_name: String, + pub(super) field: &'static str, + pub(super) code: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ServiceCollection { + pub(super) services: Vec, + pub(super) row_errors: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ServiceActionReport { + pub(super) service_name: String, + pub(super) action: ServiceControlAction, + pub(super) outcome: Result, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ServiceWorkerCompletion { + Snapshot(Result), + Action(ServiceActionReport), +} + +struct ScHandle { + handle: windows_sys::Win32::System::Services::SC_HANDLE, +} + +impl ScHandle { + fn new(handle: windows_sys::Win32::System::Services::SC_HANDLE) -> Result { + if handle.is_null() { + Err(last_error()) + } else { + Ok(Self { handle }) + } + } + + fn as_raw(&self) -> windows_sys::Win32::System::Services::SC_HANDLE { + self.handle + } +} + +impl Drop for ScHandle { + fn drop(&mut self) { + if !self.handle.is_null() { + // SAFETY: the handle was returned by OpenSCManagerW or OpenServiceW and has not been + // closed. CloseServiceHandle is the documented release for both. + unsafe { + CloseServiceHandle(self.handle); + } + self.handle = std::ptr::null_mut(); + } + } +} + +pub(super) fn perform(request: ServiceRequest) -> ServiceWorkerCompletion { + match request { + ServiceRequest::Refresh => ServiceWorkerCompletion::Snapshot(collect_services()), + ServiceRequest::Control { + service_name, + action, + } => ServiceWorkerCompletion::Action(control_service(&service_name, action)), + } +} + +fn collect_services() -> Result { + // SAFETY: null machine and database select the local active database. The access mask is + // connect plus enumerate, which is enough for EnumServicesStatusExW. + let manager = ScHandle::new(unsafe { + OpenSCManagerW( + std::ptr::null(), + std::ptr::null(), + SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE, + ) + })?; + + let enumerated = enumerate_win32_services(manager.as_raw())?; + let mut services = Vec::with_capacity(enumerated.len()); + let mut row_errors = Vec::new(); + let mut processes = HashMap::>::new(); + + for entry in enumerated { + if entry.service_name.is_empty() { + row_errors.push(ServiceRowError { + service_name: String::new(), + field: "name", + code: ERROR_INVALID_PARAMETER, + }); + continue; + } + + let (startup, startup_error) = + match query_startup_type(manager.as_raw(), &entry.service_name) { + Ok(startup) => (startup, None), + Err(code) => (ServiceStartupType::Unknown(0), Some(code)), + }; + if let Some(code) = startup_error { + row_errors.push(ServiceRowError { + service_name: entry.service_name.clone(), + field: "startup", + code, + }); + } + + let (description, description_error) = + match query_description(manager.as_raw(), &entry.service_name) { + Ok(description) => (description, None), + Err(code) => (String::new(), Some(code)), + }; + if let Some(code) = description_error { + row_errors.push(ServiceRowError { + service_name: entry.service_name.clone(), + field: "description", + code, + }); + } + + let process = resolve_process( + entry.process_id, + &mut processes, + &mut row_errors, + &entry.service_name, + ); + + services.push(ServiceRecord { + service_name: entry.service_name, + description, + status: entry.status, + startup, + process, + }); + } + + Ok(ServiceCollection { + services, + row_errors, + }) +} + +struct EnumeratedService { + service_name: String, + status: ServiceRunState, + process_id: u32, +} + +fn enumerate_win32_services( + manager: windows_sys::Win32::System::Services::SC_HANDLE, +) -> Result, u32> { + let mut services = Vec::new(); + let mut resume = 0u32; + let mut buffer = vec![0u64; 8 * 1024]; + + for _ in 0..ENUM_ATTEMPT_LIMIT { + let mut bytes_needed = 0u32; + let mut returned = 0u32; + let buffer_bytes = + u32::try_from(buffer.len().saturating_mul(size_of::())).unwrap_or(u32::MAX); + // SAFETY: `manager` is a live SCM handle. `buffer` is u64-aligned and writable for + // `buffer_bytes`. The API writes `returned` records at the front and points their wide + // strings into this same buffer until the next call. + let success = unsafe { + EnumServicesStatusExW( + manager, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + buffer.as_mut_ptr().cast(), + buffer_bytes, + &mut bytes_needed, + &mut returned, + &mut resume, + std::ptr::null(), + ) + }; + let error = if success == 0 { last_error() } else { 0 }; + if success == 0 && error != ERROR_MORE_DATA { + return Err(error); + } + + let count = usize::try_from(returned).map_err(|_| ERROR_INVALID_PARAMETER)?; + if count > 0 { + let bytes_available = buffer.len().saturating_mul(size_of::()); + let needed_bytes = count.saturating_mul(size_of::()); + if needed_bytes > bytes_available { + return Err(ERROR_INVALID_PARAMETER); + } + // SAFETY: EnumServicesStatusExW reported `count` records in this aligned buffer, and + // the size check above keeps the slice inside that allocation. Strings are copied + // before the buffer is reused. + let records = unsafe { + std::slice::from_raw_parts( + buffer.as_ptr().cast::(), + count, + ) + }; + for record in records { + // SAFETY: each name pointer is either null or a NUL-terminated wide string inside + // the live enumeration buffer. + let service_name = unsafe { + crate::infrastructure::native::widestr_ptr_to_string(record.lpServiceName) + }; + services.push(EnumeratedService { + service_name, + status: state_from_scm(record.ServiceStatusProcess.dwCurrentState), + process_id: record.ServiceStatusProcess.dwProcessId, + }); + } + } + + if success != 0 { + return Ok(services); + } + if count == 0 { + let next_bytes = usize::try_from(bytes_needed).unwrap_or(usize::MAX); + let next_words = next_bytes.div_ceil(size_of::()).saturating_add(1); + if next_words <= buffer.len() { + return Err(ERROR_INVALID_PARAMETER); + } + buffer.resize(next_words, 0); + } + } + + Err(ERROR_MORE_DATA) +} + +fn query_startup_type( + manager: windows_sys::Win32::System::Services::SC_HANDLE, + service_name: &str, +) -> Result { + let service = open_service(manager, service_name, SERVICE_QUERY_CONFIG)?; + let buffer = query_service_blob(|ptr, bytes, needed| { + // SAFETY: `service` is open for SERVICE_QUERY_CONFIG. The callback buffer is writable for + // `bytes` and lives until this call returns. + unsafe { QueryServiceConfigW(service.as_raw(), ptr.cast(), bytes, needed) } + })?; + if buffer.len().saturating_mul(size_of::()) + < size_of::() + { + return Err(ERROR_INVALID_PARAMETER); + } + // SAFETY: a successful QueryServiceConfigW writes a QUERY_SERVICE_CONFIGW at the start of the + // buffer. Only the start-type integer is read; embedded string pointers are not dereferenced. + let start_type = unsafe { + (*buffer + .as_ptr() + .cast::()) + .dwStartType + }; + Ok(startup_from_scm(start_type)) +} + +fn query_description( + manager: windows_sys::Win32::System::Services::SC_HANDLE, + service_name: &str, +) -> Result { + let service = open_service(manager, service_name, SERVICE_QUERY_CONFIG)?; + let buffer = query_service_blob(|ptr, bytes, needed| { + // SAFETY: same handle and buffer contract as the config query, at description level. + unsafe { + QueryServiceConfig2W( + service.as_raw(), + SERVICE_CONFIG_DESCRIPTION, + ptr, + bytes, + needed, + ) + } + })?; + if buffer.len().saturating_mul(size_of::()) < size_of::() { + return Err(ERROR_INVALID_PARAMETER); + } + // SAFETY: a successful description query writes SERVICE_DESCRIPTIONW at the front of `buffer`. + // Its string pointer, when non-null, addresses memory inside this same allocation. The string + // is copied before `buffer` is dropped. + let description = unsafe { + let header = &*buffer.as_ptr().cast::(); + crate::infrastructure::native::widestr_ptr_to_string(header.lpDescription) + }; + Ok(description) +} + +fn query_service_blob( + mut query: impl FnMut(*mut u8, u32, *mut u32) -> i32, +) -> Result, u32> { + let mut words = 64usize; + for _ in 0..CONFIG_GROW_LIMIT { + let mut buffer = vec![0u64; words]; + let mut bytes_needed = 0u32; + let bytes = + u32::try_from(buffer.len().saturating_mul(size_of::())).unwrap_or(u32::MAX); + let success = query(buffer.as_mut_ptr().cast(), bytes, &mut bytes_needed); + if success != 0 { + // Keep the original aligned allocation. SCM string pointers address this buffer, so a + // byte copy would leave those pointers aimed at freed memory. + return Ok(buffer); + } + let error = last_error(); + if error != ERROR_INSUFFICIENT_BUFFER { + return Err(error); + } + let needed = usize::try_from(bytes_needed).unwrap_or(usize::MAX); + let next_words = needed.div_ceil(size_of::()).saturating_add(1); + if next_words <= words { + return Err(ERROR_INVALID_PARAMETER); + } + words = next_words; + } + Err(ERROR_INSUFFICIENT_BUFFER) +} + +struct ProcessLookup { + process: OwningProcess, + name_error: Option, +} + +fn resolve_process( + process_id: u32, + cache: &mut HashMap>, + row_errors: &mut Vec, + service_name: &str, +) -> Option { + if process_id == 0 { + return None; + } + let first_lookup = !cache.contains_key(&process_id); + if first_lookup { + cache.insert(process_id, query_owning_process(process_id)); + } + match cache.get(&process_id) { + Some(Ok(lookup)) => { + if first_lookup && let Some(code) = lookup.name_error { + row_errors.push(ServiceRowError { + service_name: service_name.to_string(), + field: "process-name", + code, + }); + } + Some(lookup.process.clone()) + } + Some(Err(code)) => { + row_errors.push(ServiceRowError { + service_name: service_name.to_string(), + field: "process", + code: *code, + }); + None + } + None => None, + } +} + +fn query_owning_process(process_id: u32) -> Result { + let identity = query_process_identity_for_pid(process_id)?; + if !identity.is_verified() { + return Err(ERROR_INVALID_PARAMETER); + } + let (image_name, name_error) = match query_process_file_name(identity) { + Ok(name) => (Some(name), None), + Err(code) => (None, Some(code)), + }; + Ok(ProcessLookup { + process: OwningProcess { + identity, + image_name, + }, + name_error, + }) +} + +fn query_process_file_name(identity: ProcIdentity) -> Result { + let handle = open_process_for_identity(identity, PROCESS_QUERY_LIMITED_INFORMATION)?; + let mut capacity = 32_768u32; + let mut buffer = vec![0u16; capacity as usize]; + // SAFETY: the identity-checked handle is live. `buffer` has `capacity` writable UTF-16 units + // and the API stores the initialized length back into `capacity`. + let success = unsafe { + QueryFullProcessImageNameW(handle.as_raw(), 0, buffer.as_mut_ptr(), &mut capacity) + }; + if success == 0 { + return Err(last_error()); + } + let length = usize::try_from(capacity).unwrap_or(0).min(buffer.len()); + let path = String::from_utf16_lossy(&buffer[..length]); + Ok(file_name_from_win32_path(&path).to_string()) +} + +fn control_service(service_name: &str, action: ServiceControlAction) -> ServiceActionReport { + let outcome = if service_name.is_empty() { + Err(ServiceActionFailure::Api(ERROR_INVALID_PARAMETER)) + } else { + classify_control_result(action, control_service_and_read_state(service_name, action)) + }; + ServiceActionReport { + service_name: service_name.to_string(), + action, + outcome, + } +} + +fn control_service_and_read_state( + service_name: &str, + action: ServiceControlAction, +) -> Result { + // SAFETY: null names select the local SCM. Connect is the only manager right required to open + // one service; the service handle itself carries the action right. + let manager = ScHandle::new(unsafe { + OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT) + })?; + let access = match action { + ServiceControlAction::Start => SERVICE_START | SERVICE_QUERY_STATUS, + ServiceControlAction::Stop => SERVICE_STOP | SERVICE_QUERY_STATUS, + }; + let service = open_service(manager.as_raw(), service_name, access)?; + match action { + ServiceControlAction::Start => { + // SAFETY: the service is open for SERVICE_START. No start arguments are supplied. + let success = unsafe { StartServiceW(service.as_raw(), 0, std::ptr::null()) }; + if success == 0 { + return Err(last_error()); + } + } + ServiceControlAction::Stop => { + let mut status = unsafe { zeroed::() }; + // SAFETY: the service is open for SERVICE_STOP. `status` is a writable output of the + // control call and is not used as proof of the final state. + let success = + unsafe { ControlService(service.as_raw(), SERVICE_CONTROL_STOP, &mut status) }; + if success == 0 { + return Err(last_error()); + } + } + } + query_service_state(service.as_raw()) +} + +fn query_service_state( + service: windows_sys::Win32::System::Services::SC_HANDLE, +) -> Result { + let mut status = unsafe { zeroed::() }; + let mut bytes_needed = 0u32; + // SAFETY: the service is open for SERVICE_QUERY_STATUS. The status object is writable and + // sized for SC_STATUS_PROCESS_INFO. + let success = unsafe { + QueryServiceStatusEx( + service, + SC_STATUS_PROCESS_INFO, + (&mut status as *mut SERVICE_STATUS_PROCESS).cast(), + u32::try_from(size_of::()).unwrap_or(u32::MAX), + &mut bytes_needed, + ) + }; + if success == 0 { + return Err(last_error()); + } + Ok(state_from_scm(status.dwCurrentState)) +} + +fn open_service( + manager: windows_sys::Win32::System::Services::SC_HANDLE, + service_name: &str, + access: u32, +) -> Result { + let name = to_wide_null(service_name); + // SAFETY: `manager` is live and `name` is NUL-terminated for this synchronous call. + ScHandle::new(unsafe { OpenServiceW(manager, name.as_ptr(), access) }) +} + +fn last_error() -> u32 { + // SAFETY: GetLastError is read immediately after the failing API. + let error = unsafe { GetLastError() }; + if error == 0 { ERROR_GEN_FAILURE } else { error } +} diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 567f487..397a290 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -1023,6 +1023,50 @@ fn build_affinity_dialog() -> DialogSpec<'static> { } } +fn build_services_dialog() -> DialogSpec<'static> { + DialogSpec { + style: DS_3DLOOK | DS_CONTROL | WS_CHILD, + ex_style: 0, + x: 0, + y: 0, + cx: 393, + cy: 197, + title: "", + font_name: DIALOG_FONT_NAME, + font_size: DIALOG_FONT_SIZE, + controls: vec![ + ControlSpec { + class_name: "SysListView32", + text: "List4", + id: IDC_SERVICELIST as u16, + style: WS_CHILD + | WS_VISIBLE + | WS_BORDER + | WS_TABSTOP + | LVS_REPORT + | LVS_SINGLESEL + | LVS_OWNERDATA, + ex_style: 0, + x: 9, + y: 9, + cx: 376, + cy: 131, + }, + button("&Start", IDM_SERVICE_START as i32, 0, 214, 144, 50, 14), + button("&Stop", IDM_SERVICE_STOP as i32, 0, 268, 144, 50, 14), + button( + "&Go To Process", + IDM_SERVICE_GOTOPROCESS as i32, + BS_DEFPUSHBUTTON_STYLE, + 322, + 144, + 64, + 14, + ), + ], + } +} + fn build_users_dialog() -> DialogSpec<'static> { DialogSpec { style: DS_3DLOOK | DS_CONTROL | WS_CHILD, @@ -1236,6 +1280,7 @@ fn dialog_spec(dialog_id: u16) -> Option> { IDD_SELECTPROCCOLS => build_select_columns_dialog(), IDD_AFFINITY => build_affinity_dialog(), IDD_USERSPAGE => build_users_dialog(), + IDD_SERVICESPAGE => build_services_dialog(), IDD_MESSAGE => build_message_dialog(), IDD_DIAGNOSTICS => build_diagnostics_dialog(), _ => return None, @@ -1313,9 +1358,10 @@ 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_TASKLIST, IDD_AFFINITY, - IDD_CPUPAGE, IDD_DIAGNOSTICS, IDD_GPUPAGE, IDD_MAINWND, IDD_MESSAGE, IDD_NETPAGE, - IDD_PERFPAGE, IDD_PROCPAGE, IDD_SELECTPROCCOLS, IDD_TASKPAGE, IDD_USERSPAGE, + 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, }; use windows_sys::Win32::UI::Controls::LVS_OWNERDATA; @@ -1332,6 +1378,7 @@ mod tests { IDD_SELECTPROCCOLS, IDD_AFFINITY, IDD_USERSPAGE, + IDD_SERVICESPAGE, IDD_MESSAGE, IDD_DIAGNOSTICS, ] { @@ -1345,11 +1392,12 @@ mod tests { } #[test] - fn only_the_process_list_uses_owner_data_storage() { + fn owner_data_lists_are_the_process_and_service_pages() { let cases = [ (IDD_PROCPAGE, IDC_PROCLIST, true), (IDD_TASKPAGE, IDC_TASKLIST, false), (IDD_NETPAGE, IDC_NICTOTALS, false), + (IDD_SERVICESPAGE, IDC_SERVICELIST, true), ]; for (dialog_id, control_id, expected) in cases { diff --git a/src/ui/localization/mod.rs b/src/ui/localization/mod.rs index 7617454..ed79c03 100644 --- a/src/ui/localization/mod.rs +++ b/src/ui/localization/mod.rs @@ -24,7 +24,7 @@ mod language_ui; mod text_key; pub use language_terms::{ - adapter_state, network_column_titles, session_state, user_column_titles, + adapter_state, network_column_titles, service_column_titles, session_state, user_column_titles, user_session_column_title, }; pub use language_ui::localize_dialog; diff --git a/src/ui/localization/terms.rs b/src/ui/localization/terms.rs index c13f190..deac608 100644 --- a/src/ui/localization/terms.rs +++ b/src/ui/localization/terms.rs @@ -26,6 +26,16 @@ pub fn user_session_column_title() -> &'static str { text(TextKey::Session) } +pub fn service_column_titles() -> [&'static str; 5] { + [ + text(TextKey::ServiceColumnName), + text(TextKey::ServiceColumnDescription), + text(TextKey::Status), + text(TextKey::ServiceColumnStartupType), + text(TextKey::ServiceColumnProcess), + ] +} + pub fn network_column_titles() -> [&'static str; 7] { // 网络页列标题会被列表头和相关对话框共同复用。 [ diff --git a/src/ui/localization/text_key.rs b/src/ui/localization/text_key.rs index f30928c..05862f5 100644 --- a/src/ui/localization/text_key.rs +++ b/src/ui/localization/text_key.rs @@ -302,4 +302,27 @@ pub enum TextKey { SelectedUserCouldNotBeLoggedOff, SelectedUserCouldNotBeDisconnected, Win32ErrorPrefix, + ServicesPageTitle, + ServicesMenu, + ServiceStart, + ServiceStop, + ServiceColumnName, + ServiceColumnDescription, + ServiceColumnStartupType, + ServiceColumnProcess, + ServiceStatusStopped, + ServiceStatusStartPending, + ServiceStatusStopPending, + ServiceStatusContinuePending, + ServiceStatusPausePending, + ServiceStatusPaused, + ServiceStartupBoot, + ServiceStartupSystem, + ServiceStartupAutomatic, + ServiceStartupManual, + ServiceStartupDisabled, + ServiceStartFailed, + ServiceStopFailed, + ServiceStateUnchanged, + ServiceProcessPid, } diff --git a/src/ui/localization/ui.rs b/src/ui/localization/ui.rs index da6e840..aaa762c 100644 --- a/src/ui/localization/ui.rs +++ b/src/ui/localization/ui.rs @@ -92,6 +92,15 @@ pub fn localize_dialog(hwnd: HWND, dialog_id: u16) { set_dialog_item_text(hwnd, i32::from(IDM_LOGOFF), TextKey::Logoff); set_dialog_item_text(hwnd, i32::from(IDM_SENDMESSAGE), TextKey::SendMessage); } + IDD_SERVICESPAGE => { + set_dialog_item_text(hwnd, i32::from(IDM_SERVICE_START), TextKey::ServiceStart); + set_dialog_item_text(hwnd, i32::from(IDM_SERVICE_STOP), TextKey::ServiceStop); + set_dialog_item_text( + hwnd, + i32::from(IDM_SERVICE_GOTOPROCESS), + TextKey::GoToProcess, + ); + } IDD_PERFPAGE => { set_dialog_item_text(hwnd, IDC_STATIC14, TextKey::Handles); set_dialog_item_text(hwnd, IDC_STATIC15, TextKey::Threads); diff --git a/src/ui/menus.rs b/src/ui/menus.rs index dda1d7d..c4b3185 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -265,6 +265,31 @@ fn build_gpu_main_menu() -> MenuResult { Ok(bar) } +fn build_services_main_menu() -> MenuResult { + let mut bar = MenuBar::new()?; + let mut view = PopupMenu::new()?; + append_item(&mut view, IDM_REFRESH, TextKey::RefreshNow)?; + view.append_submenu(text(TextKey::UpdateSpeed), build_update_speed_menu()?)?; + append_common_main_menus( + &mut bar, + build_file_menu()?, + build_common_options_menu(false)?, + view, + )?; + bar.append_submenu(text(TextKey::ServicesMenu), build_service_command_menu()?)?; + bar.append_submenu(text(TextKey::Help), build_common_help_menu()?)?; + Ok(bar) +} + +fn build_service_command_menu() -> MenuResult { + let mut menu = PopupMenu::new()?; + append_item(&mut menu, IDM_SERVICE_START, TextKey::ServiceStart)?; + append_item(&mut menu, IDM_SERVICE_STOP, TextKey::ServiceStop)?; + append_separator(&mut menu)?; + append_item(&mut menu, IDM_SERVICE_GOTOPROCESS, TextKey::GoToProcess)?; + Ok(menu) +} + fn build_users_main_menu() -> MenuResult { let mut bar = MenuBar::new()?; let options = build_options_menu(&[ @@ -290,6 +315,10 @@ fn build_tray_popup() -> MenuResult { Ok(menu) } +fn build_service_context_popup() -> MenuResult { + build_service_command_menu() +} + fn build_user_context_popup() -> MenuResult { let mut menu = PopupMenu::new()?; append_item(&mut menu, IDM_SENDMESSAGE, TextKey::SendMessage)?; @@ -308,6 +337,7 @@ pub fn build_main_menu(resource_id: u16, processor_count: usize) -> MenuResult build_gpu_main_menu()?, IDR_MAINMENU_NET => build_network_main_menu()?, IDR_MAINMENU_USER => build_users_main_menu()?, + IDR_MAINMENU_SERVICE => build_services_main_menu()?, _ => return Err(ERROR_RESOURCE_DATA_NOT_FOUND), }; sanitize_task_manager_menu(menu.as_raw(), processor_count); @@ -325,6 +355,7 @@ pub(crate) const fn supports_main_menu(resource_id: u16) -> bool { | IDR_MAINMENU_GPU | IDR_MAINMENU_NET | IDR_MAINMENU_USER + | IDR_MAINMENU_SERVICE ) } @@ -333,6 +364,7 @@ pub fn build_popup_menu(resource_id: u16) -> MenuResult { IDR_TASK_CONTEXT => build_task_context_popup(), IDR_TASKVIEW => build_task_view_popup(), IDR_USER_CONTEXT => build_user_context_popup(), + IDR_SERVICE_CONTEXT => build_service_context_popup(), IDR_TRAYMENU => build_tray_popup(), _ => Err(ERROR_RESOURCE_DATA_NOT_FOUND), } diff --git a/src/ui/resource_ids.rs b/src/ui/resource_ids.rs index 0124018..656f6c4 100644 --- a/src/ui/resource_ids.rs +++ b/src/ui/resource_ids.rs @@ -33,6 +33,7 @@ pub const PWM_CPU_WORKER_COMPLETE: u32 = 0x0400 + 21; 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; // 顶层窗口、菜单和对话框模板 ID。 pub const IDD_MAINWND: u16 = 105; @@ -64,6 +65,9 @@ pub const IDD_GPUPAGE: u16 = 4001; pub const IDR_MAINMENU_CPU: u16 = 4100; pub const IDD_CPUPAGE: u16 = 4101; 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 IDI_TRAY_CPU_00: u16 = 127; pub const IDI_TRAY_CPU_01: u16 = 128; @@ -242,6 +246,7 @@ 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_SERVICELIST: i32 = 4303; pub const IDC_MESSAGE_TITLE: i32 = 251; pub const IDC_MESSAGE_MESSAGE: i32 = 252; pub const IDC_CPU0: i32 = 2000; @@ -358,6 +363,9 @@ pub const IDM_TASK_SWITCHTO: u16 = 40044; pub const IDM_TASK_BRINGTOFRONT: u16 = 40045; pub const IDM_TASK_ENDTASK: u16 = 40046; pub const IDM_TASK_FINDPROCESS: u16 = 40047; +pub const IDM_SERVICE_START: u16 = 40120; +pub const IDM_SERVICE_STOP: u16 = 40121; +pub const IDM_SERVICE_GOTOPROCESS: u16 = 40122; pub const IDC_NEXTTAB: u16 = 40100; pub const IDC_PREVTAB: u16 = 40101;