From a9c9562c5fa223125ed1ae93ad69d6bb134c3e1c Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:12:04 +0800 Subject: [PATCH 01/11] Add temporary root-cause fix applicator --- scripts/agent_fix_open_issues.py | 1483 ++++++++++++++++++++++++++++++ 1 file changed, 1483 insertions(+) create mode 100644 scripts/agent_fix_open_issues.py diff --git a/scripts/agent_fix_open_issues.py b/scripts/agent_fix_open_issues.py new file mode 100644 index 0000000..7697705 --- /dev/null +++ b/scripts/agent_fix_open_issues.py @@ -0,0 +1,1483 @@ +from __future__ import annotations + +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8", newline="\n") + + +def replace_once(path: str, old: str, new: str) -> None: + text = read(path) + count = text.count(old) + if count != 1: + raise RuntimeError(f"{path}: expected one match, found {count}: {old[:120]!r}") + write(path, text.replace(old, new, 1)) + + +def replace_between(path: str, start: str, end: str, replacement: str) -> None: + text = read(path) + start_index = text.find(start) + if start_index < 0: + raise RuntimeError(f"{path}: start marker not found: {start!r}") + end_index = text.find(end, start_index + len(start)) + if end_index < 0: + raise RuntimeError(f"{path}: end marker not found: {end!r}") + if text.find(start, start_index + len(start)) >= 0: + raise RuntimeError(f"{path}: start marker is not unique: {start!r}") + write(path, text[:start_index] + replacement + text[end_index:]) + + +# --------------------------------------------------------------------------- +# #19: isolate taskmgr-rs options from the native Task Manager Preferences. +# --------------------------------------------------------------------------- + +OPTIONS = "src/config/options.rs" + +replace_once( + OPTIONS, + 'use windows_sys::Win32::Foundation::{ERROR_REVISION_MISMATCH, ERROR_SUCCESS, RECT};', + '''use windows_sys::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_INVALID_DATA, ERROR_REVISION_MISMATCH, ERROR_SUCCESS, RECT, +};''', +) + +replace_once( + OPTIONS, + '''const TASKMAN_KEY: &str = "Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\TaskManager"; +const OPTIONS_KEY: &str = "Preferences"; +const OPTIONS_SCHEMA_VERSION: i32 = 2;''', + '''const OPTIONS_KEY: &str = "Software\\\\taskmgr-rs\\\\TaskManager"; +const OPTIONS_VALUE: &str = "OptionsV1"; +const LEGACY_TASKMAN_KEY: &str = + "Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\TaskManager"; +const LEGACY_OPTIONS_VALUE: &str = "Preferences"; +const OPTIONS_STORAGE_MAGIC: [u8; 8] = *b"TMGRRS01"; +const OPTIONS_STORAGE_VERSION: u32 = 1; +const OPTIONS_SCHEMA_VERSION: i32 = 2;''', +) + +replace_once( + OPTIONS, + '''pub struct Options { + // 该结构体会按二进制整体落盘到注册表,因此字段顺序和类型都需要保持稳定。 + pub cb_size: u32, + pub timer_interval: u32, + pub view_mode: i32, + pub cpu_history_mode: i32, + pub update_speed: i32, + pub window_rect: RECT, + pub current_page: i32, + pub active_process_columns: [i32; NUM_COLUMN + 1], + pub column_widths: [i32; NUM_COLUMN + 1], + flags: u32, + pub unused: i32, + pub unused2: i32, +} + +impl Default for Options {''', + '''pub struct Options { + // 该结构体会按二进制整体落盘到注册表,因此字段顺序和类型都需要保持稳定。 + pub cb_size: u32, + pub timer_interval: u32, + pub view_mode: i32, + pub cpu_history_mode: i32, + pub update_speed: i32, + pub window_rect: RECT, + pub current_page: i32, + pub active_process_columns: [i32; NUM_COLUMN + 1], + pub column_widths: [i32; NUM_COLUMN + 1], + flags: u32, + pub unused: i32, + pub unused2: i32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct StoredOptions { + magic: [u8; 8], + storage_version: u32, + payload_size: u32, + options: Options, +} + +impl StoredOptions { + fn new(options: Options) -> Self { + Self { + magic: OPTIONS_STORAGE_MAGIC, + storage_version: OPTIONS_STORAGE_VERSION, + payload_size: size_of::() as u32, + options, + } + } + + fn into_options(self) -> Result { + if self.magic != OPTIONS_STORAGE_MAGIC + || self.payload_size != size_of::() as u32 + { + return Err(ERROR_INVALID_DATA); + } + if self.storage_version != OPTIONS_STORAGE_VERSION { + return Err(ERROR_REVISION_MISMATCH); + } + Ok(self.options) + } +} + +/// Marker for fixed-layout registry values whose every bit pattern is a valid Rust value. +/// +/// # Safety +/// +/// Implementors must be `Copy`, have a stable layout, contain no references, and permit every +/// possible byte pattern. This lets the registry reader initialize the value as zeroed storage +/// before Windows fills every byte. +unsafe trait RegistryPod: Copy {} + +unsafe impl RegistryPod for Options {} +unsafe impl RegistryPod for StoredOptions {} + +impl Default for Options {''', +) + +replace_between( + OPTIONS, + ' pub fn load(&mut self, min_width: i32, min_height: i32) -> bool {', + ' pub fn minimize_on_use(&self) -> bool {', + r''' pub fn load(&mut self, min_width: i32, min_height: i32) -> bool { + // taskmgr-rs owns a versioned application-specific value. The Microsoft Task Manager key + // is read only for a one-time migration of values that carry taskmgr-rs' exact schema + // marker; it is never written or deleted. + if modifiers_force_defaults() { + self.set_default_values(min_width, min_height); + return false; + } + + match read_registry_binary::(OPTIONS_KEY, OPTIONS_VALUE) { + Ok(Some(stored)) => { + let loaded = match stored.into_options() { + Ok(loaded) => loaded, + Err(error) => { + record_win32_error("taskmgr-rs options envelope", error); + self.set_default_values(min_width, min_height); + return false; + } + }; + return self.apply_loaded_options(loaded, min_width, min_height, false); + } + Ok(None) => {} + Err(error) => { + record_win32_error("reading taskmgr-rs options", error); + self.set_default_values(min_width, min_height); + return false; + } + } + + match read_registry_binary::(LEGACY_TASKMAN_KEY, LEGACY_OPTIONS_VALUE) { + Ok(Some(legacy)) if legacy_options_is_taskmgr_rs(&legacy) => { + self.apply_loaded_options(legacy, min_width, min_height, true) + } + Ok(Some(_)) | Ok(None) => { + self.set_default_values(min_width, min_height); + false + } + Err(error) => { + record_win32_error("reading legacy taskmgr-rs options", error); + self.set_default_values(min_width, min_height); + false + } + } + } + + fn apply_loaded_options( + &mut self, + mut loaded: Options, + min_width: i32, + min_height: i32, + persist_migration: bool, + ) -> bool { + let migrated = match loaded.migrate_schema() { + Ok(migrated) => migrated, + Err(()) => { + record_win32_error("unsupported options schema", ERROR_REVISION_MISMATCH); + self.set_default_values(min_width, min_height); + return false; + } + }; + let loaded_was_valid = loaded.is_valid(min_width, min_height); + if !loaded_was_valid { + loaded.normalize(min_width, min_height); + } + *self = loaded; + if (persist_migration || migrated || !loaded_was_valid) + && let Err(error) = self.save() + { + record_win32_error("normalized options persistence", error); + } + loaded_was_valid + } + + pub fn save(&self) -> Result<(), u32> { + let stored = StoredOptions::new(*self); + write_registry_binary(OPTIONS_KEY, OPTIONS_VALUE, &stored) + } + +''', +) + +replace_once( + OPTIONS, + 'fn modifiers_force_defaults() -> bool {', + r'''fn legacy_options_is_taskmgr_rs(options: &Options) -> bool { + options.cb_size == size_of::() as u32 + && options.unused == OPTIONS_SCHEMA_VERSION + && options.unused2 == 0 + && options.current_page >= -1 + && options.current_page < PageId::COUNT as i32 + && is_valid_view_mode(options.view_mode) + && is_valid_cpu_history_mode(options.cpu_history_mode) + && is_valid_update_speed(options.update_speed) + && options.flags & !ALL_VALID_FLAGS == 0 + && process_columns_are_valid(&options.active_process_columns, &options.column_widths) +} + +fn read_registry_binary( + key_path: &str, + value_name: &str, +) -> Result, u32> { + unsafe { + let key_path = to_wide_null(key_path); + let value_name = to_wide_null(value_name); + let mut key: HKEY = null_mut(); + let open_status = + RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut key); + if open_status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + if open_status != ERROR_SUCCESS { + return Err(open_status); + } + + let mut value_type = 0u32; + let mut value_size = 0u32; + let size_status = RegQueryValueExW( + key, + value_name.as_ptr(), + null_mut(), + &mut value_type, + null_mut(), + &mut value_size, + ); + if size_status == ERROR_FILE_NOT_FOUND { + let close_status = RegCloseKey(key); + return if close_status == ERROR_SUCCESS { + Ok(None) + } else { + Err(close_status) + }; + } + if size_status != ERROR_SUCCESS { + RegCloseKey(key); + return Err(size_status); + } + if value_type != REG_BINARY || value_size != size_of::() as u32 { + RegCloseKey(key); + return Err(ERROR_INVALID_DATA); + } + + // SAFETY: RegistryPod requires every bit pattern to be valid. The exact-size check above + // and the second query's unchanged byte count guarantee that Windows initializes all bytes. + let mut value = zeroed::(); + let mut actual_size = value_size; + let read_status = RegQueryValueExW( + key, + value_name.as_ptr(), + null_mut(), + &mut value_type, + (&mut value as *mut T).cast::(), + &mut actual_size, + ); + let close_status = RegCloseKey(key); + if read_status != ERROR_SUCCESS { + return Err(read_status); + } + if close_status != ERROR_SUCCESS { + return Err(close_status); + } + if value_type != REG_BINARY || actual_size != size_of::() as u32 { + return Err(ERROR_INVALID_DATA); + } + Ok(Some(value)) + } +} + +fn write_registry_binary( + key_path: &str, + value_name: &str, + value: &T, +) -> Result<(), u32> { + unsafe { + let key_path = to_wide_null(key_path); + let value_name = to_wide_null(value_name); + let mut key: HKEY = null_mut(); + let mut disposition = 0u32; + let create_status = RegCreateKeyExW( + HKEY_CURRENT_USER, + key_path.as_ptr(), + 0, + null_mut(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + null_mut(), + &mut key, + &mut disposition, + ); + if create_status != ERROR_SUCCESS { + return Err(create_status); + } + + // SAFETY: RegistryPod has a fixed initialized representation and contains no references. + let bytes = + std::slice::from_raw_parts((value as *const T).cast::(), size_of::()); + let set_status = RegSetValueExW( + key, + value_name.as_ptr(), + 0, + REG_BINARY, + bytes.as_ptr(), + bytes.len() as u32, + ); + let close_status = RegCloseKey(key); + if set_status != ERROR_SUCCESS { + Err(set_status) + } else if close_status != ERROR_SUCCESS { + Err(close_status) + } else { + Ok(()) + } + } +} + +fn modifiers_force_defaults() -> bool {''', +) + +replace_once( + OPTIONS, + ''' use super::{ + ColumnId, NUM_COLUMN, OPTIONS_SCHEMA_VERSION, Options, SCHEMA_0_NETWORK_PAGE, + SCHEMA_0_USERS_PAGE, SCHEMA_1_GPU_PAGE, SCHEMA_1_NETWORK_PAGE, SCHEMA_1_USERS_PAGE, + UpdateSpeed, normalize_process_columns, process_columns_are_valid, + update_speed_timer_interval, window_rect_dimensions_are_valid, window_rect_is_valid, + };''', + ''' use super::{ + ColumnId, ERROR_INVALID_DATA, ERROR_REVISION_MISMATCH, LEGACY_OPTIONS_VALUE, + LEGACY_TASKMAN_KEY, NUM_COLUMN, OPTIONS_KEY, OPTIONS_SCHEMA_VERSION, + OPTIONS_STORAGE_MAGIC, OPTIONS_STORAGE_VERSION, OPTIONS_VALUE, Options, + SCHEMA_0_NETWORK_PAGE, SCHEMA_0_USERS_PAGE, SCHEMA_1_GPU_PAGE, SCHEMA_1_NETWORK_PAGE, + SCHEMA_1_USERS_PAGE, StoredOptions, UpdateSpeed, legacy_options_is_taskmgr_rs, + normalize_process_columns, process_columns_are_valid, update_speed_timer_interval, + window_rect_dimensions_are_valid, window_rect_is_valid, + };''', +) + +replace_once( + OPTIONS, + ''' #[test] + fn process_columns_reject_missing_primary_and_duplicates() {''', + r''' #[test] + fn stored_options_envelope_round_trips_and_rejects_foreign_data() { + let options = Options::default(); + assert_eq!( + StoredOptions::new(options).into_options().unwrap().unused, + options.unused + ); + + let mut foreign = StoredOptions::new(options); + foreign.magic = *b"NATIVE00"; + assert_eq!(foreign.into_options().err(), Some(ERROR_INVALID_DATA)); + + let mut future = StoredOptions::new(options); + future.storage_version = OPTIONS_STORAGE_VERSION + 1; + assert_eq!( + future.into_options().err(), + Some(ERROR_REVISION_MISMATCH) + ); + + let mut wrong_size = StoredOptions::new(options); + wrong_size.payload_size = 0; + assert_eq!(wrong_size.into_options().err(), Some(ERROR_INVALID_DATA)); + assert_eq!(OPTIONS_STORAGE_MAGIC, *b"TMGRRS01"); + } + + #[test] + fn legacy_migration_requires_the_exact_taskmgr_rs_schema_marker() { + let current = Options::default(); + assert!(legacy_options_is_taskmgr_rs(¤t)); + + for schema in [0, 1, OPTIONS_SCHEMA_VERSION + 1] { + let candidate = Options { + unused: schema, + ..current + }; + assert!(!legacy_options_is_taskmgr_rs(&candidate)); + } + + let wrong_size = Options { + cb_size: 0, + ..current + }; + assert!(!legacy_options_is_taskmgr_rs(&wrong_size)); + } + + #[test] + fn application_options_namespace_is_distinct_from_windows_task_manager() { + assert_ne!(OPTIONS_KEY, LEGACY_TASKMAN_KEY); + assert_ne!(OPTIONS_VALUE, LEGACY_OPTIONS_VALUE); + } + + #[test] + fn process_columns_reject_missing_primary_and_duplicates() {''', +) + +# --------------------------------------------------------------------------- +# #20: directional network capacities and explicit total-utilization semantics. +# --------------------------------------------------------------------------- + +NETWORK = "src/pages/network.rs" + +replace_once( + NETWORK, + ''' link_speed_bps: u64, + bytes_sent: u64,''', + ''' transmit_link_speed_bps: u64, + receive_link_speed_bps: u64, + bytes_sent: u64,''', +) + +replace_once( + NETWORK, + ''' // A zero curve point marks an unavailable interval after first sight or counter + // reset; the textual value remains "-" so it is not presented as measured idle. + let total_delta = counter_delta.map_or(0, |delta| delta.2); + let sent_util = counter_delta.map_or(0, |delta| { + utilization_percent_for_history(delta.0, raw_adapter.link_speed_bps, elapsed_secs) + }); + let received_util = counter_delta.map_or(0, |delta| { + utilization_percent_for_history(delta.1, raw_adapter.link_speed_bps, elapsed_secs) + }); + let total_util = counter_delta.map_or(0, |delta| { + utilization_percent_for_history(delta.2, raw_adapter.link_speed_bps, elapsed_secs) + }); + + push_history(&mut sent_history, sent_util); + push_history(&mut received_history, received_util); + push_history(&mut total_history, total_util);''', + ''' // Each direction owns an independent full-duplex capacity. "Total" is the + // busiest direction's percentage, which remains meaningful for asymmetric links and + // cannot fabricate a 200% value by summing independent capacities. + let (sent_ratio, received_ratio, total_ratio) = counter_delta.map_or( + (None, None, None), + |delta| { + directional_utilization_ratios( + delta.0, + delta.1, + raw_adapter.transmit_link_speed_bps, + raw_adapter.receive_link_speed_bps, + elapsed_secs, + ) + }, + ); + + push_history( + &mut sent_history, + utilization_percent_for_history(sent_ratio), + ); + push_history( + &mut received_history, + utilization_percent_for_history(received_ratio), + ); + push_history( + &mut total_history, + utilization_percent_for_history(total_ratio), + );''', +) + +replace_once( + NETWORK, + ''' link_speed: format_link_speed(raw_adapter.link_speed_bps), + utilization: counter_delta + .map(|_| { + utilization_text(total_delta, raw_adapter.link_speed_bps, elapsed_secs) + }) + .unwrap_or_else(|| "-".to_string()),''', + ''' link_speed: format_link_speeds( + raw_adapter.transmit_link_speed_bps, + raw_adapter.receive_link_speed_bps, + ), + utilization: utilization_text(total_ratio),''', +) + +replace_once( + NETWORK, + ''' link_speed_bps: row.ReceiveLinkSpeed.max(row.TransmitLinkSpeed), + bytes_sent: row.OutOctets,''', + ''' transmit_link_speed_bps: row.TransmitLinkSpeed, + receive_link_speed_bps: row.ReceiveLinkSpeed, + bytes_sent: row.OutOctets,''', +) + +replace_once( + NETWORK, + ' u8::from(adapter.link_speed_bps != 0),', + ''' u8::from( + adapter.transmit_link_speed_bps != 0 || adapter.receive_link_speed_bps != 0, + ),''', +) + +replace_between( + NETWORK, + '''fn utilization_ratio_percent( + bytes_per_interval: u64, + link_speed_bps: u64, + elapsed_secs: f64, +) -> Option {''', + 'fn format_counter(value: u64) -> String {', + r'''fn utilization_ratio_percent( + bytes_per_interval: u64, + link_speed_bps: u64, + elapsed_secs: f64, +) -> Option { + if link_speed_bps == 0 || !elapsed_secs.is_finite() || elapsed_secs <= 0.0 { + return None; + } + if bytes_per_interval == 0 { + return Some(0.0); + } + + let bits_per_second = (bytes_per_interval as f64 * 8.0) / elapsed_secs; + let percent = (bits_per_second * 100.0) / link_speed_bps as f64; + percent.is_finite().then(|| percent.clamp(0.0, 100.0)) +} + +fn directional_utilization_ratios( + sent_bytes: u64, + received_bytes: u64, + transmit_link_speed_bps: u64, + receive_link_speed_bps: u64, + elapsed_secs: f64, +) -> (Option, Option, Option) { + let sent = + utilization_ratio_percent(sent_bytes, transmit_link_speed_bps, elapsed_secs); + let received = + utilization_ratio_percent(received_bytes, receive_link_speed_bps, elapsed_secs); + let total = match (sent, received) { + (Some(sent), Some(received)) => Some(sent.max(received)), + (Some(sent), None) => Some(sent), + (None, Some(received)) => Some(received), + (None, None) => None, + }; + (sent, received, total) +} + +fn utilization_percent_for_history(ratio_percent: Option) -> u8 { + let Some(ratio_percent) = ratio_percent else { + return 0; + }; + + let rounded = ratio_percent.round().clamp(0.0, 100.0) as u8; + if rounded == 0 && ratio_percent > 0.0 { + 1 + } else { + rounded + } +} + +fn utilization_text(ratio_percent: Option) -> String { + let Some(ratio_percent) = ratio_percent else { + return "-".to_string(); + }; + + if ratio_percent > 0.0 && ratio_percent < 1.0 { + "<1%".to_string() + } else { + format!("{}%", ratio_percent.round().clamp(0.0, 100.0) as u8) + } +} + +fn format_link_speeds(transmit_bits_per_second: u64, receive_bits_per_second: u64) -> String { + match (transmit_bits_per_second, receive_bits_per_second) { + (0, 0) => "-".to_string(), + (transmit, receive) if transmit == receive => format_link_speed(transmit), + (transmit, receive) => format!( + "Tx {} / Rx {}", + format_link_speed(transmit), + format_link_speed(receive) + ), + } +} + +fn format_link_speed(bits_per_second: u64) -> String { + // 链路速率采用十进制网络单位显示,更符合网卡/交换机常见标注方式。 + if bits_per_second == 0 { + return "-".to_string(); + } + + let units = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"]; + let mut value = bits_per_second as f64; + let mut unit = 0usize; + while value >= 1000.0 && unit + 1 < units.len() { + value /= 1000.0; + unit += 1; + } + + if value >= 100.0 || unit == 0 { + format!("{value:.0} {}", units[unit]) + } else { + format!("{value:.1} {}", units[unit]) + } +} + +''', +) + +replace_once( + NETWORK, + ''' #[test] + fn adapter_counter_delta_rejects_missing_regressed_and_overflowed_intervals() {''', + r''' #[test] + fn asymmetric_links_use_each_direction_capacity() { + let (sent, received, total) = + directional_utilization_ratios(1_250_000, 0, 10_000_000, 100_000_000, 1.0); + assert_eq!(sent, Some(100.0)); + assert_eq!(received, Some(0.0)); + assert_eq!(total, Some(100.0)); + + let (sent, received, total) = + directional_utilization_ratios(0, 1_250_000, 100_000_000, 10_000_000, 1.0); + assert_eq!(sent, Some(0.0)); + assert_eq!(received, Some(100.0)); + assert_eq!(total, Some(100.0)); + } + + #[test] + fn full_duplex_total_is_the_busiest_direction_not_a_sum() { + let (_, _, total) = + directional_utilization_ratios(12_500_000, 12_500_000, 100_000_000, 100_000_000, 1.0); + assert_eq!(total, Some(100.0)); + } + + #[test] + fn unavailable_direction_does_not_poison_the_other_direction() { + let (sent, received, total) = + directional_utilization_ratios(0, 1_250_000, 0, 10_000_000, 1.0); + assert_eq!(sent, None); + assert_eq!(received, Some(100.0)); + assert_eq!(total, Some(100.0)); + assert_eq!(utilization_text(None), "-"); + assert_eq!(utilization_text(Some(0.0)), "0%"); + } + + #[test] + fn asymmetric_link_speed_text_preserves_both_capacities() { + assert_eq!( + format_link_speeds(10_000_000, 100_000_000), + "Tx 10.0 Mbps / Rx 100 Mbps" + ); + assert_eq!( + format_link_speeds(1_000_000_000, 1_000_000_000), + "1.0 Gbps" + ); + } + + #[test] + fn adapter_counter_delta_rejects_missing_regressed_and_overflowed_intervals() {''', +) + +# --------------------------------------------------------------------------- +# #21: preserve AeDebug templates, choose the target registry view, and inherit +# only the debugger-ready event handle. +# --------------------------------------------------------------------------- + +ACTIONS = "src/pages/processes/actions.rs" + +replace_once( + ACTIONS, + '''use std::collections::{HashMap, HashSet}; +use std::mem::{size_of, zeroed}; +use std::path::Path; +use std::ptr::{null, null_mut};''', + '''use std::collections::{HashMap, HashSet}; +use std::ffi::c_void; +use std::mem::{size_of, zeroed}; +use std::path::Path; +use std::ptr::{null, null_mut};''', +) + +replace_once( + ACTIONS, + '''use windows_sys::Win32::Foundation::{ + ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, + ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_NO_MORE_FILES, + ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, FILETIME, GetLastError, HANDLE, HWND, LPARAM, + WAIT_OBJECT_0, WPARAM, +};''', + '''use windows_sys::Win32::Foundation::{ + ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, + ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, + ERROR_NO_MORE_FILES, ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, FILETIME, GetLastError, HANDLE, + HWND, LPARAM, WAIT_OBJECT_0, WPARAM, +};''', +) + +replace_once( + ACTIONS, + '''use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegOpenKeyExW, + RegQueryValueExW, +};''', + '''use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, REG_EXPAND_SZ, REG_SZ, + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, +};''', +) + +replace_once( + ACTIONS, + '''use windows_sys::Win32::System::SystemInformation::{ + GROUP_AFFINITY, GetSystemTimeAsFileTime, GetWindowsDirectoryW, +};''', + '''use windows_sys::Win32::System::SystemInformation::{ + GROUP_AFFINITY, GetSystemTimeAsFileTime, GetWindowsDirectoryW, IMAGE_FILE_MACHINE_AMD64, + IMAGE_FILE_MACHINE_ARM, IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_ARMNT, + IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_IA64, IMAGE_FILE_MACHINE_THUMB, + IMAGE_FILE_MACHINE_UNKNOWN, +};''', +) + +replace_once( + ACTIONS, + '''use windows_sys::Win32::System::Threading::{ + ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CreateProcessW, + GetProcessAffinityMask, GetProcessGroupAffinity, GetProcessIdOfThread, GetThreadGroupAffinity, + HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, OpenThread, + PROCESS_INFORMATION, PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, + PROCESS_SET_INFORMATION, PROCESS_SET_LIMITED_INFORMATION, PROCESS_TERMINATE, + QueryFullProcessImageNameW, REALTIME_PRIORITY_CLASS, STARTUPINFOW, SetPriorityClass, + SetProcessAffinityMask, SetProcessDefaultCpuSets, SetThreadGroupAffinity, + THREAD_QUERY_LIMITED_INFORMATION, THREAD_SET_INFORMATION, TerminateProcess, + WaitForSingleObject, +};''', + '''use windows_sys::Win32::System::Threading::{ + ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CREATE_NEW_CONSOLE, CreateEventW, + CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, + GetProcessAffinityMask, GetProcessGroupAffinity, GetProcessIdOfThread, GetThreadGroupAffinity, + HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, InitializeProcThreadAttributeList, IsWow64Process2, + LPPROC_THREAD_ATTRIBUTE_LIST, NORMAL_PRIORITY_CLASS, OpenThread, PROCESS_INFORMATION, + PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_INFORMATION, + PROCESS_SET_LIMITED_INFORMATION, PROCESS_TERMINATE, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + QueryFullProcessImageNameW, REALTIME_PRIORITY_CLASS, STARTUPINFOEXW, STARTUPINFOW, + SetPriorityClass, SetProcessAffinityMask, SetProcessDefaultCpuSets, SetThreadGroupAffinity, + THREAD_QUERY_LIMITED_INFORMATION, THREAD_SET_INFORMATION, TerminateProcess, + UpdateProcThreadAttribute, WaitForSingleObject, +};''', +) + +replace_once( + ACTIONS, + 'use windows_sys::Win32::UI::Controls::{', + '''use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; +use windows_sys::Win32::UI::Controls::{''', +) + +replace_between( + ACTIONS, + ''' // 以 AeDebug 注册表配置的调试器启动并附加到目标进程。命令行传 -p 。 + pub(super) fn attach_debugger(&mut self, identity: ProcIdentity) -> bool {''', + ' // 通过 explorer.exe /select 命令在资源管理器中定位进程的可执行文件。', + r''' // 使用目标进程位数对应的 AeDebug 命令模板启动调试器。完整模板中的第一个 + // `%ld` 接收 PID,第二个接收唯一继承给调试器的 ready-event 句柄。 + pub(super) fn attach_debugger(&mut self, identity: ProcIdentity) -> bool { + if !self.quick_confirm(&self.strings.warning, &self.strings.debug) { + return false; + } + + let target_handle = + match open_process_for_identity(identity, PROCESS_QUERY_LIMITED_INFORMATION) { + Ok(handle) => handle, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + let registry_view = match debugger_registry_view_for_process(target_handle.as_raw()) { + Ok(view) => view, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + let debugger = match load_debugger_command(registry_view) { + Ok(Some(debugger)) => debugger, + Ok(None) => { + self.show_failure_message(&self.strings.cant_debug, ERROR_FILE_NOT_FOUND); + return false; + } + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + if !Path::new(&debugger.executable).is_file() { + self.show_failure_message(&self.strings.cant_debug, ERROR_FILE_NOT_FOUND); + return false; + } + + let security = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: null_mut(), + bInheritHandle: 1, + }; + // SAFETY: `security` remains live for the synchronous call and requests one unnamed, + // nonsignaled event whose returned handle is adopted immediately. + let raw_event = unsafe { CreateEventW(&security, 0, 0, null()) }; + let Some(debugger_ready_event) = (unsafe { OwnedHandle::from_raw(raw_event) }) else { + self.show_failure_message(&self.strings.cant_debug, nonzero_last_error()); + return false; + }; + let command_line = + match format_debugger_template(&debugger.template, identity.pid, debugger_ready_event.as_raw() as usize) { + Ok(command_line) => command_line, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + let attributes = match ProcThreadAttributeList::for_handle(debugger_ready_event.as_raw()) { + Ok(attributes) => attributes, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + + let mut command_line_wide = to_wide_null(&command_line); + let application_name = to_wide_null(&debugger.executable); + let startup_info = STARTUPINFOEXW { + StartupInfo: STARTUPINFOW { + cb: size_of::() as u32, + ..unsafe { zeroed() } + }, + lpAttributeList: attributes.as_ptr(), + }; + let mut process_info = unsafe { zeroed::() }; + + // SAFETY: the application/command buffers and extended startup information remain live + // for the call. bInheritHandles is required by PROC_THREAD_ATTRIBUTE_HANDLE_LIST, which + // restricts inheritance to the one event in `attributes`. + let created = unsafe { + CreateProcessW( + application_name.as_ptr(), + command_line_wide.as_mut_ptr(), + null_mut(), + null_mut(), + 1, + CREATE_NEW_CONSOLE | EXTENDED_STARTUPINFO_PRESENT, + null(), + null(), + &startup_info.StartupInfo, + &mut process_info, + ) + }; + let create_error = if created == 0 { + unsafe { GetLastError() } + } else { + 0 + }; + drop(target_handle); + + if created == 0 { + self.show_failure_message( + &self.strings.cant_debug, + if create_error == 0 { + ERROR_GEN_FAILURE + } else { + create_error + }, + ); + false + } else { + // SAFETY: successful CreateProcessW returned two fresh handles and this is their only + // ownership transfer. The child owns its inherited copy of the ready event. + match unsafe { own_created_process_handles(process_info) } { + Ok(_) => true, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + false + } + } + } + } + +''', +) + +replace_between( + ACTIONS, + 'pub(super) fn load_debugger_path() -> Result, u32> {', + '// 先构建完整的已验证句柄集合,再把不可逆的终止阶段与 UI 结果呈现分离。', + r'''const AEDEBUG_KEY: &str = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AeDebug"; +const AEDEBUG_VALUE: &str = "Debugger"; +const MAX_DEBUGGER_COMMAND_BYTES: u32 = 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DebuggerRegistryView { + Native, + Registry32, + Registry64, +} + +impl DebuggerRegistryView { + const fn access_mask(self) -> u32 { + match self { + Self::Native => 0, + Self::Registry32 => KEY_WOW64_32KEY, + Self::Registry64 => KEY_WOW64_64KEY, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DebuggerCommand { + template: String, + executable: String, +} + +struct ProcThreadAttributeList { + storage: Vec, + handles: Box<[HANDLE; 1]>, + initialized: bool, +} + +impl ProcThreadAttributeList { + fn for_handle(handle: HANDLE) -> Result { + if handle.is_null() { + return Err(ERROR_INVALID_HANDLE); + } + + let mut byte_count = 0usize; + unsafe { + InitializeProcThreadAttributeList(null_mut(), 1, 0, &mut byte_count); + } + if byte_count == 0 { + return Err(nonzero_last_error()); + } + + let word_count = byte_count.div_ceil(size_of::()); + let mut value = Self { + storage: vec![0usize; word_count], + handles: Box::new([handle]), + initialized: false, + }; + if unsafe { + InitializeProcThreadAttributeList(value.as_ptr(), 1, 0, &mut byte_count) + } == 0 + { + return Err(nonzero_last_error()); + } + value.initialized = true; + if unsafe { + UpdateProcThreadAttribute( + value.as_ptr(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + value.handles.as_mut_ptr().cast::(), + size_of::(), + null_mut(), + null_mut(), + ) + } == 0 + { + return Err(nonzero_last_error()); + } + Ok(value) + } + + fn as_ptr(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.storage.as_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST + } +} + +impl Drop for ProcThreadAttributeList { + fn drop(&mut self) { + if self.initialized { + unsafe { DeleteProcThreadAttributeList(self.as_ptr()) }; + } + } +} + +pub(super) fn load_debugger_path() -> Result, u32> { + // This is an availability probe for menu state. Launch-time selection is repeated against + // the selected process' machine type so the command can never use the wrong registry view. + let mut first_error = None; + for view in [ + DebuggerRegistryView::Native, + DebuggerRegistryView::Registry64, + DebuggerRegistryView::Registry32, + ] { + match load_debugger_command(view) { + Ok(Some(command)) if Path::new(&command.executable).is_file() => { + return Ok(Some(command.executable)); + } + Ok(_) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } + } + if let Some(error) = first_error { + Err(error) + } else { + Ok(None) + } +} + +fn load_debugger_command(view: DebuggerRegistryView) -> Result, u32> { + let Some((raw_command, value_type)) = read_aedebug_string(view)? else { + return Ok(None); + }; + parse_debugger_command(&raw_command, value_type) +} + +fn read_aedebug_string( + view: DebuggerRegistryView, +) -> Result, u32> { + unsafe { + let key_name = to_wide_null(AEDEBUG_KEY); + let value_name = to_wide_null(AEDEBUG_VALUE); + let mut key: HKEY = null_mut(); + let open_status = RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + key_name.as_ptr(), + 0, + KEY_READ | view.access_mask(), + &mut key, + ); + if open_status == ERROR_FILE_NOT_FOUND || open_status == ERROR_PATH_NOT_FOUND { + return Ok(None); + } + if open_status != 0 { + return Err(open_status); + } + + let mut value_type = 0u32; + let mut value_size = 0u32; + let size_status = RegQueryValueExW( + key, + value_name.as_ptr(), + null_mut(), + &mut value_type, + null_mut(), + &mut value_size, + ); + if size_status == ERROR_FILE_NOT_FOUND { + let close_status = RegCloseKey(key); + return if close_status == 0 { + Ok(None) + } else { + Err(close_status) + }; + } + if size_status != 0 { + RegCloseKey(key); + return Err(size_status); + } + if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) + || value_size < size_of::() as u32 + || !value_size.is_multiple_of(size_of::() as u32) + || value_size > MAX_DEBUGGER_COMMAND_BYTES + { + RegCloseKey(key); + return Err(ERROR_INVALID_DATA); + } + + let mut buffer = vec![0u16; value_size as usize / size_of::()]; + let mut actual_size = value_size; + let read_status = RegQueryValueExW( + key, + value_name.as_ptr(), + null_mut(), + &mut value_type, + buffer.as_mut_ptr().cast::(), + &mut actual_size, + ); + let close_status = RegCloseKey(key); + if read_status == ERROR_MORE_DATA { + return Err(ERROR_MORE_DATA); + } + if read_status != 0 { + return Err(read_status); + } + if close_status != 0 { + return Err(close_status); + } + if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) + || actual_size < size_of::() as u32 + || !actual_size.is_multiple_of(size_of::() as u32) + || actual_size > value_size + { + return Err(ERROR_INVALID_DATA); + } + let units = actual_size as usize / size_of::(); + let Some(length) = buffer[..units].iter().position(|value| *value == 0) else { + return Err(ERROR_INVALID_DATA); + }; + Ok(Some(( + String::from_utf16(&buffer[..length]).map_err(|_| ERROR_INVALID_DATA)?, + value_type, + ))) + } +} + +fn debugger_registry_view_for_process(process: HANDLE) -> Result { + if process.is_null() { + return Err(ERROR_INVALID_HANDLE); + } + let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + if unsafe { IsWow64Process2(process, &mut process_machine, &mut native_machine) } == 0 { + return Err(nonzero_last_error()); + } + debugger_registry_view_for_machines(process_machine, native_machine) +} + +fn debugger_registry_view_for_machines( + process_machine: u16, + native_machine: u16, +) -> Result { + let effective_machine = if process_machine == IMAGE_FILE_MACHINE_UNKNOWN { + native_machine + } else { + process_machine + }; + let target_is_32_bit = match effective_machine { + IMAGE_FILE_MACHINE_I386 + | IMAGE_FILE_MACHINE_ARM + | IMAGE_FILE_MACHINE_ARMNT + | IMAGE_FILE_MACHINE_THUMB => true, + IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => false, + _ => return Err(ERROR_NOT_SUPPORTED), + }; + let native_is_64_bit = match native_machine { + IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => true, + IMAGE_FILE_MACHINE_I386 + | IMAGE_FILE_MACHINE_ARM + | IMAGE_FILE_MACHINE_ARMNT + | IMAGE_FILE_MACHINE_THUMB => false, + _ => return Err(ERROR_NOT_SUPPORTED), + }; + Ok(if !native_is_64_bit { + DebuggerRegistryView::Native + } else if target_is_32_bit { + DebuggerRegistryView::Registry32 + } else { + DebuggerRegistryView::Registry64 + }) +} + +// 引用命令行参数。只在包含空格、制表符或引号时加引号,并正确处理反斜杠转义。 +fn quote_command_line_arg(value: &str) -> String { + if !value.contains([' ', '\t', '"']) { + return value.to_string(); + } + + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('"'); + let mut backslashes = 0usize; + + for ch in value.chars() { + if ch == '\\' { + backslashes += 1; + continue; + } + + if ch == '"' { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + continue; + } + + if backslashes > 0 { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + } + quoted.push(ch); + } + + if backslashes > 0 { + quoted.push_str(&"\\".repeat(backslashes * 2)); + } + quoted.push('"'); + quoted +} + +// 从命令行字符串中提取第一个 token(即可执行文件路径)。处理引号包裹和非引号两种格式。 +pub(super) fn extract_first_command_token(command_line: &str) -> String { + let trimmed = command_line.trim(); + if let Some(rest) = trimmed.strip_prefix('"') { + rest.split('"').next().unwrap_or_default().to_string() + } else { + trimmed + .split_whitespace() + .next() + .unwrap_or_default() + .to_string() + } +} + +fn parse_debugger_command( + command_line: &str, + value_type: u32, +) -> Result, u32> { + let expanded = if value_type == REG_EXPAND_SZ { + expand_environment_variables(command_line)? + } else if value_type == REG_SZ { + command_line.to_string() + } else { + return Err(ERROR_INVALID_DATA); + }; + parse_expanded_debugger_command(&expanded) +} + +fn parse_expanded_debugger_command( + command_line: &str, +) -> Result, u32> { + let template = command_line.trim().to_string(); + let executable = extract_first_command_token(&template); + let file_name = Path::new(&executable) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if executable.is_empty() + || !Path::new(&executable).is_absolute() + || file_name.eq_ignore_ascii_case("drwtsn32") + || file_name.eq_ignore_ascii_case("drwtsn32.exe") + { + return Ok(None); + } + validate_debugger_template(&template)?; + Ok(Some(DebuggerCommand { + template, + executable, + })) +} + +fn validate_debugger_template(template: &str) -> Result<(), u32> { + let mut placeholders = 0usize; + let mut chars = template.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + continue; + } + match chars.next() { + Some('%') => {} + Some('l' | 'L') => { + if !matches!(chars.next(), Some('d' | 'D')) { + return Err(ERROR_INVALID_DATA); + } + placeholders += 1; + } + Some('p' | 'P') => return Err(ERROR_NOT_SUPPORTED), + Some(_) | None => return Err(ERROR_INVALID_DATA), + } + } + if placeholders == 2 { + Ok(()) + } else { + Err(ERROR_INVALID_DATA) + } +} + +fn format_debugger_template( + template: &str, + process_id: u32, + event_handle: usize, +) -> Result { + validate_debugger_template(template)?; + let replacements = [process_id.to_string(), event_handle.to_string()]; + let mut replacement_index = 0usize; + let mut output = String::with_capacity(template.len() + 32); + let mut chars = template.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + output.push(ch); + continue; + } + match chars.next() { + Some('%') => output.push('%'), + Some('l' | 'L') => { + if !matches!(chars.next(), Some('d' | 'D')) { + return Err(ERROR_INVALID_DATA); + } + output.push_str( + replacements + .get(replacement_index) + .ok_or(ERROR_INVALID_DATA)?, + ); + replacement_index += 1; + } + Some('p' | 'P') => return Err(ERROR_NOT_SUPPORTED), + Some(_) | None => return Err(ERROR_INVALID_DATA), + } + } + if replacement_index == replacements.len() { + Ok(output) + } else { + Err(ERROR_INVALID_DATA) + } +} + +// Compatibility helper used by the existing pure parsing tests. +pub(super) fn normalize_debugger_command_with( + command_line: &str, + value_type: u32, + expand_environment_variables: F, +) -> Option +where + F: Fn(&str) -> String, +{ + let expanded = if value_type == REG_EXPAND_SZ { + expand_environment_variables(command_line) + } else if value_type == REG_SZ { + command_line.to_string() + } else { + return None; + }; + let executable = extract_first_command_token(&expanded); + let file_name = Path::new(&executable) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if executable.is_empty() + || file_name.eq_ignore_ascii_case("drwtsn32") + || file_name.eq_ignore_ascii_case("drwtsn32.exe") + { + None + } else { + Some(executable) + } +} + +// 展开字符串中的环境变量(如 %SystemRoot%)。 +// 使用 Win32 ExpandEnvironmentStringsW API,正确处理 WOW64 重定向和 %% 转义。 +fn expand_environment_variables(command_line: &str) -> Result { + let wide_input = to_wide_null(command_line); + let required = unsafe { ExpandEnvironmentStringsW(wide_input.as_ptr(), null_mut(), 0) }; + if required == 0 { + return Err(nonzero_last_error()); + } + let mut buffer = vec![0u16; required as usize]; + let written = + unsafe { ExpandEnvironmentStringsW(wide_input.as_ptr(), buffer.as_mut_ptr(), required) }; + if written == 0 || written > required { + return Err(nonzero_last_error()); + } + let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len()); + Ok(String::from_utf16_lossy(&buffer[..len])) +} + +#[cfg(test)] +mod debugger_tests { + use super::*; + + #[test] + fn full_aedebug_template_preserves_debugger_specific_arguments() { + let template = r#""C:\Debuggers\windbg.exe" -p %ld -e %ld -g"#; + let command = parse_expanded_debugger_command(template) + .unwrap() + .unwrap(); + assert_eq!(command.executable, r"C:\Debuggers\windbg.exe"); + assert_eq!( + format_debugger_template(&command.template, 1234, 5678).unwrap(), + r#""C:\Debuggers\windbg.exe" -p 1234 -e 5678 -g"# + ); + } + + #[test] + fn visual_studio_jit_template_and_literal_percent_are_supported() { + let template = + r#""C:\Windows\System32\vsjitdebugger.exe" -p %ld -e %ld --label 100%%"#; + assert_eq!( + format_debugger_template(template, 42, 99).unwrap(), + r#""C:\Windows\System32\vsjitdebugger.exe" -p 42 -e 99 --label 100%"# + ); + } + + #[test] + fn unsupported_or_ambiguous_templates_are_rejected() { + assert_eq!( + parse_expanded_debugger_command( + r#""C:\Debuggers\dbg.exe" -p %ld -e %ld -j 0x%p"# + ), + Err(ERROR_NOT_SUPPORTED) + ); + for template in [ + r#""C:\Debuggers\dbg.exe" -p %ld"#, + r#""C:\Debuggers\dbg.exe" -p %ld -e %ld -x %ld"#, + r#""C:\Debuggers\dbg.exe" -p %q -e %ld"#, + ] { + assert!(parse_expanded_debugger_command(template).is_err()); + } + assert!( + parse_expanded_debugger_command( + r#""relative\dbg.exe" -p %ld -e %ld"# + ) + .unwrap() + .is_none() + ); + } + + #[test] + fn target_machine_selects_the_matching_registry_view() { + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_I386, + IMAGE_FILE_MACHINE_AMD64, + ) + .unwrap(), + DebuggerRegistryView::Registry32 + ); + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_UNKNOWN, + IMAGE_FILE_MACHINE_AMD64, + ) + .unwrap(), + DebuggerRegistryView::Registry64 + ); + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_UNKNOWN, + IMAGE_FILE_MACHINE_I386, + ) + .unwrap(), + DebuggerRegistryView::Native + ); + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_ARMNT, + IMAGE_FILE_MACHINE_ARM64, + ) + .unwrap(), + DebuggerRegistryView::Registry32 + ); + } +} + +''', +) + +print("Applied root-cause fixes for issues #19, #20, and #21.") From 861b9698d71d28fd3e1d8000f4e929ff1946350a Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:12:36 +0800 Subject: [PATCH 02/11] Add temporary Windows validation workflow --- .../agent-apply-open-issue-fixes.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/agent-apply-open-issue-fixes.yml diff --git a/.github/workflows/agent-apply-open-issue-fixes.yml b/.github/workflows/agent-apply-open-issue-fixes.yml new file mode 100644 index 0000000..066d86e --- /dev/null +++ b/.github/workflows/agent-apply-open-issue-fixes.yml @@ -0,0 +1,81 @@ +# Temporary branch-only workflow. It applies the reviewed root-cause patch, validates it on the +# repository's native Windows toolchain, and removes itself before publishing the final commit. +name: Apply open issue root-cause fixes + +on: + push: + branches: + - agent/fix-open-issue-root-causes + paths: + - scripts/agent_fix_open_issues.py + - .github/workflows/agent-apply-open-issue-fixes.yml + +permissions: + contents: write + +concurrency: + group: agent-open-issue-fixes-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: pwsh + +jobs: + apply-and-validate: + if: github.actor != 'github-actions[bot]' + runs-on: windows-2025-vs2026 + timeout-minutes: 45 + + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Apply reviewed source transformations + run: python scripts/agent_fix_open_issues.py + + - name: Install Rust toolchain + run: | + rustup set profile minimal + rustup toolchain install stable ` + --component rustfmt ` + --component clippy ` + --target x86_64-pc-windows-msvc + rustup show active-toolchain + + - name: Format source + run: cargo fmt --all + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Check all targets + run: cargo check --all-targets --locked + + - name: Run strict Clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Run all tests + run: cargo test --all-targets --locked + + - name: Build release executable + run: cargo build --release --locked + + - name: Check whitespace + run: git diff --check + + - name: Publish final source-only commit + run: | + Remove-Item scripts/agent_fix_open_issues.py + Remove-Item .github/workflows/agent-apply-open-issue-fixes.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + if (git diff --cached --quiet) { + throw "The validated transformation produced no source changes." + } + git commit -m "Fix open issue root causes" + git push origin "HEAD:$env:GITHUB_REF_NAME" From db5577365e7568edf9be7d030e1c65a45004187f Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:19:11 +0800 Subject: [PATCH 03/11] Fix CI lint staging for issue root-cause patch --- scripts/agent_fix_ci_lints.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 scripts/agent_fix_ci_lints.py diff --git a/scripts/agent_fix_ci_lints.py b/scripts/agent_fix_ci_lints.py new file mode 100644 index 0000000..2248f78 --- /dev/null +++ b/scripts/agent_fix_ci_lints.py @@ -0,0 +1,30 @@ +from pathlib import Path + + +ACTIONS = Path("src/pages/processes/actions.rs") +text = ACTIONS.read_text(encoding="utf-8") + +helper = "pub(super) fn normalize_debugger_command_with(" +if text.count(helper) != 1: + raise RuntimeError(f"expected one debugger compatibility helper, found {text.count(helper)}") +text = text.replace(helper, "#[cfg(test)]\n" + helper, 1) + +module_start = "#[cfg(test)]\nmod debugger_tests {" +production_marker = ( + "\n// 先构建完整的已验证句柄集合,再把不可逆的终止阶段与 UI 结果呈现分离。" +) +start = text.find(module_start) +if start < 0: + raise RuntimeError("debugger test module start not found") +end = text.find(production_marker, start) +if end < 0: + raise RuntimeError("production marker after debugger tests not found") +if text.find(module_start, start + len(module_start)) >= 0: + raise RuntimeError("debugger test module start is not unique") + +test_module = text[start:end].rstrip() +text = text[:start] + text[end:] +text = text.rstrip() + "\n\n" + test_module + "\n" +ACTIONS.write_text(text, encoding="utf-8", newline="\n") + +print("Adjusted staged debugger tests for strict Clippy.") From d28715e303d897358d65379081189a5c4778ca56 Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:19:22 +0800 Subject: [PATCH 04/11] Rerun Windows validation after strict Clippy fixes --- .github/workflows/agent-apply-open-issue-fixes.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-apply-open-issue-fixes.yml b/.github/workflows/agent-apply-open-issue-fixes.yml index 066d86e..00f1bc5 100644 --- a/.github/workflows/agent-apply-open-issue-fixes.yml +++ b/.github/workflows/agent-apply-open-issue-fixes.yml @@ -8,6 +8,7 @@ on: - agent/fix-open-issue-root-causes paths: - scripts/agent_fix_open_issues.py + - scripts/agent_fix_ci_lints.py - .github/workflows/agent-apply-open-issue-fixes.yml permissions: @@ -34,7 +35,9 @@ jobs: fetch-depth: 0 - name: Apply reviewed source transformations - run: python scripts/agent_fix_open_issues.py + run: | + python scripts/agent_fix_open_issues.py + python scripts/agent_fix_ci_lints.py - name: Install Rust toolchain run: | @@ -69,6 +72,7 @@ jobs: - name: Publish final source-only commit run: | Remove-Item scripts/agent_fix_open_issues.py + Remove-Item scripts/agent_fix_ci_lints.py Remove-Item .github/workflows/agent-apply-open-issue-fixes.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 8cb29cdee35dbd1d3f94518e8b6ac71340e5f859 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:23:48 +0000 Subject: [PATCH 05/11] Fix open issue root causes --- .../agent-apply-open-issue-fixes.yml | 85 - scripts/agent_fix_ci_lints.py | 30 - scripts/agent_fix_open_issues.py | 1483 ----------------- src/config/options.rs | 384 ++++- src/pages/network.rs | 155 +- src/pages/processes/actions.rs | 637 +++++-- 6 files changed, 927 insertions(+), 1847 deletions(-) delete mode 100644 .github/workflows/agent-apply-open-issue-fixes.yml delete mode 100644 scripts/agent_fix_ci_lints.py delete mode 100644 scripts/agent_fix_open_issues.py diff --git a/.github/workflows/agent-apply-open-issue-fixes.yml b/.github/workflows/agent-apply-open-issue-fixes.yml deleted file mode 100644 index 00f1bc5..0000000 --- a/.github/workflows/agent-apply-open-issue-fixes.yml +++ /dev/null @@ -1,85 +0,0 @@ -# Temporary branch-only workflow. It applies the reviewed root-cause patch, validates it on the -# repository's native Windows toolchain, and removes itself before publishing the final commit. -name: Apply open issue root-cause fixes - -on: - push: - branches: - - agent/fix-open-issue-root-causes - paths: - - scripts/agent_fix_open_issues.py - - scripts/agent_fix_ci_lints.py - - .github/workflows/agent-apply-open-issue-fixes.yml - -permissions: - contents: write - -concurrency: - group: agent-open-issue-fixes-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: pwsh - -jobs: - apply-and-validate: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025-vs2026 - timeout-minutes: 45 - - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Apply reviewed source transformations - run: | - python scripts/agent_fix_open_issues.py - python scripts/agent_fix_ci_lints.py - - - name: Install Rust toolchain - run: | - rustup set profile minimal - rustup toolchain install stable ` - --component rustfmt ` - --component clippy ` - --target x86_64-pc-windows-msvc - rustup show active-toolchain - - - name: Format source - run: cargo fmt --all - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Check all targets - run: cargo check --all-targets --locked - - - name: Run strict Clippy - run: cargo clippy --all-targets --locked -- -D warnings - - - name: Run all tests - run: cargo test --all-targets --locked - - - name: Build release executable - run: cargo build --release --locked - - - name: Check whitespace - run: git diff --check - - - name: Publish final source-only commit - run: | - Remove-Item scripts/agent_fix_open_issues.py - Remove-Item scripts/agent_fix_ci_lints.py - Remove-Item .github/workflows/agent-apply-open-issue-fixes.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - if (git diff --cached --quiet) { - throw "The validated transformation produced no source changes." - } - git commit -m "Fix open issue root causes" - git push origin "HEAD:$env:GITHUB_REF_NAME" diff --git a/scripts/agent_fix_ci_lints.py b/scripts/agent_fix_ci_lints.py deleted file mode 100644 index 2248f78..0000000 --- a/scripts/agent_fix_ci_lints.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path - - -ACTIONS = Path("src/pages/processes/actions.rs") -text = ACTIONS.read_text(encoding="utf-8") - -helper = "pub(super) fn normalize_debugger_command_with(" -if text.count(helper) != 1: - raise RuntimeError(f"expected one debugger compatibility helper, found {text.count(helper)}") -text = text.replace(helper, "#[cfg(test)]\n" + helper, 1) - -module_start = "#[cfg(test)]\nmod debugger_tests {" -production_marker = ( - "\n// 先构建完整的已验证句柄集合,再把不可逆的终止阶段与 UI 结果呈现分离。" -) -start = text.find(module_start) -if start < 0: - raise RuntimeError("debugger test module start not found") -end = text.find(production_marker, start) -if end < 0: - raise RuntimeError("production marker after debugger tests not found") -if text.find(module_start, start + len(module_start)) >= 0: - raise RuntimeError("debugger test module start is not unique") - -test_module = text[start:end].rstrip() -text = text[:start] + text[end:] -text = text.rstrip() + "\n\n" + test_module + "\n" -ACTIONS.write_text(text, encoding="utf-8", newline="\n") - -print("Adjusted staged debugger tests for strict Clippy.") diff --git a/scripts/agent_fix_open_issues.py b/scripts/agent_fix_open_issues.py deleted file mode 100644 index 7697705..0000000 --- a/scripts/agent_fix_open_issues.py +++ /dev/null @@ -1,1483 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8", newline="\n") - - -def replace_once(path: str, old: str, new: str) -> None: - text = read(path) - count = text.count(old) - if count != 1: - raise RuntimeError(f"{path}: expected one match, found {count}: {old[:120]!r}") - write(path, text.replace(old, new, 1)) - - -def replace_between(path: str, start: str, end: str, replacement: str) -> None: - text = read(path) - start_index = text.find(start) - if start_index < 0: - raise RuntimeError(f"{path}: start marker not found: {start!r}") - end_index = text.find(end, start_index + len(start)) - if end_index < 0: - raise RuntimeError(f"{path}: end marker not found: {end!r}") - if text.find(start, start_index + len(start)) >= 0: - raise RuntimeError(f"{path}: start marker is not unique: {start!r}") - write(path, text[:start_index] + replacement + text[end_index:]) - - -# --------------------------------------------------------------------------- -# #19: isolate taskmgr-rs options from the native Task Manager Preferences. -# --------------------------------------------------------------------------- - -OPTIONS = "src/config/options.rs" - -replace_once( - OPTIONS, - 'use windows_sys::Win32::Foundation::{ERROR_REVISION_MISMATCH, ERROR_SUCCESS, RECT};', - '''use windows_sys::Win32::Foundation::{ - ERROR_FILE_NOT_FOUND, ERROR_INVALID_DATA, ERROR_REVISION_MISMATCH, ERROR_SUCCESS, RECT, -};''', -) - -replace_once( - OPTIONS, - '''const TASKMAN_KEY: &str = "Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\TaskManager"; -const OPTIONS_KEY: &str = "Preferences"; -const OPTIONS_SCHEMA_VERSION: i32 = 2;''', - '''const OPTIONS_KEY: &str = "Software\\\\taskmgr-rs\\\\TaskManager"; -const OPTIONS_VALUE: &str = "OptionsV1"; -const LEGACY_TASKMAN_KEY: &str = - "Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\TaskManager"; -const LEGACY_OPTIONS_VALUE: &str = "Preferences"; -const OPTIONS_STORAGE_MAGIC: [u8; 8] = *b"TMGRRS01"; -const OPTIONS_STORAGE_VERSION: u32 = 1; -const OPTIONS_SCHEMA_VERSION: i32 = 2;''', -) - -replace_once( - OPTIONS, - '''pub struct Options { - // 该结构体会按二进制整体落盘到注册表,因此字段顺序和类型都需要保持稳定。 - pub cb_size: u32, - pub timer_interval: u32, - pub view_mode: i32, - pub cpu_history_mode: i32, - pub update_speed: i32, - pub window_rect: RECT, - pub current_page: i32, - pub active_process_columns: [i32; NUM_COLUMN + 1], - pub column_widths: [i32; NUM_COLUMN + 1], - flags: u32, - pub unused: i32, - pub unused2: i32, -} - -impl Default for Options {''', - '''pub struct Options { - // 该结构体会按二进制整体落盘到注册表,因此字段顺序和类型都需要保持稳定。 - pub cb_size: u32, - pub timer_interval: u32, - pub view_mode: i32, - pub cpu_history_mode: i32, - pub update_speed: i32, - pub window_rect: RECT, - pub current_page: i32, - pub active_process_columns: [i32; NUM_COLUMN + 1], - pub column_widths: [i32; NUM_COLUMN + 1], - flags: u32, - pub unused: i32, - pub unused2: i32, -} - -#[repr(C)] -#[derive(Clone, Copy)] -struct StoredOptions { - magic: [u8; 8], - storage_version: u32, - payload_size: u32, - options: Options, -} - -impl StoredOptions { - fn new(options: Options) -> Self { - Self { - magic: OPTIONS_STORAGE_MAGIC, - storage_version: OPTIONS_STORAGE_VERSION, - payload_size: size_of::() as u32, - options, - } - } - - fn into_options(self) -> Result { - if self.magic != OPTIONS_STORAGE_MAGIC - || self.payload_size != size_of::() as u32 - { - return Err(ERROR_INVALID_DATA); - } - if self.storage_version != OPTIONS_STORAGE_VERSION { - return Err(ERROR_REVISION_MISMATCH); - } - Ok(self.options) - } -} - -/// Marker for fixed-layout registry values whose every bit pattern is a valid Rust value. -/// -/// # Safety -/// -/// Implementors must be `Copy`, have a stable layout, contain no references, and permit every -/// possible byte pattern. This lets the registry reader initialize the value as zeroed storage -/// before Windows fills every byte. -unsafe trait RegistryPod: Copy {} - -unsafe impl RegistryPod for Options {} -unsafe impl RegistryPod for StoredOptions {} - -impl Default for Options {''', -) - -replace_between( - OPTIONS, - ' pub fn load(&mut self, min_width: i32, min_height: i32) -> bool {', - ' pub fn minimize_on_use(&self) -> bool {', - r''' pub fn load(&mut self, min_width: i32, min_height: i32) -> bool { - // taskmgr-rs owns a versioned application-specific value. The Microsoft Task Manager key - // is read only for a one-time migration of values that carry taskmgr-rs' exact schema - // marker; it is never written or deleted. - if modifiers_force_defaults() { - self.set_default_values(min_width, min_height); - return false; - } - - match read_registry_binary::(OPTIONS_KEY, OPTIONS_VALUE) { - Ok(Some(stored)) => { - let loaded = match stored.into_options() { - Ok(loaded) => loaded, - Err(error) => { - record_win32_error("taskmgr-rs options envelope", error); - self.set_default_values(min_width, min_height); - return false; - } - }; - return self.apply_loaded_options(loaded, min_width, min_height, false); - } - Ok(None) => {} - Err(error) => { - record_win32_error("reading taskmgr-rs options", error); - self.set_default_values(min_width, min_height); - return false; - } - } - - match read_registry_binary::(LEGACY_TASKMAN_KEY, LEGACY_OPTIONS_VALUE) { - Ok(Some(legacy)) if legacy_options_is_taskmgr_rs(&legacy) => { - self.apply_loaded_options(legacy, min_width, min_height, true) - } - Ok(Some(_)) | Ok(None) => { - self.set_default_values(min_width, min_height); - false - } - Err(error) => { - record_win32_error("reading legacy taskmgr-rs options", error); - self.set_default_values(min_width, min_height); - false - } - } - } - - fn apply_loaded_options( - &mut self, - mut loaded: Options, - min_width: i32, - min_height: i32, - persist_migration: bool, - ) -> bool { - let migrated = match loaded.migrate_schema() { - Ok(migrated) => migrated, - Err(()) => { - record_win32_error("unsupported options schema", ERROR_REVISION_MISMATCH); - self.set_default_values(min_width, min_height); - return false; - } - }; - let loaded_was_valid = loaded.is_valid(min_width, min_height); - if !loaded_was_valid { - loaded.normalize(min_width, min_height); - } - *self = loaded; - if (persist_migration || migrated || !loaded_was_valid) - && let Err(error) = self.save() - { - record_win32_error("normalized options persistence", error); - } - loaded_was_valid - } - - pub fn save(&self) -> Result<(), u32> { - let stored = StoredOptions::new(*self); - write_registry_binary(OPTIONS_KEY, OPTIONS_VALUE, &stored) - } - -''', -) - -replace_once( - OPTIONS, - 'fn modifiers_force_defaults() -> bool {', - r'''fn legacy_options_is_taskmgr_rs(options: &Options) -> bool { - options.cb_size == size_of::() as u32 - && options.unused == OPTIONS_SCHEMA_VERSION - && options.unused2 == 0 - && options.current_page >= -1 - && options.current_page < PageId::COUNT as i32 - && is_valid_view_mode(options.view_mode) - && is_valid_cpu_history_mode(options.cpu_history_mode) - && is_valid_update_speed(options.update_speed) - && options.flags & !ALL_VALID_FLAGS == 0 - && process_columns_are_valid(&options.active_process_columns, &options.column_widths) -} - -fn read_registry_binary( - key_path: &str, - value_name: &str, -) -> Result, u32> { - unsafe { - let key_path = to_wide_null(key_path); - let value_name = to_wide_null(value_name); - let mut key: HKEY = null_mut(); - let open_status = - RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut key); - if open_status == ERROR_FILE_NOT_FOUND { - return Ok(None); - } - if open_status != ERROR_SUCCESS { - return Err(open_status); - } - - let mut value_type = 0u32; - let mut value_size = 0u32; - let size_status = RegQueryValueExW( - key, - value_name.as_ptr(), - null_mut(), - &mut value_type, - null_mut(), - &mut value_size, - ); - if size_status == ERROR_FILE_NOT_FOUND { - let close_status = RegCloseKey(key); - return if close_status == ERROR_SUCCESS { - Ok(None) - } else { - Err(close_status) - }; - } - if size_status != ERROR_SUCCESS { - RegCloseKey(key); - return Err(size_status); - } - if value_type != REG_BINARY || value_size != size_of::() as u32 { - RegCloseKey(key); - return Err(ERROR_INVALID_DATA); - } - - // SAFETY: RegistryPod requires every bit pattern to be valid. The exact-size check above - // and the second query's unchanged byte count guarantee that Windows initializes all bytes. - let mut value = zeroed::(); - let mut actual_size = value_size; - let read_status = RegQueryValueExW( - key, - value_name.as_ptr(), - null_mut(), - &mut value_type, - (&mut value as *mut T).cast::(), - &mut actual_size, - ); - let close_status = RegCloseKey(key); - if read_status != ERROR_SUCCESS { - return Err(read_status); - } - if close_status != ERROR_SUCCESS { - return Err(close_status); - } - if value_type != REG_BINARY || actual_size != size_of::() as u32 { - return Err(ERROR_INVALID_DATA); - } - Ok(Some(value)) - } -} - -fn write_registry_binary( - key_path: &str, - value_name: &str, - value: &T, -) -> Result<(), u32> { - unsafe { - let key_path = to_wide_null(key_path); - let value_name = to_wide_null(value_name); - let mut key: HKEY = null_mut(); - let mut disposition = 0u32; - let create_status = RegCreateKeyExW( - HKEY_CURRENT_USER, - key_path.as_ptr(), - 0, - null_mut(), - REG_OPTION_NON_VOLATILE, - KEY_WRITE, - null_mut(), - &mut key, - &mut disposition, - ); - if create_status != ERROR_SUCCESS { - return Err(create_status); - } - - // SAFETY: RegistryPod has a fixed initialized representation and contains no references. - let bytes = - std::slice::from_raw_parts((value as *const T).cast::(), size_of::()); - let set_status = RegSetValueExW( - key, - value_name.as_ptr(), - 0, - REG_BINARY, - bytes.as_ptr(), - bytes.len() as u32, - ); - let close_status = RegCloseKey(key); - if set_status != ERROR_SUCCESS { - Err(set_status) - } else if close_status != ERROR_SUCCESS { - Err(close_status) - } else { - Ok(()) - } - } -} - -fn modifiers_force_defaults() -> bool {''', -) - -replace_once( - OPTIONS, - ''' use super::{ - ColumnId, NUM_COLUMN, OPTIONS_SCHEMA_VERSION, Options, SCHEMA_0_NETWORK_PAGE, - SCHEMA_0_USERS_PAGE, SCHEMA_1_GPU_PAGE, SCHEMA_1_NETWORK_PAGE, SCHEMA_1_USERS_PAGE, - UpdateSpeed, normalize_process_columns, process_columns_are_valid, - update_speed_timer_interval, window_rect_dimensions_are_valid, window_rect_is_valid, - };''', - ''' use super::{ - ColumnId, ERROR_INVALID_DATA, ERROR_REVISION_MISMATCH, LEGACY_OPTIONS_VALUE, - LEGACY_TASKMAN_KEY, NUM_COLUMN, OPTIONS_KEY, OPTIONS_SCHEMA_VERSION, - OPTIONS_STORAGE_MAGIC, OPTIONS_STORAGE_VERSION, OPTIONS_VALUE, Options, - SCHEMA_0_NETWORK_PAGE, SCHEMA_0_USERS_PAGE, SCHEMA_1_GPU_PAGE, SCHEMA_1_NETWORK_PAGE, - SCHEMA_1_USERS_PAGE, StoredOptions, UpdateSpeed, legacy_options_is_taskmgr_rs, - normalize_process_columns, process_columns_are_valid, update_speed_timer_interval, - window_rect_dimensions_are_valid, window_rect_is_valid, - };''', -) - -replace_once( - OPTIONS, - ''' #[test] - fn process_columns_reject_missing_primary_and_duplicates() {''', - r''' #[test] - fn stored_options_envelope_round_trips_and_rejects_foreign_data() { - let options = Options::default(); - assert_eq!( - StoredOptions::new(options).into_options().unwrap().unused, - options.unused - ); - - let mut foreign = StoredOptions::new(options); - foreign.magic = *b"NATIVE00"; - assert_eq!(foreign.into_options().err(), Some(ERROR_INVALID_DATA)); - - let mut future = StoredOptions::new(options); - future.storage_version = OPTIONS_STORAGE_VERSION + 1; - assert_eq!( - future.into_options().err(), - Some(ERROR_REVISION_MISMATCH) - ); - - let mut wrong_size = StoredOptions::new(options); - wrong_size.payload_size = 0; - assert_eq!(wrong_size.into_options().err(), Some(ERROR_INVALID_DATA)); - assert_eq!(OPTIONS_STORAGE_MAGIC, *b"TMGRRS01"); - } - - #[test] - fn legacy_migration_requires_the_exact_taskmgr_rs_schema_marker() { - let current = Options::default(); - assert!(legacy_options_is_taskmgr_rs(¤t)); - - for schema in [0, 1, OPTIONS_SCHEMA_VERSION + 1] { - let candidate = Options { - unused: schema, - ..current - }; - assert!(!legacy_options_is_taskmgr_rs(&candidate)); - } - - let wrong_size = Options { - cb_size: 0, - ..current - }; - assert!(!legacy_options_is_taskmgr_rs(&wrong_size)); - } - - #[test] - fn application_options_namespace_is_distinct_from_windows_task_manager() { - assert_ne!(OPTIONS_KEY, LEGACY_TASKMAN_KEY); - assert_ne!(OPTIONS_VALUE, LEGACY_OPTIONS_VALUE); - } - - #[test] - fn process_columns_reject_missing_primary_and_duplicates() {''', -) - -# --------------------------------------------------------------------------- -# #20: directional network capacities and explicit total-utilization semantics. -# --------------------------------------------------------------------------- - -NETWORK = "src/pages/network.rs" - -replace_once( - NETWORK, - ''' link_speed_bps: u64, - bytes_sent: u64,''', - ''' transmit_link_speed_bps: u64, - receive_link_speed_bps: u64, - bytes_sent: u64,''', -) - -replace_once( - NETWORK, - ''' // A zero curve point marks an unavailable interval after first sight or counter - // reset; the textual value remains "-" so it is not presented as measured idle. - let total_delta = counter_delta.map_or(0, |delta| delta.2); - let sent_util = counter_delta.map_or(0, |delta| { - utilization_percent_for_history(delta.0, raw_adapter.link_speed_bps, elapsed_secs) - }); - let received_util = counter_delta.map_or(0, |delta| { - utilization_percent_for_history(delta.1, raw_adapter.link_speed_bps, elapsed_secs) - }); - let total_util = counter_delta.map_or(0, |delta| { - utilization_percent_for_history(delta.2, raw_adapter.link_speed_bps, elapsed_secs) - }); - - push_history(&mut sent_history, sent_util); - push_history(&mut received_history, received_util); - push_history(&mut total_history, total_util);''', - ''' // Each direction owns an independent full-duplex capacity. "Total" is the - // busiest direction's percentage, which remains meaningful for asymmetric links and - // cannot fabricate a 200% value by summing independent capacities. - let (sent_ratio, received_ratio, total_ratio) = counter_delta.map_or( - (None, None, None), - |delta| { - directional_utilization_ratios( - delta.0, - delta.1, - raw_adapter.transmit_link_speed_bps, - raw_adapter.receive_link_speed_bps, - elapsed_secs, - ) - }, - ); - - push_history( - &mut sent_history, - utilization_percent_for_history(sent_ratio), - ); - push_history( - &mut received_history, - utilization_percent_for_history(received_ratio), - ); - push_history( - &mut total_history, - utilization_percent_for_history(total_ratio), - );''', -) - -replace_once( - NETWORK, - ''' link_speed: format_link_speed(raw_adapter.link_speed_bps), - utilization: counter_delta - .map(|_| { - utilization_text(total_delta, raw_adapter.link_speed_bps, elapsed_secs) - }) - .unwrap_or_else(|| "-".to_string()),''', - ''' link_speed: format_link_speeds( - raw_adapter.transmit_link_speed_bps, - raw_adapter.receive_link_speed_bps, - ), - utilization: utilization_text(total_ratio),''', -) - -replace_once( - NETWORK, - ''' link_speed_bps: row.ReceiveLinkSpeed.max(row.TransmitLinkSpeed), - bytes_sent: row.OutOctets,''', - ''' transmit_link_speed_bps: row.TransmitLinkSpeed, - receive_link_speed_bps: row.ReceiveLinkSpeed, - bytes_sent: row.OutOctets,''', -) - -replace_once( - NETWORK, - ' u8::from(adapter.link_speed_bps != 0),', - ''' u8::from( - adapter.transmit_link_speed_bps != 0 || adapter.receive_link_speed_bps != 0, - ),''', -) - -replace_between( - NETWORK, - '''fn utilization_ratio_percent( - bytes_per_interval: u64, - link_speed_bps: u64, - elapsed_secs: f64, -) -> Option {''', - 'fn format_counter(value: u64) -> String {', - r'''fn utilization_ratio_percent( - bytes_per_interval: u64, - link_speed_bps: u64, - elapsed_secs: f64, -) -> Option { - if link_speed_bps == 0 || !elapsed_secs.is_finite() || elapsed_secs <= 0.0 { - return None; - } - if bytes_per_interval == 0 { - return Some(0.0); - } - - let bits_per_second = (bytes_per_interval as f64 * 8.0) / elapsed_secs; - let percent = (bits_per_second * 100.0) / link_speed_bps as f64; - percent.is_finite().then(|| percent.clamp(0.0, 100.0)) -} - -fn directional_utilization_ratios( - sent_bytes: u64, - received_bytes: u64, - transmit_link_speed_bps: u64, - receive_link_speed_bps: u64, - elapsed_secs: f64, -) -> (Option, Option, Option) { - let sent = - utilization_ratio_percent(sent_bytes, transmit_link_speed_bps, elapsed_secs); - let received = - utilization_ratio_percent(received_bytes, receive_link_speed_bps, elapsed_secs); - let total = match (sent, received) { - (Some(sent), Some(received)) => Some(sent.max(received)), - (Some(sent), None) => Some(sent), - (None, Some(received)) => Some(received), - (None, None) => None, - }; - (sent, received, total) -} - -fn utilization_percent_for_history(ratio_percent: Option) -> u8 { - let Some(ratio_percent) = ratio_percent else { - return 0; - }; - - let rounded = ratio_percent.round().clamp(0.0, 100.0) as u8; - if rounded == 0 && ratio_percent > 0.0 { - 1 - } else { - rounded - } -} - -fn utilization_text(ratio_percent: Option) -> String { - let Some(ratio_percent) = ratio_percent else { - return "-".to_string(); - }; - - if ratio_percent > 0.0 && ratio_percent < 1.0 { - "<1%".to_string() - } else { - format!("{}%", ratio_percent.round().clamp(0.0, 100.0) as u8) - } -} - -fn format_link_speeds(transmit_bits_per_second: u64, receive_bits_per_second: u64) -> String { - match (transmit_bits_per_second, receive_bits_per_second) { - (0, 0) => "-".to_string(), - (transmit, receive) if transmit == receive => format_link_speed(transmit), - (transmit, receive) => format!( - "Tx {} / Rx {}", - format_link_speed(transmit), - format_link_speed(receive) - ), - } -} - -fn format_link_speed(bits_per_second: u64) -> String { - // 链路速率采用十进制网络单位显示,更符合网卡/交换机常见标注方式。 - if bits_per_second == 0 { - return "-".to_string(); - } - - let units = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"]; - let mut value = bits_per_second as f64; - let mut unit = 0usize; - while value >= 1000.0 && unit + 1 < units.len() { - value /= 1000.0; - unit += 1; - } - - if value >= 100.0 || unit == 0 { - format!("{value:.0} {}", units[unit]) - } else { - format!("{value:.1} {}", units[unit]) - } -} - -''', -) - -replace_once( - NETWORK, - ''' #[test] - fn adapter_counter_delta_rejects_missing_regressed_and_overflowed_intervals() {''', - r''' #[test] - fn asymmetric_links_use_each_direction_capacity() { - let (sent, received, total) = - directional_utilization_ratios(1_250_000, 0, 10_000_000, 100_000_000, 1.0); - assert_eq!(sent, Some(100.0)); - assert_eq!(received, Some(0.0)); - assert_eq!(total, Some(100.0)); - - let (sent, received, total) = - directional_utilization_ratios(0, 1_250_000, 100_000_000, 10_000_000, 1.0); - assert_eq!(sent, Some(0.0)); - assert_eq!(received, Some(100.0)); - assert_eq!(total, Some(100.0)); - } - - #[test] - fn full_duplex_total_is_the_busiest_direction_not_a_sum() { - let (_, _, total) = - directional_utilization_ratios(12_500_000, 12_500_000, 100_000_000, 100_000_000, 1.0); - assert_eq!(total, Some(100.0)); - } - - #[test] - fn unavailable_direction_does_not_poison_the_other_direction() { - let (sent, received, total) = - directional_utilization_ratios(0, 1_250_000, 0, 10_000_000, 1.0); - assert_eq!(sent, None); - assert_eq!(received, Some(100.0)); - assert_eq!(total, Some(100.0)); - assert_eq!(utilization_text(None), "-"); - assert_eq!(utilization_text(Some(0.0)), "0%"); - } - - #[test] - fn asymmetric_link_speed_text_preserves_both_capacities() { - assert_eq!( - format_link_speeds(10_000_000, 100_000_000), - "Tx 10.0 Mbps / Rx 100 Mbps" - ); - assert_eq!( - format_link_speeds(1_000_000_000, 1_000_000_000), - "1.0 Gbps" - ); - } - - #[test] - fn adapter_counter_delta_rejects_missing_regressed_and_overflowed_intervals() {''', -) - -# --------------------------------------------------------------------------- -# #21: preserve AeDebug templates, choose the target registry view, and inherit -# only the debugger-ready event handle. -# --------------------------------------------------------------------------- - -ACTIONS = "src/pages/processes/actions.rs" - -replace_once( - ACTIONS, - '''use std::collections::{HashMap, HashSet}; -use std::mem::{size_of, zeroed}; -use std::path::Path; -use std::ptr::{null, null_mut};''', - '''use std::collections::{HashMap, HashSet}; -use std::ffi::c_void; -use std::mem::{size_of, zeroed}; -use std::path::Path; -use std::ptr::{null, null_mut};''', -) - -replace_once( - ACTIONS, - '''use windows_sys::Win32::Foundation::{ - ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, - ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_NO_MORE_FILES, - ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, FILETIME, GetLastError, HANDLE, HWND, LPARAM, - WAIT_OBJECT_0, WPARAM, -};''', - '''use windows_sys::Win32::Foundation::{ - ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, - ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, - ERROR_NO_MORE_FILES, ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, FILETIME, GetLastError, HANDLE, - HWND, LPARAM, WAIT_OBJECT_0, WPARAM, -};''', -) - -replace_once( - ACTIONS, - '''use windows_sys::Win32::System::Registry::{ - HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegOpenKeyExW, - RegQueryValueExW, -};''', - '''use windows_sys::Win32::System::Registry::{ - HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, REG_EXPAND_SZ, REG_SZ, - RegCloseKey, RegOpenKeyExW, RegQueryValueExW, -};''', -) - -replace_once( - ACTIONS, - '''use windows_sys::Win32::System::SystemInformation::{ - GROUP_AFFINITY, GetSystemTimeAsFileTime, GetWindowsDirectoryW, -};''', - '''use windows_sys::Win32::System::SystemInformation::{ - GROUP_AFFINITY, GetSystemTimeAsFileTime, GetWindowsDirectoryW, IMAGE_FILE_MACHINE_AMD64, - IMAGE_FILE_MACHINE_ARM, IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_ARMNT, - IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_IA64, IMAGE_FILE_MACHINE_THUMB, - IMAGE_FILE_MACHINE_UNKNOWN, -};''', -) - -replace_once( - ACTIONS, - '''use windows_sys::Win32::System::Threading::{ - ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CreateProcessW, - GetProcessAffinityMask, GetProcessGroupAffinity, GetProcessIdOfThread, GetThreadGroupAffinity, - HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, OpenThread, - PROCESS_INFORMATION, PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, - PROCESS_SET_INFORMATION, PROCESS_SET_LIMITED_INFORMATION, PROCESS_TERMINATE, - QueryFullProcessImageNameW, REALTIME_PRIORITY_CLASS, STARTUPINFOW, SetPriorityClass, - SetProcessAffinityMask, SetProcessDefaultCpuSets, SetThreadGroupAffinity, - THREAD_QUERY_LIMITED_INFORMATION, THREAD_SET_INFORMATION, TerminateProcess, - WaitForSingleObject, -};''', - '''use windows_sys::Win32::System::Threading::{ - ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CREATE_NEW_CONSOLE, CreateEventW, - CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, - GetProcessAffinityMask, GetProcessGroupAffinity, GetProcessIdOfThread, GetThreadGroupAffinity, - HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, InitializeProcThreadAttributeList, IsWow64Process2, - LPPROC_THREAD_ATTRIBUTE_LIST, NORMAL_PRIORITY_CLASS, OpenThread, PROCESS_INFORMATION, - PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_INFORMATION, - PROCESS_SET_LIMITED_INFORMATION, PROCESS_TERMINATE, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, - QueryFullProcessImageNameW, REALTIME_PRIORITY_CLASS, STARTUPINFOEXW, STARTUPINFOW, - SetPriorityClass, SetProcessAffinityMask, SetProcessDefaultCpuSets, SetThreadGroupAffinity, - THREAD_QUERY_LIMITED_INFORMATION, THREAD_SET_INFORMATION, TerminateProcess, - UpdateProcThreadAttribute, WaitForSingleObject, -};''', -) - -replace_once( - ACTIONS, - 'use windows_sys::Win32::UI::Controls::{', - '''use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; -use windows_sys::Win32::UI::Controls::{''', -) - -replace_between( - ACTIONS, - ''' // 以 AeDebug 注册表配置的调试器启动并附加到目标进程。命令行传 -p 。 - pub(super) fn attach_debugger(&mut self, identity: ProcIdentity) -> bool {''', - ' // 通过 explorer.exe /select 命令在资源管理器中定位进程的可执行文件。', - r''' // 使用目标进程位数对应的 AeDebug 命令模板启动调试器。完整模板中的第一个 - // `%ld` 接收 PID,第二个接收唯一继承给调试器的 ready-event 句柄。 - pub(super) fn attach_debugger(&mut self, identity: ProcIdentity) -> bool { - if !self.quick_confirm(&self.strings.warning, &self.strings.debug) { - return false; - } - - let target_handle = - match open_process_for_identity(identity, PROCESS_QUERY_LIMITED_INFORMATION) { - Ok(handle) => handle, - Err(error) => { - self.show_failure_message(&self.strings.cant_debug, error); - return false; - } - }; - let registry_view = match debugger_registry_view_for_process(target_handle.as_raw()) { - Ok(view) => view, - Err(error) => { - self.show_failure_message(&self.strings.cant_debug, error); - return false; - } - }; - let debugger = match load_debugger_command(registry_view) { - Ok(Some(debugger)) => debugger, - Ok(None) => { - self.show_failure_message(&self.strings.cant_debug, ERROR_FILE_NOT_FOUND); - return false; - } - Err(error) => { - self.show_failure_message(&self.strings.cant_debug, error); - return false; - } - }; - if !Path::new(&debugger.executable).is_file() { - self.show_failure_message(&self.strings.cant_debug, ERROR_FILE_NOT_FOUND); - return false; - } - - let security = SECURITY_ATTRIBUTES { - nLength: size_of::() as u32, - lpSecurityDescriptor: null_mut(), - bInheritHandle: 1, - }; - // SAFETY: `security` remains live for the synchronous call and requests one unnamed, - // nonsignaled event whose returned handle is adopted immediately. - let raw_event = unsafe { CreateEventW(&security, 0, 0, null()) }; - let Some(debugger_ready_event) = (unsafe { OwnedHandle::from_raw(raw_event) }) else { - self.show_failure_message(&self.strings.cant_debug, nonzero_last_error()); - return false; - }; - let command_line = - match format_debugger_template(&debugger.template, identity.pid, debugger_ready_event.as_raw() as usize) { - Ok(command_line) => command_line, - Err(error) => { - self.show_failure_message(&self.strings.cant_debug, error); - return false; - } - }; - let attributes = match ProcThreadAttributeList::for_handle(debugger_ready_event.as_raw()) { - Ok(attributes) => attributes, - Err(error) => { - self.show_failure_message(&self.strings.cant_debug, error); - return false; - } - }; - - let mut command_line_wide = to_wide_null(&command_line); - let application_name = to_wide_null(&debugger.executable); - let startup_info = STARTUPINFOEXW { - StartupInfo: STARTUPINFOW { - cb: size_of::() as u32, - ..unsafe { zeroed() } - }, - lpAttributeList: attributes.as_ptr(), - }; - let mut process_info = unsafe { zeroed::() }; - - // SAFETY: the application/command buffers and extended startup information remain live - // for the call. bInheritHandles is required by PROC_THREAD_ATTRIBUTE_HANDLE_LIST, which - // restricts inheritance to the one event in `attributes`. - let created = unsafe { - CreateProcessW( - application_name.as_ptr(), - command_line_wide.as_mut_ptr(), - null_mut(), - null_mut(), - 1, - CREATE_NEW_CONSOLE | EXTENDED_STARTUPINFO_PRESENT, - null(), - null(), - &startup_info.StartupInfo, - &mut process_info, - ) - }; - let create_error = if created == 0 { - unsafe { GetLastError() } - } else { - 0 - }; - drop(target_handle); - - if created == 0 { - self.show_failure_message( - &self.strings.cant_debug, - if create_error == 0 { - ERROR_GEN_FAILURE - } else { - create_error - }, - ); - false - } else { - // SAFETY: successful CreateProcessW returned two fresh handles and this is their only - // ownership transfer. The child owns its inherited copy of the ready event. - match unsafe { own_created_process_handles(process_info) } { - Ok(_) => true, - Err(error) => { - self.show_failure_message(&self.strings.cant_debug, error); - false - } - } - } - } - -''', -) - -replace_between( - ACTIONS, - 'pub(super) fn load_debugger_path() -> Result, u32> {', - '// 先构建完整的已验证句柄集合,再把不可逆的终止阶段与 UI 结果呈现分离。', - r'''const AEDEBUG_KEY: &str = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AeDebug"; -const AEDEBUG_VALUE: &str = "Debugger"; -const MAX_DEBUGGER_COMMAND_BYTES: u32 = 1024 * 1024; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DebuggerRegistryView { - Native, - Registry32, - Registry64, -} - -impl DebuggerRegistryView { - const fn access_mask(self) -> u32 { - match self { - Self::Native => 0, - Self::Registry32 => KEY_WOW64_32KEY, - Self::Registry64 => KEY_WOW64_64KEY, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct DebuggerCommand { - template: String, - executable: String, -} - -struct ProcThreadAttributeList { - storage: Vec, - handles: Box<[HANDLE; 1]>, - initialized: bool, -} - -impl ProcThreadAttributeList { - fn for_handle(handle: HANDLE) -> Result { - if handle.is_null() { - return Err(ERROR_INVALID_HANDLE); - } - - let mut byte_count = 0usize; - unsafe { - InitializeProcThreadAttributeList(null_mut(), 1, 0, &mut byte_count); - } - if byte_count == 0 { - return Err(nonzero_last_error()); - } - - let word_count = byte_count.div_ceil(size_of::()); - let mut value = Self { - storage: vec![0usize; word_count], - handles: Box::new([handle]), - initialized: false, - }; - if unsafe { - InitializeProcThreadAttributeList(value.as_ptr(), 1, 0, &mut byte_count) - } == 0 - { - return Err(nonzero_last_error()); - } - value.initialized = true; - if unsafe { - UpdateProcThreadAttribute( - value.as_ptr(), - 0, - PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, - value.handles.as_mut_ptr().cast::(), - size_of::(), - null_mut(), - null_mut(), - ) - } == 0 - { - return Err(nonzero_last_error()); - } - Ok(value) - } - - fn as_ptr(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { - self.storage.as_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST - } -} - -impl Drop for ProcThreadAttributeList { - fn drop(&mut self) { - if self.initialized { - unsafe { DeleteProcThreadAttributeList(self.as_ptr()) }; - } - } -} - -pub(super) fn load_debugger_path() -> Result, u32> { - // This is an availability probe for menu state. Launch-time selection is repeated against - // the selected process' machine type so the command can never use the wrong registry view. - let mut first_error = None; - for view in [ - DebuggerRegistryView::Native, - DebuggerRegistryView::Registry64, - DebuggerRegistryView::Registry32, - ] { - match load_debugger_command(view) { - Ok(Some(command)) if Path::new(&command.executable).is_file() => { - return Ok(Some(command.executable)); - } - Ok(_) => {} - Err(error) => { - first_error.get_or_insert(error); - } - } - } - if let Some(error) = first_error { - Err(error) - } else { - Ok(None) - } -} - -fn load_debugger_command(view: DebuggerRegistryView) -> Result, u32> { - let Some((raw_command, value_type)) = read_aedebug_string(view)? else { - return Ok(None); - }; - parse_debugger_command(&raw_command, value_type) -} - -fn read_aedebug_string( - view: DebuggerRegistryView, -) -> Result, u32> { - unsafe { - let key_name = to_wide_null(AEDEBUG_KEY); - let value_name = to_wide_null(AEDEBUG_VALUE); - let mut key: HKEY = null_mut(); - let open_status = RegOpenKeyExW( - HKEY_LOCAL_MACHINE, - key_name.as_ptr(), - 0, - KEY_READ | view.access_mask(), - &mut key, - ); - if open_status == ERROR_FILE_NOT_FOUND || open_status == ERROR_PATH_NOT_FOUND { - return Ok(None); - } - if open_status != 0 { - return Err(open_status); - } - - let mut value_type = 0u32; - let mut value_size = 0u32; - let size_status = RegQueryValueExW( - key, - value_name.as_ptr(), - null_mut(), - &mut value_type, - null_mut(), - &mut value_size, - ); - if size_status == ERROR_FILE_NOT_FOUND { - let close_status = RegCloseKey(key); - return if close_status == 0 { - Ok(None) - } else { - Err(close_status) - }; - } - if size_status != 0 { - RegCloseKey(key); - return Err(size_status); - } - if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) - || value_size < size_of::() as u32 - || !value_size.is_multiple_of(size_of::() as u32) - || value_size > MAX_DEBUGGER_COMMAND_BYTES - { - RegCloseKey(key); - return Err(ERROR_INVALID_DATA); - } - - let mut buffer = vec![0u16; value_size as usize / size_of::()]; - let mut actual_size = value_size; - let read_status = RegQueryValueExW( - key, - value_name.as_ptr(), - null_mut(), - &mut value_type, - buffer.as_mut_ptr().cast::(), - &mut actual_size, - ); - let close_status = RegCloseKey(key); - if read_status == ERROR_MORE_DATA { - return Err(ERROR_MORE_DATA); - } - if read_status != 0 { - return Err(read_status); - } - if close_status != 0 { - return Err(close_status); - } - if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) - || actual_size < size_of::() as u32 - || !actual_size.is_multiple_of(size_of::() as u32) - || actual_size > value_size - { - return Err(ERROR_INVALID_DATA); - } - let units = actual_size as usize / size_of::(); - let Some(length) = buffer[..units].iter().position(|value| *value == 0) else { - return Err(ERROR_INVALID_DATA); - }; - Ok(Some(( - String::from_utf16(&buffer[..length]).map_err(|_| ERROR_INVALID_DATA)?, - value_type, - ))) - } -} - -fn debugger_registry_view_for_process(process: HANDLE) -> Result { - if process.is_null() { - return Err(ERROR_INVALID_HANDLE); - } - let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; - let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; - if unsafe { IsWow64Process2(process, &mut process_machine, &mut native_machine) } == 0 { - return Err(nonzero_last_error()); - } - debugger_registry_view_for_machines(process_machine, native_machine) -} - -fn debugger_registry_view_for_machines( - process_machine: u16, - native_machine: u16, -) -> Result { - let effective_machine = if process_machine == IMAGE_FILE_MACHINE_UNKNOWN { - native_machine - } else { - process_machine - }; - let target_is_32_bit = match effective_machine { - IMAGE_FILE_MACHINE_I386 - | IMAGE_FILE_MACHINE_ARM - | IMAGE_FILE_MACHINE_ARMNT - | IMAGE_FILE_MACHINE_THUMB => true, - IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => false, - _ => return Err(ERROR_NOT_SUPPORTED), - }; - let native_is_64_bit = match native_machine { - IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => true, - IMAGE_FILE_MACHINE_I386 - | IMAGE_FILE_MACHINE_ARM - | IMAGE_FILE_MACHINE_ARMNT - | IMAGE_FILE_MACHINE_THUMB => false, - _ => return Err(ERROR_NOT_SUPPORTED), - }; - Ok(if !native_is_64_bit { - DebuggerRegistryView::Native - } else if target_is_32_bit { - DebuggerRegistryView::Registry32 - } else { - DebuggerRegistryView::Registry64 - }) -} - -// 引用命令行参数。只在包含空格、制表符或引号时加引号,并正确处理反斜杠转义。 -fn quote_command_line_arg(value: &str) -> String { - if !value.contains([' ', '\t', '"']) { - return value.to_string(); - } - - let mut quoted = String::with_capacity(value.len() + 2); - quoted.push('"'); - let mut backslashes = 0usize; - - for ch in value.chars() { - if ch == '\\' { - backslashes += 1; - continue; - } - - if ch == '"' { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - continue; - } - - if backslashes > 0 { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - } - quoted.push(ch); - } - - if backslashes > 0 { - quoted.push_str(&"\\".repeat(backslashes * 2)); - } - quoted.push('"'); - quoted -} - -// 从命令行字符串中提取第一个 token(即可执行文件路径)。处理引号包裹和非引号两种格式。 -pub(super) fn extract_first_command_token(command_line: &str) -> String { - let trimmed = command_line.trim(); - if let Some(rest) = trimmed.strip_prefix('"') { - rest.split('"').next().unwrap_or_default().to_string() - } else { - trimmed - .split_whitespace() - .next() - .unwrap_or_default() - .to_string() - } -} - -fn parse_debugger_command( - command_line: &str, - value_type: u32, -) -> Result, u32> { - let expanded = if value_type == REG_EXPAND_SZ { - expand_environment_variables(command_line)? - } else if value_type == REG_SZ { - command_line.to_string() - } else { - return Err(ERROR_INVALID_DATA); - }; - parse_expanded_debugger_command(&expanded) -} - -fn parse_expanded_debugger_command( - command_line: &str, -) -> Result, u32> { - let template = command_line.trim().to_string(); - let executable = extract_first_command_token(&template); - let file_name = Path::new(&executable) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default(); - if executable.is_empty() - || !Path::new(&executable).is_absolute() - || file_name.eq_ignore_ascii_case("drwtsn32") - || file_name.eq_ignore_ascii_case("drwtsn32.exe") - { - return Ok(None); - } - validate_debugger_template(&template)?; - Ok(Some(DebuggerCommand { - template, - executable, - })) -} - -fn validate_debugger_template(template: &str) -> Result<(), u32> { - let mut placeholders = 0usize; - let mut chars = template.chars(); - while let Some(ch) = chars.next() { - if ch != '%' { - continue; - } - match chars.next() { - Some('%') => {} - Some('l' | 'L') => { - if !matches!(chars.next(), Some('d' | 'D')) { - return Err(ERROR_INVALID_DATA); - } - placeholders += 1; - } - Some('p' | 'P') => return Err(ERROR_NOT_SUPPORTED), - Some(_) | None => return Err(ERROR_INVALID_DATA), - } - } - if placeholders == 2 { - Ok(()) - } else { - Err(ERROR_INVALID_DATA) - } -} - -fn format_debugger_template( - template: &str, - process_id: u32, - event_handle: usize, -) -> Result { - validate_debugger_template(template)?; - let replacements = [process_id.to_string(), event_handle.to_string()]; - let mut replacement_index = 0usize; - let mut output = String::with_capacity(template.len() + 32); - let mut chars = template.chars(); - while let Some(ch) = chars.next() { - if ch != '%' { - output.push(ch); - continue; - } - match chars.next() { - Some('%') => output.push('%'), - Some('l' | 'L') => { - if !matches!(chars.next(), Some('d' | 'D')) { - return Err(ERROR_INVALID_DATA); - } - output.push_str( - replacements - .get(replacement_index) - .ok_or(ERROR_INVALID_DATA)?, - ); - replacement_index += 1; - } - Some('p' | 'P') => return Err(ERROR_NOT_SUPPORTED), - Some(_) | None => return Err(ERROR_INVALID_DATA), - } - } - if replacement_index == replacements.len() { - Ok(output) - } else { - Err(ERROR_INVALID_DATA) - } -} - -// Compatibility helper used by the existing pure parsing tests. -pub(super) fn normalize_debugger_command_with( - command_line: &str, - value_type: u32, - expand_environment_variables: F, -) -> Option -where - F: Fn(&str) -> String, -{ - let expanded = if value_type == REG_EXPAND_SZ { - expand_environment_variables(command_line) - } else if value_type == REG_SZ { - command_line.to_string() - } else { - return None; - }; - let executable = extract_first_command_token(&expanded); - let file_name = Path::new(&executable) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default(); - if executable.is_empty() - || file_name.eq_ignore_ascii_case("drwtsn32") - || file_name.eq_ignore_ascii_case("drwtsn32.exe") - { - None - } else { - Some(executable) - } -} - -// 展开字符串中的环境变量(如 %SystemRoot%)。 -// 使用 Win32 ExpandEnvironmentStringsW API,正确处理 WOW64 重定向和 %% 转义。 -fn expand_environment_variables(command_line: &str) -> Result { - let wide_input = to_wide_null(command_line); - let required = unsafe { ExpandEnvironmentStringsW(wide_input.as_ptr(), null_mut(), 0) }; - if required == 0 { - return Err(nonzero_last_error()); - } - let mut buffer = vec![0u16; required as usize]; - let written = - unsafe { ExpandEnvironmentStringsW(wide_input.as_ptr(), buffer.as_mut_ptr(), required) }; - if written == 0 || written > required { - return Err(nonzero_last_error()); - } - let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len()); - Ok(String::from_utf16_lossy(&buffer[..len])) -} - -#[cfg(test)] -mod debugger_tests { - use super::*; - - #[test] - fn full_aedebug_template_preserves_debugger_specific_arguments() { - let template = r#""C:\Debuggers\windbg.exe" -p %ld -e %ld -g"#; - let command = parse_expanded_debugger_command(template) - .unwrap() - .unwrap(); - assert_eq!(command.executable, r"C:\Debuggers\windbg.exe"); - assert_eq!( - format_debugger_template(&command.template, 1234, 5678).unwrap(), - r#""C:\Debuggers\windbg.exe" -p 1234 -e 5678 -g"# - ); - } - - #[test] - fn visual_studio_jit_template_and_literal_percent_are_supported() { - let template = - r#""C:\Windows\System32\vsjitdebugger.exe" -p %ld -e %ld --label 100%%"#; - assert_eq!( - format_debugger_template(template, 42, 99).unwrap(), - r#""C:\Windows\System32\vsjitdebugger.exe" -p 42 -e 99 --label 100%"# - ); - } - - #[test] - fn unsupported_or_ambiguous_templates_are_rejected() { - assert_eq!( - parse_expanded_debugger_command( - r#""C:\Debuggers\dbg.exe" -p %ld -e %ld -j 0x%p"# - ), - Err(ERROR_NOT_SUPPORTED) - ); - for template in [ - r#""C:\Debuggers\dbg.exe" -p %ld"#, - r#""C:\Debuggers\dbg.exe" -p %ld -e %ld -x %ld"#, - r#""C:\Debuggers\dbg.exe" -p %q -e %ld"#, - ] { - assert!(parse_expanded_debugger_command(template).is_err()); - } - assert!( - parse_expanded_debugger_command( - r#""relative\dbg.exe" -p %ld -e %ld"# - ) - .unwrap() - .is_none() - ); - } - - #[test] - fn target_machine_selects_the_matching_registry_view() { - assert_eq!( - debugger_registry_view_for_machines( - IMAGE_FILE_MACHINE_I386, - IMAGE_FILE_MACHINE_AMD64, - ) - .unwrap(), - DebuggerRegistryView::Registry32 - ); - assert_eq!( - debugger_registry_view_for_machines( - IMAGE_FILE_MACHINE_UNKNOWN, - IMAGE_FILE_MACHINE_AMD64, - ) - .unwrap(), - DebuggerRegistryView::Registry64 - ); - assert_eq!( - debugger_registry_view_for_machines( - IMAGE_FILE_MACHINE_UNKNOWN, - IMAGE_FILE_MACHINE_I386, - ) - .unwrap(), - DebuggerRegistryView::Native - ); - assert_eq!( - debugger_registry_view_for_machines( - IMAGE_FILE_MACHINE_ARMNT, - IMAGE_FILE_MACHINE_ARM64, - ) - .unwrap(), - DebuggerRegistryView::Registry32 - ); - } -} - -''', -) - -print("Applied root-cause fixes for issues #19, #20, and #21.") diff --git a/src/config/options.rs b/src/config/options.rs index a0a9f05..871d1af 100644 --- a/src/config/options.rs +++ b/src/config/options.rs @@ -15,7 +15,9 @@ use std::mem::{size_of, zeroed}; // 数据合法性校验以及注册表的读写边界。 use std::ptr::null_mut; -use windows_sys::Win32::Foundation::{ERROR_REVISION_MISMATCH, ERROR_SUCCESS, RECT}; +use windows_sys::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_INVALID_DATA, ERROR_REVISION_MISMATCH, ERROR_SUCCESS, RECT, +}; use windows_sys::Win32::Graphics::Gdi::{MONITOR_DEFAULTTONULL, MonitorFromRect}; use windows_sys::Win32::System::Registry::{ HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_BINARY, REG_OPTION_NON_VOLATILE, RegCloseKey, @@ -31,8 +33,12 @@ use crate::app::page_registry::PageId; use crate::infrastructure::native::{record_win32_error, to_wide_null}; use crate::ui::resource_ids::NUM_COLUMN; -const TASKMAN_KEY: &str = "Software\\Microsoft\\Windows NT\\CurrentVersion\\TaskManager"; -const OPTIONS_KEY: &str = "Preferences"; +const OPTIONS_KEY: &str = "Software\\taskmgr-rs\\TaskManager"; +const OPTIONS_VALUE: &str = "OptionsV1"; +const LEGACY_TASKMAN_KEY: &str = "Software\\Microsoft\\Windows NT\\CurrentVersion\\TaskManager"; +const LEGACY_OPTIONS_VALUE: &str = "Preferences"; +const OPTIONS_STORAGE_MAGIC: [u8; 8] = *b"TMGRRS01"; +const OPTIONS_STORAGE_VERSION: u32 = 1; const OPTIONS_SCHEMA_VERSION: i32 = 2; const SCHEMA_0_NETWORK_PAGE: i32 = 3; const SCHEMA_0_USERS_PAGE: i32 = 4; @@ -144,6 +150,48 @@ pub struct Options { pub unused2: i32, } +#[repr(C)] +#[derive(Clone, Copy)] +struct StoredOptions { + magic: [u8; 8], + storage_version: u32, + payload_size: u32, + options: Options, +} + +impl StoredOptions { + fn new(options: Options) -> Self { + Self { + magic: OPTIONS_STORAGE_MAGIC, + storage_version: OPTIONS_STORAGE_VERSION, + payload_size: size_of::() as u32, + options, + } + } + + fn into_options(self) -> Result { + if self.magic != OPTIONS_STORAGE_MAGIC || self.payload_size != size_of::() as u32 { + return Err(ERROR_INVALID_DATA); + } + if self.storage_version != OPTIONS_STORAGE_VERSION { + return Err(ERROR_REVISION_MISMATCH); + } + Ok(self.options) + } +} + +/// Marker for fixed-layout registry values whose every bit pattern is a valid Rust value. +/// +/// # Safety +/// +/// Implementors must be `Copy`, have a stable layout, contain no references, and permit every +/// possible byte pattern. This lets the registry reader initialize the value as zeroed storage +/// before Windows fills every byte. +unsafe trait RegistryPod: Copy {} + +unsafe impl RegistryPod for Options {} +unsafe impl RegistryPod for StoredOptions {} + impl Default for Options { fn default() -> Self { // 默认值尽量贴近经典任务管理器的首次启动体验。 @@ -200,109 +248,81 @@ impl Options { } pub fn load(&mut self, min_width: i32, min_height: i32) -> bool { - // 读取失败或数据不合法时,统一回退到默认配置,避免坏配置把程序带崩。 + // taskmgr-rs owns a versioned application-specific value. The Microsoft Task Manager key + // is read only for a one-time migration of values that carry taskmgr-rs' exact schema + // marker; it is never written or deleted. if modifiers_force_defaults() { self.set_default_values(min_width, min_height); return false; } - // 安全性: registry buffers point to live local variables for the duration of each call; - // loaded binary data is size/type checked before being used. - unsafe { - let key_name = to_wide_null(TASKMAN_KEY); - let value_name = to_wide_null(OPTIONS_KEY); - let mut key: HKEY = null_mut(); - if RegOpenKeyExW(HKEY_CURRENT_USER, key_name.as_ptr(), 0, KEY_READ, &mut key) - != ERROR_SUCCESS - { - self.set_default_values(min_width, min_height); - return false; + match read_registry_binary::(OPTIONS_KEY, OPTIONS_VALUE) { + Ok(Some(stored)) => { + let loaded = match stored.into_options() { + Ok(loaded) => loaded, + Err(error) => { + record_win32_error("taskmgr-rs options envelope", error); + self.set_default_values(min_width, min_height); + return false; + } + }; + return self.apply_loaded_options(loaded, min_width, min_height, false); } - - let mut loaded = zeroed::(); - let mut value_type = 0u32; - let mut value_size = size_of::() as u32; - let status = RegQueryValueExW( - key, - value_name.as_ptr(), - null_mut(), - &mut value_type, - &mut loaded as *mut Options as *mut u8, - &mut value_size, - ); - RegCloseKey(key); - - if status != ERROR_SUCCESS - || value_type != REG_BINARY - || value_size != size_of::() as u32 - { + Ok(None) => {} + Err(error) => { + record_win32_error("reading taskmgr-rs options", error); self.set_default_values(min_width, min_height); return false; } + } - let migrated = match loaded.migrate_schema() { - Ok(migrated) => migrated, - Err(()) => { - record_win32_error("unsupported options schema", ERROR_REVISION_MISMATCH); - self.set_default_values(min_width, min_height); - return false; - } - }; - let loaded_was_valid = loaded.is_valid(min_width, min_height); - if !loaded_was_valid { - loaded.normalize(min_width, min_height); + match read_registry_binary::(LEGACY_TASKMAN_KEY, LEGACY_OPTIONS_VALUE) { + Ok(Some(legacy)) if legacy_options_is_taskmgr_rs(&legacy) => { + self.apply_loaded_options(legacy, min_width, min_height, true) } - *self = loaded; - if (migrated || !loaded_was_valid) - && let Err(error) = self.save() - { - record_win32_error("normalized options persistence", error); + Ok(Some(_)) | Ok(None) => { + self.set_default_values(min_width, min_height); + false + } + Err(error) => { + record_win32_error("reading legacy taskmgr-rs options", error); + self.set_default_values(min_width, min_height); + false } - loaded_was_valid } } - pub fn save(&self) -> Result<(), u32> { - // 整个结构体按历史格式整体写入注册表,保持与原版偏好布局兼容。 - // 安全性: registry handles are opened and closed in this block; the value buffer points - // to `self` and is written as the historical binary Options format. - unsafe { - let key_name = to_wide_null(TASKMAN_KEY); - let value_name = to_wide_null(OPTIONS_KEY); - let mut key: HKEY = null_mut(); - let mut disposition = 0u32; - - let create_status = RegCreateKeyExW( - HKEY_CURRENT_USER, - key_name.as_ptr(), - 0, - null_mut(), - REG_OPTION_NON_VOLATILE, - KEY_WRITE, - null_mut(), - &mut key, - &mut disposition, - ); - if create_status != ERROR_SUCCESS { - return Err(create_status); - } - - let set_status = RegSetValueExW( - key, - value_name.as_ptr(), - 0, - REG_BINARY, - self as *const Options as *const u8, - size_of::() as u32, - ); - RegCloseKey(key); - - if set_status == ERROR_SUCCESS { - Ok(()) - } else { - Err(set_status) + fn apply_loaded_options( + &mut self, + mut loaded: Options, + min_width: i32, + min_height: i32, + persist_migration: bool, + ) -> bool { + let migrated = match loaded.migrate_schema() { + Ok(migrated) => migrated, + Err(()) => { + record_win32_error("unsupported options schema", ERROR_REVISION_MISMATCH); + self.set_default_values(min_width, min_height); + return false; } + }; + let loaded_was_valid = loaded.is_valid(min_width, min_height); + if !loaded_was_valid { + loaded.normalize(min_width, min_height); } + *self = loaded; + if (persist_migration || migrated || !loaded_was_valid) + && let Err(error) = self.save() + { + record_win32_error("normalized options persistence", error); + } + loaded_was_valid + } + + pub fn save(&self) -> Result<(), u32> { + let stored = StoredOptions::new(*self); + write_registry_binary(OPTIONS_KEY, OPTIONS_VALUE, &stored) } pub fn minimize_on_use(&self) -> bool { @@ -467,6 +487,135 @@ impl Options { } } +fn legacy_options_is_taskmgr_rs(options: &Options) -> bool { + options.cb_size == size_of::() as u32 + && options.unused == OPTIONS_SCHEMA_VERSION + && options.unused2 == 0 + && options.current_page >= -1 + && options.current_page < PageId::COUNT as i32 + && is_valid_view_mode(options.view_mode) + && is_valid_cpu_history_mode(options.cpu_history_mode) + && is_valid_update_speed(options.update_speed) + && options.flags & !ALL_VALID_FLAGS == 0 + && process_columns_are_valid(&options.active_process_columns, &options.column_widths) +} + +fn read_registry_binary( + key_path: &str, + value_name: &str, +) -> Result, u32> { + unsafe { + let key_path = to_wide_null(key_path); + let value_name = to_wide_null(value_name); + let mut key: HKEY = null_mut(); + let open_status = + RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut key); + if open_status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + if open_status != ERROR_SUCCESS { + return Err(open_status); + } + + let mut value_type = 0u32; + let mut value_size = 0u32; + let size_status = RegQueryValueExW( + key, + value_name.as_ptr(), + null_mut(), + &mut value_type, + null_mut(), + &mut value_size, + ); + if size_status == ERROR_FILE_NOT_FOUND { + let close_status = RegCloseKey(key); + return if close_status == ERROR_SUCCESS { + Ok(None) + } else { + Err(close_status) + }; + } + if size_status != ERROR_SUCCESS { + RegCloseKey(key); + return Err(size_status); + } + if value_type != REG_BINARY || value_size != size_of::() as u32 { + RegCloseKey(key); + return Err(ERROR_INVALID_DATA); + } + + // SAFETY: RegistryPod requires every bit pattern to be valid. The exact-size check above + // and the second query's unchanged byte count guarantee that Windows initializes all bytes. + let mut value = zeroed::(); + let mut actual_size = value_size; + let read_status = RegQueryValueExW( + key, + value_name.as_ptr(), + null_mut(), + &mut value_type, + (&mut value as *mut T).cast::(), + &mut actual_size, + ); + let close_status = RegCloseKey(key); + if read_status != ERROR_SUCCESS { + return Err(read_status); + } + if close_status != ERROR_SUCCESS { + return Err(close_status); + } + if value_type != REG_BINARY || actual_size != size_of::() as u32 { + return Err(ERROR_INVALID_DATA); + } + Ok(Some(value)) + } +} + +fn write_registry_binary( + key_path: &str, + value_name: &str, + value: &T, +) -> Result<(), u32> { + unsafe { + let key_path = to_wide_null(key_path); + let value_name = to_wide_null(value_name); + let mut key: HKEY = null_mut(); + let mut disposition = 0u32; + let create_status = RegCreateKeyExW( + HKEY_CURRENT_USER, + key_path.as_ptr(), + 0, + null_mut(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + null_mut(), + &mut key, + &mut disposition, + ); + if create_status != ERROR_SUCCESS { + return Err(create_status); + } + + // SAFETY: RegistryPod has a fixed initialized representation and contains no references. + let bytes = std::slice::from_raw_parts((value as *const T).cast::(), size_of::()); + let set_status = RegSetValueExW( + key, + value_name.as_ptr(), + 0, + REG_BINARY, + bytes.as_ptr(), + bytes.len() as u32, + ); + let close_status = RegCloseKey(key); + if set_status != ERROR_SUCCESS { + Err(set_status) + } else if close_status != ERROR_SUCCESS { + Err(close_status) + } else { + Ok(()) + } + } +} + fn modifiers_force_defaults() -> bool { // 安全性: `GetKeyState` only reads current keyboard state for virtual-key codes. unsafe { @@ -628,13 +777,64 @@ fn screen_reader_enabled() -> bool { #[cfg(test)] mod tests { use super::{ - ColumnId, NUM_COLUMN, OPTIONS_SCHEMA_VERSION, Options, SCHEMA_0_NETWORK_PAGE, + ColumnId, ERROR_INVALID_DATA, ERROR_REVISION_MISMATCH, LEGACY_OPTIONS_VALUE, + LEGACY_TASKMAN_KEY, NUM_COLUMN, OPTIONS_KEY, OPTIONS_SCHEMA_VERSION, OPTIONS_STORAGE_MAGIC, + OPTIONS_STORAGE_VERSION, OPTIONS_VALUE, Options, SCHEMA_0_NETWORK_PAGE, SCHEMA_0_USERS_PAGE, SCHEMA_1_GPU_PAGE, SCHEMA_1_NETWORK_PAGE, SCHEMA_1_USERS_PAGE, - UpdateSpeed, normalize_process_columns, process_columns_are_valid, - update_speed_timer_interval, window_rect_dimensions_are_valid, window_rect_is_valid, + StoredOptions, UpdateSpeed, legacy_options_is_taskmgr_rs, normalize_process_columns, + process_columns_are_valid, update_speed_timer_interval, window_rect_dimensions_are_valid, + window_rect_is_valid, }; use windows_sys::Win32::Foundation::RECT; + #[test] + fn stored_options_envelope_round_trips_and_rejects_foreign_data() { + let options = Options::default(); + assert_eq!( + StoredOptions::new(options).into_options().unwrap().unused, + options.unused + ); + + let mut foreign = StoredOptions::new(options); + foreign.magic = *b"NATIVE00"; + assert_eq!(foreign.into_options().err(), Some(ERROR_INVALID_DATA)); + + let mut future = StoredOptions::new(options); + future.storage_version = OPTIONS_STORAGE_VERSION + 1; + assert_eq!(future.into_options().err(), Some(ERROR_REVISION_MISMATCH)); + + let mut wrong_size = StoredOptions::new(options); + wrong_size.payload_size = 0; + assert_eq!(wrong_size.into_options().err(), Some(ERROR_INVALID_DATA)); + assert_eq!(OPTIONS_STORAGE_MAGIC, *b"TMGRRS01"); + } + + #[test] + fn legacy_migration_requires_the_exact_taskmgr_rs_schema_marker() { + let current = Options::default(); + assert!(legacy_options_is_taskmgr_rs(¤t)); + + for schema in [0, 1, OPTIONS_SCHEMA_VERSION + 1] { + let candidate = Options { + unused: schema, + ..current + }; + assert!(!legacy_options_is_taskmgr_rs(&candidate)); + } + + let wrong_size = Options { + cb_size: 0, + ..current + }; + assert!(!legacy_options_is_taskmgr_rs(&wrong_size)); + } + + #[test] + fn application_options_namespace_is_distinct_from_windows_task_manager() { + assert_ne!(OPTIONS_KEY, LEGACY_TASKMAN_KEY); + assert_ne!(OPTIONS_VALUE, LEGACY_OPTIONS_VALUE); + } + #[test] fn process_columns_reject_missing_primary_and_duplicates() { let mut columns = [-1; NUM_COLUMN + 1]; diff --git a/src/pages/network.rs b/src/pages/network.rs index 1491d74..8642580 100644 --- a/src/pages/network.rs +++ b/src/pages/network.rs @@ -79,7 +79,8 @@ struct RawAdapterEntry { key: AdapterIdentity, name: String, state: String, - link_speed_bps: u64, + transmit_link_speed_bps: u64, + receive_link_speed_bps: u64, bytes_sent: u64, bytes_received: u64, } @@ -859,22 +860,32 @@ impl NetworkPageState { .as_ref() .map(|state| (state.current_sent, state.current_received)), ); - // A zero curve point marks an unavailable interval after first sight or counter - // reset; the textual value remains "-" so it is not presented as measured idle. - let total_delta = counter_delta.map_or(0, |delta| delta.2); - let sent_util = counter_delta.map_or(0, |delta| { - utilization_percent_for_history(delta.0, raw_adapter.link_speed_bps, elapsed_secs) - }); - let received_util = counter_delta.map_or(0, |delta| { - utilization_percent_for_history(delta.1, raw_adapter.link_speed_bps, elapsed_secs) - }); - let total_util = counter_delta.map_or(0, |delta| { - utilization_percent_for_history(delta.2, raw_adapter.link_speed_bps, elapsed_secs) - }); + // Each direction owns an independent full-duplex capacity. "Total" is the + // busiest direction's percentage, which remains meaningful for asymmetric links and + // cannot fabricate a 200% value by summing independent capacities. + let (sent_ratio, received_ratio, total_ratio) = + counter_delta.map_or((None, None, None), |delta| { + directional_utilization_ratios( + delta.0, + delta.1, + raw_adapter.transmit_link_speed_bps, + raw_adapter.receive_link_speed_bps, + elapsed_secs, + ) + }); - push_history(&mut sent_history, sent_util); - push_history(&mut received_history, received_util); - push_history(&mut total_history, total_util); + push_history( + &mut sent_history, + utilization_percent_for_history(sent_ratio), + ); + push_history( + &mut received_history, + utilization_percent_for_history(received_ratio), + ); + push_history( + &mut total_history, + utilization_percent_for_history(total_ratio), + ); let bytes_total = raw_adapter .bytes_sent @@ -884,12 +895,11 @@ impl NetworkPageState { key: raw_adapter.key, name: raw_adapter.name, state: raw_adapter.state, - link_speed: format_link_speed(raw_adapter.link_speed_bps), - utilization: counter_delta - .map(|_| { - utilization_text(total_delta, raw_adapter.link_speed_bps, elapsed_secs) - }) - .unwrap_or_else(|| "-".to_string()), + link_speed: format_link_speeds( + raw_adapter.transmit_link_speed_bps, + raw_adapter.receive_link_speed_bps, + ), + utilization: utilization_text(total_ratio), bytes_sent: format_counter(raw_adapter.bytes_sent), bytes_received: format_counter(raw_adapter.bytes_received), bytes_total: bytes_total @@ -964,7 +974,8 @@ impl NetworkPageState { key, name, state: adapter_state_text(row.OperStatus), - link_speed_bps: row.ReceiveLinkSpeed.max(row.TransmitLinkSpeed), + transmit_link_speed_bps: row.TransmitLinkSpeed, + receive_link_speed_bps: row.ReceiveLinkSpeed, bytes_sent: row.OutOctets, bytes_received: row.InOctets, }); @@ -1378,7 +1389,7 @@ fn collapse_raw_adapters(adapters: Vec) -> Vec fn raw_adapter_rank(adapter: &RawAdapterEntry) -> (u8, u8, i64) { ( - u8::from(adapter.link_speed_bps != 0), + u8::from(adapter.transmit_link_speed_bps != 0 || adapter.receive_link_speed_bps != 0), u8::from(!adapter.state.eq_ignore_ascii_case("disconnected")), -(adapter.name.len() as i64), ) @@ -1423,22 +1434,38 @@ fn utilization_ratio_percent( link_speed_bps: u64, elapsed_secs: f64, ) -> Option { - if bytes_per_interval == 0 || link_speed_bps == 0 || elapsed_secs <= 0.0 { + if link_speed_bps == 0 || !elapsed_secs.is_finite() || elapsed_secs <= 0.0 { return None; } + if bytes_per_interval == 0 { + return Some(0.0); + } let bits_per_second = (bytes_per_interval as f64 * 8.0) / elapsed_secs; - Some(((bits_per_second * 100.0) / link_speed_bps as f64).clamp(0.0, 100.0)) + let percent = (bits_per_second * 100.0) / link_speed_bps as f64; + percent.is_finite().then(|| percent.clamp(0.0, 100.0)) } -fn utilization_percent_for_history( - bytes_per_interval: u64, - link_speed_bps: u64, +fn directional_utilization_ratios( + sent_bytes: u64, + received_bytes: u64, + transmit_link_speed_bps: u64, + receive_link_speed_bps: u64, elapsed_secs: f64, -) -> u8 { - let Some(ratio_percent) = - utilization_ratio_percent(bytes_per_interval, link_speed_bps, elapsed_secs) - else { +) -> (Option, Option, Option) { + let sent = utilization_ratio_percent(sent_bytes, transmit_link_speed_bps, elapsed_secs); + let received = utilization_ratio_percent(received_bytes, receive_link_speed_bps, elapsed_secs); + let total = match (sent, received) { + (Some(sent), Some(received)) => Some(sent.max(received)), + (Some(sent), None) => Some(sent), + (None, Some(received)) => Some(received), + (None, None) => None, + }; + (sent, received, total) +} + +fn utilization_percent_for_history(ratio_percent: Option) -> u8 { + let Some(ratio_percent) = ratio_percent else { return 0; }; @@ -1450,11 +1477,9 @@ fn utilization_percent_for_history( } } -fn utilization_text(bytes_per_interval: u64, link_speed_bps: u64, elapsed_secs: f64) -> String { - let Some(ratio_percent) = - utilization_ratio_percent(bytes_per_interval, link_speed_bps, elapsed_secs) - else { - return "0%".to_string(); +fn utilization_text(ratio_percent: Option) -> String { + let Some(ratio_percent) = ratio_percent else { + return "-".to_string(); }; if ratio_percent > 0.0 && ratio_percent < 1.0 { @@ -1464,6 +1489,18 @@ fn utilization_text(bytes_per_interval: u64, link_speed_bps: u64, elapsed_secs: } } +fn format_link_speeds(transmit_bits_per_second: u64, receive_bits_per_second: u64) -> String { + match (transmit_bits_per_second, receive_bits_per_second) { + (0, 0) => "-".to_string(), + (transmit, receive) if transmit == receive => format_link_speed(transmit), + (transmit, receive) => format!( + "Tx {} / Rx {}", + format_link_speed(transmit), + format_link_speed(receive) + ), + } +} + fn format_link_speed(bits_per_second: u64) -> String { // 链路速率采用十进制网络单位显示,更符合网卡/交换机常见标注方式。 if bits_per_second == 0 { @@ -1845,6 +1882,48 @@ mod tests { assert_eq!(adapter_row_texts(&adapter)[0], "Renamed Ethernet"); } + #[test] + fn asymmetric_links_use_each_direction_capacity() { + let (sent, received, total) = + directional_utilization_ratios(1_250_000, 0, 10_000_000, 100_000_000, 1.0); + assert_eq!(sent, Some(100.0)); + assert_eq!(received, Some(0.0)); + assert_eq!(total, Some(100.0)); + + let (sent, received, total) = + directional_utilization_ratios(0, 1_250_000, 100_000_000, 10_000_000, 1.0); + assert_eq!(sent, Some(0.0)); + assert_eq!(received, Some(100.0)); + assert_eq!(total, Some(100.0)); + } + + #[test] + fn full_duplex_total_is_the_busiest_direction_not_a_sum() { + let (_, _, total) = + directional_utilization_ratios(12_500_000, 12_500_000, 100_000_000, 100_000_000, 1.0); + assert_eq!(total, Some(100.0)); + } + + #[test] + fn unavailable_direction_does_not_poison_the_other_direction() { + let (sent, received, total) = + directional_utilization_ratios(0, 1_250_000, 0, 10_000_000, 1.0); + assert_eq!(sent, None); + assert_eq!(received, Some(100.0)); + assert_eq!(total, Some(100.0)); + assert_eq!(utilization_text(None), "-"); + assert_eq!(utilization_text(Some(0.0)), "0%"); + } + + #[test] + fn asymmetric_link_speed_text_preserves_both_capacities() { + assert_eq!( + format_link_speeds(10_000_000, 100_000_000), + "Tx 10.0 Mbps / Rx 100 Mbps" + ); + assert_eq!(format_link_speeds(1_000_000_000, 1_000_000_000), "1.0 Gbps"); + } + #[test] fn adapter_counter_delta_rejects_missing_regressed_and_overflowed_intervals() { assert_eq!(adapter_counter_delta(10, 20, None), None); diff --git a/src/pages/processes/actions.rs b/src/pages/processes/actions.rs index 58b859c..2fb48ec 100644 --- a/src/pages/processes/actions.rs +++ b/src/pages/processes/actions.rs @@ -12,16 +12,18 @@ //! Every target process is reopened through `ProcIdentity` immediately before use. use std::collections::{HashMap, HashSet}; +use std::ffi::c_void; use std::mem::{size_of, zeroed}; use std::path::Path; use std::ptr::{null, null_mut}; use windows_sys::Win32::Foundation::{ ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_GEN_FAILURE, ERROR_INSUFFICIENT_BUFFER, - ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_NO_MORE_FILES, - ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, FILETIME, GetLastError, HANDLE, HWND, LPARAM, - WAIT_OBJECT_0, WPARAM, + ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, + ERROR_NO_MORE_FILES, ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, FILETIME, GetLastError, HANDLE, + HWND, LPARAM, WAIT_OBJECT_0, WPARAM, }; +use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, @@ -29,22 +31,27 @@ use windows_sys::Win32::System::Diagnostics::ToolHelp::{ }; use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW; use windows_sys::Win32::System::Registry::{ - HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegOpenKeyExW, - RegQueryValueExW, + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, REG_EXPAND_SZ, REG_SZ, + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, }; use windows_sys::Win32::System::SystemInformation::{ - GROUP_AFFINITY, GetSystemTimeAsFileTime, GetWindowsDirectoryW, + GROUP_AFFINITY, GetSystemTimeAsFileTime, GetWindowsDirectoryW, IMAGE_FILE_MACHINE_AMD64, + IMAGE_FILE_MACHINE_ARM, IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_ARMNT, + IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_IA64, IMAGE_FILE_MACHINE_THUMB, + IMAGE_FILE_MACHINE_UNKNOWN, }; use windows_sys::Win32::System::Threading::{ - ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CreateProcessW, + ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CREATE_NEW_CONSOLE, CreateEventW, + CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetProcessAffinityMask, GetProcessGroupAffinity, GetProcessIdOfThread, GetThreadGroupAffinity, - HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, OpenThread, - PROCESS_INFORMATION, PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, - PROCESS_SET_INFORMATION, PROCESS_SET_LIMITED_INFORMATION, PROCESS_TERMINATE, - QueryFullProcessImageNameW, REALTIME_PRIORITY_CLASS, STARTUPINFOW, SetPriorityClass, - SetProcessAffinityMask, SetProcessDefaultCpuSets, SetThreadGroupAffinity, - THREAD_QUERY_LIMITED_INFORMATION, THREAD_SET_INFORMATION, TerminateProcess, - WaitForSingleObject, + HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, InitializeProcThreadAttributeList, IsWow64Process2, + LPPROC_THREAD_ATTRIBUTE_LIST, NORMAL_PRIORITY_CLASS, OpenThread, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION, PROCESS_QUERY_INFORMATION, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_INFORMATION, PROCESS_SET_LIMITED_INFORMATION, + PROCESS_TERMINATE, QueryFullProcessImageNameW, REALTIME_PRIORITY_CLASS, STARTUPINFOEXW, + STARTUPINFOW, SetPriorityClass, SetProcessAffinityMask, SetProcessDefaultCpuSets, + SetThreadGroupAffinity, THREAD_QUERY_LIMITED_INFORMATION, THREAD_SET_INFORMATION, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; use windows_sys::Win32::UI::Controls::{ BST_CHECKED, BST_UNCHECKED, CheckDlgButton, IsDlgButtonChecked, @@ -277,17 +284,9 @@ impl ProcessPageState { outcome.completed_without_failure() } - // 以 AeDebug 注册表配置的调试器启动并附加到目标进程。命令行传 -p 。 + // 使用目标进程位数对应的 AeDebug 命令模板启动调试器。完整模板中的第一个 + // `%ld` 接收 PID,第二个接收唯一继承给调试器的 ready-event 句柄。 pub(super) fn attach_debugger(&mut self, identity: ProcIdentity) -> bool { - let Some(debugger_path) = self.debugger_path.as_ref() else { - let error = match self.debugger_error { - Some(error) => error, - None => ERROR_FILE_NOT_FOUND, - }; - self.show_failure_message(&self.strings.cant_debug, error); - return false; - }; - if !self.quick_confirm(&self.strings.warning, &self.strings.debug) { return false; } @@ -300,34 +299,88 @@ impl ProcessPageState { return false; } }; + let registry_view = match debugger_registry_view_for_process(target_handle.as_raw()) { + Ok(view) => view, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + let debugger = match load_debugger_command(registry_view) { + Ok(Some(debugger)) => debugger, + Ok(None) => { + self.show_failure_message(&self.strings.cant_debug, ERROR_FILE_NOT_FOUND); + return false; + } + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + if !Path::new(&debugger.executable).is_file() { + self.show_failure_message(&self.strings.cant_debug, ERROR_FILE_NOT_FOUND); + return false; + } + + let security = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: null_mut(), + bInheritHandle: 1, + }; + // SAFETY: `security` remains live for the synchronous call and requests one unnamed, + // nonsignaled event whose returned handle is adopted immediately. + let raw_event = unsafe { CreateEventW(&security, 0, 0, null()) }; + let Some(debugger_ready_event) = (unsafe { OwnedHandle::from_raw(raw_event) }) else { + self.show_failure_message(&self.strings.cant_debug, nonzero_last_error()); + return false; + }; + let command_line = match format_debugger_template( + &debugger.template, + identity.pid, + debugger_ready_event.as_raw() as usize, + ) { + Ok(command_line) => command_line, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; + let attributes = match ProcThreadAttributeList::for_handle(debugger_ready_event.as_raw()) { + Ok(attributes) => attributes, + Err(error) => { + self.show_failure_message(&self.strings.cant_debug, error); + return false; + } + }; - let pid = identity.pid; - let command_line = format!("{} -p {pid}", quote_command_line_arg(debugger_path)); let mut command_line_wide = to_wide_null(&command_line); - let application_name = to_wide_null(debugger_path); - let startup_info = STARTUPINFOW { - cb: size_of::() as u32, - ..unsafe { zeroed() } + let application_name = to_wide_null(&debugger.executable); + let startup_info = STARTUPINFOEXW { + StartupInfo: STARTUPINFOW { + cb: size_of::() as u32, + ..unsafe { zeroed() } + }, + lpAttributeList: attributes.as_ptr(), }; let mut process_info = unsafe { zeroed::() }; - // SAFETY: the terminated application name, mutable command line, and initialized - // input/output structs all remain live for this synchronous call. + // SAFETY: the application/command buffers and extended startup information remain live + // for the call. bInheritHandles is required by PROC_THREAD_ATTRIBUTE_HANDLE_LIST, which + // restricts inheritance to the one event in `attributes`. let created = unsafe { CreateProcessW( application_name.as_ptr(), command_line_wide.as_mut_ptr(), null_mut(), null_mut(), - 0, - windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE, + 1, + CREATE_NEW_CONSOLE | EXTENDED_STARTUPINFO_PRESENT, null(), null(), - &startup_info, + &startup_info.StartupInfo, &mut process_info, ) }; - // Capture last-error before dropping `target_handle`, whose destructor may change it. let create_error = if created == 0 { unsafe { GetLastError() } } else { @@ -336,11 +389,18 @@ impl ProcessPageState { drop(target_handle); if created == 0 { - self.show_failure_message(&self.strings.cant_debug, create_error); + self.show_failure_message( + &self.strings.cant_debug, + if create_error == 0 { + ERROR_GEN_FAILURE + } else { + create_error + }, + ); false } else { - // SAFETY: this branch is reached only after CreateProcessW succeeded, which returned - // two fresh handles whose ownership is transferred here. + // SAFETY: successful CreateProcessW returned two fresh handles and this is their only + // ownership transfer. The child owns its inherited copy of the ready event. match unsafe { own_created_process_handles(process_info) } { Ok(_) => true, Err(error) => { @@ -1014,86 +1074,259 @@ pub(super) fn affinity_cpu_mask(cpu_index: i32) -> usize { .unwrap_or(0) } -pub(super) fn load_debugger_path() -> Result, u32> { - // 进程页的“调试”命令依赖 AeDebug 注册表配置。 - // 这里只提取真正的可执行文件路径,过滤掉旧式 drwtsn32 之类的无效值。 - let mut key: HKEY = null_mut(); - let key_name = to_wide_null("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AeDebug"); - let value_name = to_wide_null("Debugger"); - // SAFETY: both input strings are terminated and `key` is a valid output location. - let open_status = - unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, key_name.as_ptr(), 0, KEY_READ, &mut key) }; - if open_status != 0 { - return if open_status == ERROR_FILE_NOT_FOUND || open_status == ERROR_PATH_NOT_FOUND { - Ok(None) - } else { - Err(open_status) +const AEDEBUG_KEY: &str = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AeDebug"; +const AEDEBUG_VALUE: &str = "Debugger"; +const MAX_DEBUGGER_COMMAND_BYTES: u32 = 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DebuggerRegistryView { + Native, + Registry32, + Registry64, +} + +impl DebuggerRegistryView { + const fn access_mask(self) -> u32 { + match self { + Self::Native => 0, + Self::Registry32 => KEY_WOW64_32KEY, + Self::Registry64 => KEY_WOW64_64KEY, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DebuggerCommand { + template: String, + executable: String, +} + +struct ProcThreadAttributeList { + storage: Vec, + handles: Box<[HANDLE; 1]>, + initialized: bool, +} + +impl ProcThreadAttributeList { + fn for_handle(handle: HANDLE) -> Result { + if handle.is_null() { + return Err(ERROR_INVALID_HANDLE); + } + + let mut byte_count = 0usize; + unsafe { + InitializeProcThreadAttributeList(null_mut(), 1, 0, &mut byte_count); + } + if byte_count == 0 { + return Err(nonzero_last_error()); + } + + let word_count = byte_count.div_ceil(size_of::()); + let mut value = Self { + storage: vec![0usize; word_count], + handles: Box::new([handle]), + initialized: false, }; + if unsafe { InitializeProcThreadAttributeList(value.as_ptr(), 1, 0, &mut byte_count) } == 0 + { + return Err(nonzero_last_error()); + } + value.initialized = true; + if unsafe { + UpdateProcThreadAttribute( + value.as_ptr(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + value.handles.as_mut_ptr().cast::(), + size_of::(), + null_mut(), + null_mut(), + ) + } == 0 + { + return Err(nonzero_last_error()); + } + Ok(value) + } + + fn as_ptr(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.storage.as_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST + } +} + +impl Drop for ProcThreadAttributeList { + fn drop(&mut self) { + if self.initialized { + unsafe { DeleteProcThreadAttributeList(self.as_ptr()) }; + } + } +} + +pub(super) fn load_debugger_path() -> Result, u32> { + // This is an availability probe for menu state. Launch-time selection is repeated against + // the selected process' machine type so the command can never use the wrong registry view. + let mut first_error = None; + for view in [ + DebuggerRegistryView::Native, + DebuggerRegistryView::Registry64, + DebuggerRegistryView::Registry32, + ] { + match load_debugger_command(view) { + Ok(Some(command)) if Path::new(&command.executable).is_file() => { + return Ok(Some(command.executable)); + } + Ok(_) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } } + if let Some(error) = first_error { + Err(error) + } else { + Ok(None) + } +} - let mut value_size = 0u32; - // SAFETY: `key` was opened successfully; this size query uses no data buffer and writes only - // to `value_size`. - let size_status = unsafe { - RegQueryValueExW( +fn load_debugger_command(view: DebuggerRegistryView) -> Result, u32> { + let Some((raw_command, value_type)) = read_aedebug_string(view)? else { + return Ok(None); + }; + parse_debugger_command(&raw_command, value_type) +} + +fn read_aedebug_string(view: DebuggerRegistryView) -> Result, u32> { + unsafe { + let key_name = to_wide_null(AEDEBUG_KEY); + let value_name = to_wide_null(AEDEBUG_VALUE); + let mut key: HKEY = null_mut(); + let open_status = RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + key_name.as_ptr(), + 0, + KEY_READ | view.access_mask(), + &mut key, + ); + if open_status == ERROR_FILE_NOT_FOUND || open_status == ERROR_PATH_NOT_FOUND { + return Ok(None); + } + if open_status != 0 { + return Err(open_status); + } + + let mut value_type = 0u32; + let mut value_size = 0u32; + let size_status = RegQueryValueExW( key, value_name.as_ptr(), null_mut(), - null_mut(), + &mut value_type, null_mut(), &mut value_size, - ) - }; - if size_status != 0 || value_size < 2 { - let close_status = unsafe { RegCloseKey(key) }; - if close_status != 0 { - return Err(close_status); + ); + if size_status == ERROR_FILE_NOT_FOUND { + let close_status = RegCloseKey(key); + return if close_status == 0 { + Ok(None) + } else { + Err(close_status) + }; + } + if size_status != 0 { + RegCloseKey(key); + return Err(size_status); + } + if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) + || value_size < size_of::() as u32 + || !value_size.is_multiple_of(size_of::() as u32) + || value_size > MAX_DEBUGGER_COMMAND_BYTES + { + RegCloseKey(key); + return Err(ERROR_INVALID_DATA); } - return if size_status == ERROR_FILE_NOT_FOUND { - Ok(None) - } else if size_status != 0 { - Err(size_status) - } else { - Err(ERROR_INVALID_DATA) - }; - } - let mut buffer = vec![0u16; (value_size as usize).div_ceil(size_of::()).max(2)]; - let mut value_type = 0u32; - // SAFETY: `buffer` is writable for the byte count returned by the size query and all output - // pointers reference live local variables. - let status = unsafe { - RegQueryValueExW( + let mut buffer = vec![0u16; value_size as usize / size_of::()]; + let mut actual_size = value_size; + let read_status = RegQueryValueExW( key, value_name.as_ptr(), null_mut(), &mut value_type, - buffer.as_mut_ptr() as *mut u8, - &mut value_size, - ) - }; - let close_status = unsafe { RegCloseKey(key) }; - if close_status != 0 { - return Err(close_status); + buffer.as_mut_ptr().cast::(), + &mut actual_size, + ); + let close_status = RegCloseKey(key); + if read_status == ERROR_MORE_DATA { + return Err(ERROR_MORE_DATA); + } + if read_status != 0 { + return Err(read_status); + } + if close_status != 0 { + return Err(close_status); + } + if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) + || actual_size < size_of::() as u32 + || !actual_size.is_multiple_of(size_of::() as u32) + || actual_size > value_size + { + return Err(ERROR_INVALID_DATA); + } + let units = actual_size as usize / size_of::(); + let Some(length) = buffer[..units].iter().position(|value| *value == 0) else { + return Err(ERROR_INVALID_DATA); + }; + Ok(Some(( + String::from_utf16(&buffer[..length]).map_err(|_| ERROR_INVALID_DATA)?, + value_type, + ))) } +} - if status != 0 || value_size < 2 || !(value_type == REG_SZ || value_type == REG_EXPAND_SZ) { - return Err(if status != 0 { - status - } else { - ERROR_INVALID_DATA - }); +fn debugger_registry_view_for_process(process: HANDLE) -> Result { + if process.is_null() { + return Err(ERROR_INVALID_HANDLE); + } + let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + if unsafe { IsWow64Process2(process, &mut process_machine, &mut native_machine) } == 0 { + return Err(nonzero_last_error()); } + debugger_registry_view_for_machines(process_machine, native_machine) +} - let length = buffer - .iter() - .position(|value| *value == 0) - .unwrap_or(buffer.len()); - let raw_command = String::from_utf16_lossy(&buffer[..length]); - let Some(executable) = normalize_debugger_command(&raw_command, value_type)? else { - return Ok(None); +fn debugger_registry_view_for_machines( + process_machine: u16, + native_machine: u16, +) -> Result { + let effective_machine = if process_machine == IMAGE_FILE_MACHINE_UNKNOWN { + native_machine + } else { + process_machine }; - Ok(Path::new(&executable).is_file().then_some(executable)) + let target_is_32_bit = match effective_machine { + IMAGE_FILE_MACHINE_I386 + | IMAGE_FILE_MACHINE_ARM + | IMAGE_FILE_MACHINE_ARMNT + | IMAGE_FILE_MACHINE_THUMB => true, + IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => false, + _ => return Err(ERROR_NOT_SUPPORTED), + }; + let native_is_64_bit = match native_machine { + IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => true, + IMAGE_FILE_MACHINE_I386 + | IMAGE_FILE_MACHINE_ARM + | IMAGE_FILE_MACHINE_ARMNT + | IMAGE_FILE_MACHINE_THUMB => false, + _ => return Err(ERROR_NOT_SUPPORTED), + }; + Ok(if !native_is_64_bit { + DebuggerRegistryView::Native + } else if target_is_32_bit { + DebuggerRegistryView::Registry32 + } else { + DebuggerRegistryView::Registry64 + }) } // 引用命令行参数。只在包含空格、制表符或引号时加引号,并正确处理反斜杠转义。 @@ -1147,20 +1380,108 @@ pub(super) fn extract_first_command_token(command_line: &str) -> String { } } -// 将 AeDebug 注册表值规范化:展开环境变量后提取可执行文件路径,过滤无效调试器。 -fn normalize_debugger_command(command_line: &str, value_type: u32) -> Result, u32> { +fn parse_debugger_command( + command_line: &str, + value_type: u32, +) -> Result, u32> { let expanded = if value_type == REG_EXPAND_SZ { expand_environment_variables(command_line)? - } else { + } else if value_type == REG_SZ { command_line.to_string() + } else { + return Err(ERROR_INVALID_DATA); }; - Ok(normalize_debugger_command_with( - &expanded, - REG_SZ, - str::to_string, - )) + parse_expanded_debugger_command(&expanded) +} + +fn parse_expanded_debugger_command(command_line: &str) -> Result, u32> { + let template = command_line.trim().to_string(); + let executable = extract_first_command_token(&template); + let file_name = Path::new(&executable) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if executable.is_empty() + || !Path::new(&executable).is_absolute() + || file_name.eq_ignore_ascii_case("drwtsn32") + || file_name.eq_ignore_ascii_case("drwtsn32.exe") + { + return Ok(None); + } + validate_debugger_template(&template)?; + Ok(Some(DebuggerCommand { + template, + executable, + })) +} + +fn validate_debugger_template(template: &str) -> Result<(), u32> { + let mut placeholders = 0usize; + let mut chars = template.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + continue; + } + match chars.next() { + Some('%') => {} + Some('l' | 'L') => { + if !matches!(chars.next(), Some('d' | 'D')) { + return Err(ERROR_INVALID_DATA); + } + placeholders += 1; + } + Some('p' | 'P') => return Err(ERROR_NOT_SUPPORTED), + Some(_) | None => return Err(ERROR_INVALID_DATA), + } + } + if placeholders == 2 { + Ok(()) + } else { + Err(ERROR_INVALID_DATA) + } } +fn format_debugger_template( + template: &str, + process_id: u32, + event_handle: usize, +) -> Result { + validate_debugger_template(template)?; + let replacements = [process_id.to_string(), event_handle.to_string()]; + let mut replacement_index = 0usize; + let mut output = String::with_capacity(template.len() + 32); + let mut chars = template.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + output.push(ch); + continue; + } + match chars.next() { + Some('%') => output.push('%'), + Some('l' | 'L') => { + if !matches!(chars.next(), Some('d' | 'D')) { + return Err(ERROR_INVALID_DATA); + } + output.push_str( + replacements + .get(replacement_index) + .ok_or(ERROR_INVALID_DATA)?, + ); + replacement_index += 1; + } + Some('p' | 'P') => return Err(ERROR_NOT_SUPPORTED), + Some(_) | None => return Err(ERROR_INVALID_DATA), + } + } + if replacement_index == replacements.len() { + Ok(output) + } else { + Err(ERROR_INVALID_DATA) + } +} + +// Compatibility helper used by the existing pure parsing tests. +#[cfg(test)] pub(super) fn normalize_debugger_command_with( command_line: &str, value_type: u32, @@ -1171,14 +1492,19 @@ where { let expanded = if value_type == REG_EXPAND_SZ { expand_environment_variables(command_line) - } else { + } else if value_type == REG_SZ { command_line.to_string() + } else { + return None; }; let executable = extract_first_command_token(&expanded); - + let file_name = Path::new(&executable) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); if executable.is_empty() - || executable.eq_ignore_ascii_case("drwtsn32") - || executable.eq_ignore_ascii_case("drwtsn32.exe") + || file_name.eq_ignore_ascii_case("drwtsn32") + || file_name.eq_ignore_ascii_case("drwtsn32.exe") { None } else { @@ -1189,21 +1515,16 @@ where // 展开字符串中的环境变量(如 %SystemRoot%)。 // 使用 Win32 ExpandEnvironmentStringsW API,正确处理 WOW64 重定向和 %% 转义。 fn expand_environment_variables(command_line: &str) -> Result { - // 安全性: the Win32 ExpandEnvironmentStringsW API reads the process environment block - // maintained by the kernel, which handles system-variable edge cases (WOW64 redirections, - // %% escaping, variable-length limits) correctly. let wide_input = to_wide_null(command_line); let required = unsafe { ExpandEnvironmentStringsW(wide_input.as_ptr(), null_mut(), 0) }; if required == 0 { - let error = unsafe { GetLastError() }; - return Err(if error == 0 { ERROR_GEN_FAILURE } else { error }); + return Err(nonzero_last_error()); } let mut buffer = vec![0u16; required as usize]; let written = unsafe { ExpandEnvironmentStringsW(wide_input.as_ptr(), buffer.as_mut_ptr(), required) }; if written == 0 || written > required { - let error = unsafe { GetLastError() }; - return Err(if error == 0 { ERROR_GEN_FAILURE } else { error }); + return Err(nonzero_last_error()); } let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len()); Ok(String::from_utf16_lossy(&buffer[..len])) @@ -1465,3 +1786,81 @@ fn query_windows_directory() -> Result { buffer.resize(length.saturating_add(1), 0); } } + +#[cfg(test)] +mod debugger_tests { + use super::*; + + #[test] + fn full_aedebug_template_preserves_debugger_specific_arguments() { + let template = r#""C:\Debuggers\windbg.exe" -p %ld -e %ld -g"#; + let command = parse_expanded_debugger_command(template).unwrap().unwrap(); + assert_eq!(command.executable, r"C:\Debuggers\windbg.exe"); + assert_eq!( + format_debugger_template(&command.template, 1234, 5678).unwrap(), + r#""C:\Debuggers\windbg.exe" -p 1234 -e 5678 -g"# + ); + } + + #[test] + fn visual_studio_jit_template_and_literal_percent_are_supported() { + let template = r#""C:\Windows\System32\vsjitdebugger.exe" -p %ld -e %ld --label 100%%"#; + assert_eq!( + format_debugger_template(template, 42, 99).unwrap(), + r#""C:\Windows\System32\vsjitdebugger.exe" -p 42 -e 99 --label 100%"# + ); + } + + #[test] + fn unsupported_or_ambiguous_templates_are_rejected() { + assert_eq!( + parse_expanded_debugger_command(r#""C:\Debuggers\dbg.exe" -p %ld -e %ld -j 0x%p"#), + Err(ERROR_NOT_SUPPORTED) + ); + for template in [ + r#""C:\Debuggers\dbg.exe" -p %ld"#, + r#""C:\Debuggers\dbg.exe" -p %ld -e %ld -x %ld"#, + r#""C:\Debuggers\dbg.exe" -p %q -e %ld"#, + ] { + assert!(parse_expanded_debugger_command(template).is_err()); + } + assert!( + parse_expanded_debugger_command(r#""relative\dbg.exe" -p %ld -e %ld"#) + .unwrap() + .is_none() + ); + } + + #[test] + fn target_machine_selects_the_matching_registry_view() { + assert_eq!( + debugger_registry_view_for_machines(IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_AMD64,) + .unwrap(), + DebuggerRegistryView::Registry32 + ); + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_UNKNOWN, + IMAGE_FILE_MACHINE_AMD64, + ) + .unwrap(), + DebuggerRegistryView::Registry64 + ); + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_UNKNOWN, + IMAGE_FILE_MACHINE_I386, + ) + .unwrap(), + DebuggerRegistryView::Native + ); + assert_eq!( + debugger_registry_view_for_machines( + IMAGE_FILE_MACHINE_ARMNT, + IMAGE_FILE_MACHINE_ARM64, + ) + .unwrap(), + DebuggerRegistryView::Registry32 + ); + } +} From c9a3f4910dec0fe397f42a4367bd56330eadda98 Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:17:40 +0800 Subject: [PATCH 06/11] Stage automatic v0.2.6 release --- .github/workflows/agent-prepare-v0.2.6.yml | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/agent-prepare-v0.2.6.yml diff --git a/.github/workflows/agent-prepare-v0.2.6.yml b/.github/workflows/agent-prepare-v0.2.6.yml new file mode 100644 index 0000000..7804d1a --- /dev/null +++ b/.github/workflows/agent-prepare-v0.2.6.yml @@ -0,0 +1,85 @@ +name: Prepare automatic v0.2.6 release + +on: + push: + branches: + - agent/fix-open-issue-root-causes + paths: + - .github/workflows/agent-prepare-v0.2.6.yml + +permissions: + contents: write + +jobs: + prepare-release: + if: github.actor != 'github-actions[bot]' + runs-on: windows-2025-vs2026 + timeout-minutes: 10 + defaults: + run: + shell: pwsh + + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Bump package version and document automatic release + run: | + function Replace-ExactlyOnce { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Old, + [Parameter(Mandatory)][string]$New + ) + $text = Get-Content -LiteralPath $Path -Raw + $count = ([regex]::Matches($text, [regex]::Escape($Old))).Count + if ($count -ne 1) { + throw "$Path expected exactly one match, found $count" + } + $updated = $text.Replace($Old, $New) + [IO.File]::WriteAllText((Resolve-Path -LiteralPath $Path).Path, $updated, [Text.UTF8Encoding]::new($false)) + } + + Replace-ExactlyOnce ` + -Path 'Cargo.toml' ` + -Old 'version = "0.2.5"' ` + -New 'version = "0.2.6"' + + Replace-ExactlyOnce ` + -Path 'Cargo.lock' ` + -Old "name = `"taskmgr-rs`"`nversion = `"0.2.5`"" ` + -New "name = `"taskmgr-rs`"`nversion = `"0.2.6`"" + + $oldEnglish = @' +To publish a version, first update `Cargo.toml` and `Cargo.lock`, merge the change, then push the +matching tag such as `v0.2.5`. The Release workflow rejects a tag that does not exactly match the +Cargo package version. It builds all three architectures in parallel, verifies the PE machine and +'@ + $newEnglish = @' +To publish a version, update `Cargo.toml` and `Cargo.lock` together and merge the change to `main`. +The Release workflow reads the package version and automatically creates the matching tag (for +example `v0.2.6`) when it does not exist. It builds all three architectures in parallel, verifies the PE machine and +'@ + Replace-ExactlyOnce -Path 'README.md' -Old $oldEnglish.TrimEnd("`r", "`n") -New $newEnglish.TrimEnd("`r", "`n") + + $oldChinese = @' +发布版本时,先同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入主线,再推送与版本完全一致的 tag, +例如 `v0.2.5`。Release 工作流会拒绝与 Cargo 包版本不一致的 tag,并行构建三种架构,核对 PE +'@ + $newChinese = @' +发布版本时,只需同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入 `main`。Release 工作流会读取包 +版本;如果对应 tag(例如 `v0.2.6`)尚不存在,就自动创建 tag,并行构建三种架构,核对 PE +'@ + Replace-ExactlyOnce -Path 'README.zh-CN.md' -Old $oldChinese.TrimEnd("`r", "`n") -New $newChinese.TrimEnd("`r", "`n") + + Remove-Item -LiteralPath '.github/workflows/agent-prepare-v0.2.6.yml' + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Cargo.toml Cargo.lock README.md README.zh-CN.md .github/workflows/agent-prepare-v0.2.6.yml + git diff --cached --check + git diff --cached --exit-code --quiet && throw 'release preparation produced no changes' + git commit -m "Prepare v0.2.6 automatic release" + git push origin "HEAD:$env:GITHUB_REF_NAME" From 7a9a2cfc37bf928c591431bbb8f6cf6adc63db91 Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:18:50 +0800 Subject: [PATCH 07/11] Fix release preparation workflow indentation --- .github/workflows/agent-prepare-v0.2.6.yml | 33 ++++++++++++---------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/workflows/agent-prepare-v0.2.6.yml b/.github/workflows/agent-prepare-v0.2.6.yml index 7804d1a..8bc094b 100644 --- a/.github/workflows/agent-prepare-v0.2.6.yml +++ b/.github/workflows/agent-prepare-v0.2.6.yml @@ -53,25 +53,25 @@ jobs: -New "name = `"taskmgr-rs`"`nversion = `"0.2.6`"" $oldEnglish = @' -To publish a version, first update `Cargo.toml` and `Cargo.lock`, merge the change, then push the -matching tag such as `v0.2.5`. The Release workflow rejects a tag that does not exactly match the -Cargo package version. It builds all three architectures in parallel, verifies the PE machine and -'@ + To publish a version, first update `Cargo.toml` and `Cargo.lock`, merge the change, then push the + matching tag such as `v0.2.5`. The Release workflow rejects a tag that does not exactly match the + Cargo package version. It builds all three architectures in parallel, verifies the PE machine and + '@ $newEnglish = @' -To publish a version, update `Cargo.toml` and `Cargo.lock` together and merge the change to `main`. -The Release workflow reads the package version and automatically creates the matching tag (for -example `v0.2.6`) when it does not exist. It builds all three architectures in parallel, verifies the PE machine and -'@ + To publish a version, update `Cargo.toml` and `Cargo.lock` together and merge the change to `main`. + The Release workflow reads the package version and automatically creates the matching tag (for + example `v0.2.6`) when it does not exist. It builds all three architectures in parallel, verifies the PE machine and + '@ Replace-ExactlyOnce -Path 'README.md' -Old $oldEnglish.TrimEnd("`r", "`n") -New $newEnglish.TrimEnd("`r", "`n") $oldChinese = @' -发布版本时,先同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入主线,再推送与版本完全一致的 tag, -例如 `v0.2.5`。Release 工作流会拒绝与 Cargo 包版本不一致的 tag,并行构建三种架构,核对 PE -'@ + 发布版本时,先同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入主线,再推送与版本完全一致的 tag, + 例如 `v0.2.5`。Release 工作流会拒绝与 Cargo 包版本不一致的 tag,并行构建三种架构,核对 PE + '@ $newChinese = @' -发布版本时,只需同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入 `main`。Release 工作流会读取包 -版本;如果对应 tag(例如 `v0.2.6`)尚不存在,就自动创建 tag,并行构建三种架构,核对 PE -'@ + 发布版本时,只需同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入 `main`。Release 工作流会读取包 + 版本;如果对应 tag(例如 `v0.2.6`)尚不存在,就自动创建 tag,并行构建三种架构,核对 PE + '@ Replace-ExactlyOnce -Path 'README.zh-CN.md' -Old $oldChinese.TrimEnd("`r", "`n") -New $newChinese.TrimEnd("`r", "`n") Remove-Item -LiteralPath '.github/workflows/agent-prepare-v0.2.6.yml' @@ -80,6 +80,9 @@ example `v0.2.6`) when it does not exist. It builds all three architectures in p git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Cargo.toml Cargo.lock README.md README.zh-CN.md .github/workflows/agent-prepare-v0.2.6.yml git diff --cached --check - git diff --cached --exit-code --quiet && throw 'release preparation produced no changes' + git diff --cached --exit-code --quiet + if ($LASTEXITCODE -eq 0) { + throw 'release preparation produced no changes' + } git commit -m "Prepare v0.2.6 automatic release" git push origin "HEAD:$env:GITHUB_REF_NAME" From 16520934ccb3de1acbe94aa174b6d08496e77898 Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:20:27 +0800 Subject: [PATCH 08/11] Make release version bump newline-safe --- .github/workflows/agent-prepare-v0.2.6.yml | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/agent-prepare-v0.2.6.yml b/.github/workflows/agent-prepare-v0.2.6.yml index 8bc094b..2ee8824 100644 --- a/.github/workflows/agent-prepare-v0.2.6.yml +++ b/.github/workflows/agent-prepare-v0.2.6.yml @@ -42,15 +42,30 @@ jobs: [IO.File]::WriteAllText((Resolve-Path -LiteralPath $Path).Path, $updated, [Text.UTF8Encoding]::new($false)) } + function Replace-RegexExactlyOnce { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Pattern, + [Parameter(Mandatory)][string]$Replacement + ) + $text = Get-Content -LiteralPath $Path -Raw + $matches = [regex]::Matches($text, $Pattern) + if ($matches.Count -ne 1) { + throw "$Path expected exactly one regex match, found $($matches.Count)" + } + $updated = [regex]::Replace($text, $Pattern, $Replacement, 1) + [IO.File]::WriteAllText((Resolve-Path -LiteralPath $Path).Path, $updated, [Text.UTF8Encoding]::new($false)) + } + Replace-ExactlyOnce ` -Path 'Cargo.toml' ` -Old 'version = "0.2.5"' ` -New 'version = "0.2.6"' - Replace-ExactlyOnce ` + Replace-RegexExactlyOnce ` -Path 'Cargo.lock' ` - -Old "name = `"taskmgr-rs`"`nversion = `"0.2.5`"" ` - -New "name = `"taskmgr-rs`"`nversion = `"0.2.6`"" + -Pattern 'name = "taskmgr-rs"(\r?\n)version = "0\.2\.5"' ` + -Replacement 'name = "taskmgr-rs"$1version = "0.2.6"' $oldEnglish = @' To publish a version, first update `Cargo.toml` and `Cargo.lock`, merge the change, then push the @@ -60,7 +75,8 @@ jobs: $newEnglish = @' To publish a version, update `Cargo.toml` and `Cargo.lock` together and merge the change to `main`. The Release workflow reads the package version and automatically creates the matching tag (for - example `v0.2.6`) when it does not exist. It builds all three architectures in parallel, verifies the PE machine and + example `v0.2.6`) when it does not exist. It then builds all three architectures in parallel, + verifies the PE machine and '@ Replace-ExactlyOnce -Path 'README.md' -Old $oldEnglish.TrimEnd("`r", "`n") -New $newEnglish.TrimEnd("`r", "`n") From babb60cda5f132023ebe1237735b68931652cc04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:20:44 +0000 Subject: [PATCH 09/11] Prepare v0.2.6 automatic release --- .github/workflows/agent-prepare-v0.2.6.yml | 104 --------------------- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 +- README.zh-CN.md | 4 +- 5 files changed, 8 insertions(+), 111 deletions(-) delete mode 100644 .github/workflows/agent-prepare-v0.2.6.yml diff --git a/.github/workflows/agent-prepare-v0.2.6.yml b/.github/workflows/agent-prepare-v0.2.6.yml deleted file mode 100644 index 2ee8824..0000000 --- a/.github/workflows/agent-prepare-v0.2.6.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Prepare automatic v0.2.6 release - -on: - push: - branches: - - agent/fix-open-issue-root-causes - paths: - - .github/workflows/agent-prepare-v0.2.6.yml - -permissions: - contents: write - -jobs: - prepare-release: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025-vs2026 - timeout-minutes: 10 - defaults: - run: - shell: pwsh - - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Bump package version and document automatic release - run: | - function Replace-ExactlyOnce { - param( - [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][string]$Old, - [Parameter(Mandatory)][string]$New - ) - $text = Get-Content -LiteralPath $Path -Raw - $count = ([regex]::Matches($text, [regex]::Escape($Old))).Count - if ($count -ne 1) { - throw "$Path expected exactly one match, found $count" - } - $updated = $text.Replace($Old, $New) - [IO.File]::WriteAllText((Resolve-Path -LiteralPath $Path).Path, $updated, [Text.UTF8Encoding]::new($false)) - } - - function Replace-RegexExactlyOnce { - param( - [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][string]$Pattern, - [Parameter(Mandatory)][string]$Replacement - ) - $text = Get-Content -LiteralPath $Path -Raw - $matches = [regex]::Matches($text, $Pattern) - if ($matches.Count -ne 1) { - throw "$Path expected exactly one regex match, found $($matches.Count)" - } - $updated = [regex]::Replace($text, $Pattern, $Replacement, 1) - [IO.File]::WriteAllText((Resolve-Path -LiteralPath $Path).Path, $updated, [Text.UTF8Encoding]::new($false)) - } - - Replace-ExactlyOnce ` - -Path 'Cargo.toml' ` - -Old 'version = "0.2.5"' ` - -New 'version = "0.2.6"' - - Replace-RegexExactlyOnce ` - -Path 'Cargo.lock' ` - -Pattern 'name = "taskmgr-rs"(\r?\n)version = "0\.2\.5"' ` - -Replacement 'name = "taskmgr-rs"$1version = "0.2.6"' - - $oldEnglish = @' - To publish a version, first update `Cargo.toml` and `Cargo.lock`, merge the change, then push the - matching tag such as `v0.2.5`. The Release workflow rejects a tag that does not exactly match the - Cargo package version. It builds all three architectures in parallel, verifies the PE machine and - '@ - $newEnglish = @' - To publish a version, update `Cargo.toml` and `Cargo.lock` together and merge the change to `main`. - The Release workflow reads the package version and automatically creates the matching tag (for - example `v0.2.6`) when it does not exist. It then builds all three architectures in parallel, - verifies the PE machine and - '@ - Replace-ExactlyOnce -Path 'README.md' -Old $oldEnglish.TrimEnd("`r", "`n") -New $newEnglish.TrimEnd("`r", "`n") - - $oldChinese = @' - 发布版本时,先同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入主线,再推送与版本完全一致的 tag, - 例如 `v0.2.5`。Release 工作流会拒绝与 Cargo 包版本不一致的 tag,并行构建三种架构,核对 PE - '@ - $newChinese = @' - 发布版本时,只需同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入 `main`。Release 工作流会读取包 - 版本;如果对应 tag(例如 `v0.2.6`)尚不存在,就自动创建 tag,并行构建三种架构,核对 PE - '@ - Replace-ExactlyOnce -Path 'README.zh-CN.md' -Old $oldChinese.TrimEnd("`r", "`n") -New $newChinese.TrimEnd("`r", "`n") - - Remove-Item -LiteralPath '.github/workflows/agent-prepare-v0.2.6.yml' - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock README.md README.zh-CN.md .github/workflows/agent-prepare-v0.2.6.yml - git diff --cached --check - git diff --cached --exit-code --quiet - if ($LASTEXITCODE -eq 0) { - throw 'release preparation produced no changes' - } - git commit -m "Prepare v0.2.6 automatic release" - git push origin "HEAD:$env:GITHUB_REF_NAME" diff --git a/Cargo.lock b/Cargo.lock index 8ac2200..a0602d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,7 +295,7 @@ dependencies = [ [[package]] name = "taskmgr-rs" -version = "0.2.5" +version = "0.2.6" dependencies = [ "crc32fast", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index 832b7d6..476994a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ # 这里定义包名、版本、发布构建策略,以及 Win32 API 绑定所需的功能开关。 [package] name = "taskmgr-rs" -version = "0.2.5" +version = "0.2.6" authors = ["taskmgr-rs contributors"] description = "A classic Windows Task Manager built in Rust with native Win32 APIs." license = "MIT" diff --git a/README.md b/README.md index aba2498..4738347 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,10 @@ Pull requests and pushes to `main` run formatting, all-target checks, strict Cli x86_64 release build on GitHub's Windows Server 2025 / Visual Studio 2026 runner. Separate jobs compile i686 and ARM64; i686 tests also run under WOW64. -To publish a version, first update `Cargo.toml` and `Cargo.lock`, merge the change, then push the -matching tag such as `v0.2.5`. The Release workflow rejects a tag that does not exactly match the -Cargo package version. It builds all three architectures in parallel, verifies the PE machine and +To publish a version, update `Cargo.toml` and `Cargo.lock` together and merge the change to `main`. +The Release workflow reads the package version and automatically creates the matching tag (for +example `v0.2.6`) when it does not exist. It then builds all three architectures in parallel, +verifies the PE machine and Windows version resources, writes `SHA256SUMS.txt`, creates GitHub build-provenance attestations, and publishes only after the uploaded sizes and digests match. A publishing failure leaves only a draft; a build failure creates no release, so neither path exposes a partial release. diff --git a/README.zh-CN.md b/README.zh-CN.md index 79b3a55..c1cd482 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -79,8 +79,8 @@ Pull Request 和推送到 `main` 的提交会在 GitHub 的 Windows Server 2025 runner 上执行格式检查、全目标 check、严格 Clippy、完整测试和 x86_64 release 构建。独立任务 同时验证 i686 与 ARM64,i686 测试会在 WOW64 下实际执行。 -发布版本时,先同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入主线,再推送与版本完全一致的 tag, -例如 `v0.2.5`。Release 工作流会拒绝与 Cargo 包版本不一致的 tag,并行构建三种架构,核对 PE +发布版本时,只需同步修改 `Cargo.toml` 与 `Cargo.lock` 并合入 `main`。Release 工作流会读取包 +版本;如果对应 tag(例如 `v0.2.6`)尚不存在,就自动创建 tag,并行构建三种架构,核对 PE 机器类型和 Windows 版本资源,生成 `SHA256SUMS.txt` 与 GitHub 构建来源证明。只有 GitHub 服务器上的资产大小和摘要都与本地一致时才公开 Release。发布阶段失败时只保留草稿,构建 阶段失败时不会创建 Release,因此都不会暴露不完整发布。 From 70498c35f1d51e1f9e0b841139ab1cb371013e06 Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:21:50 +0800 Subject: [PATCH 10/11] Trigger final PR validation --- .github/.ci-trigger-v0.2.6 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/.ci-trigger-v0.2.6 diff --git a/.github/.ci-trigger-v0.2.6 b/.github/.ci-trigger-v0.2.6 new file mode 100644 index 0000000..9f2931f --- /dev/null +++ b/.github/.ci-trigger-v0.2.6 @@ -0,0 +1 @@ +temporary CI trigger for final v0.2.6 head From bc527f279f68c0b8e5d72d989117b6a311cc0ed5 Mon Sep 17 00:00:00 2001 From: JamesLinYJ <110664404+JamesLinYJ@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:22:02 +0800 Subject: [PATCH 11/11] Remove final PR validation trigger --- .github/.ci-trigger-v0.2.6 | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/.ci-trigger-v0.2.6 diff --git a/.github/.ci-trigger-v0.2.6 b/.github/.ci-trigger-v0.2.6 deleted file mode 100644 index 9f2931f..0000000 --- a/.github/.ci-trigger-v0.2.6 +++ /dev/null @@ -1 +0,0 @@ -temporary CI trigger for final v0.2.6 head