这是一个Rust 编写的 Windows GUI 小程序,作用是:自动采集本机设备信息 → 弹窗让用户选择保存位置 → 导出为 Excel 盘点表。下面按结构逐部分拆解。
一、整体功能流程
启动 → 隐藏控制台(GUI 子系统) │ ├─ 1. terminal::detect() 采集本机硬件/设备信息 ├─ 2. 获取北京日期 + 桌面路径 构造默认文件名(设备信息登记表YYYYMMDD.xlsx) ├─ 3. ask_save_path() 弹 Windows 原生"另存为"对话框 └─ 4. asset::save(info, path) 若用户确认 → 生成 xlsx 文件任一步出错都不会崩溃,而是写入%TEMP%\pc_ledger_error.log。
二、顶部属性与导入
#![windows_subsystem = "windows"]
crate 级属性,告诉链接器使用Windows GUI 子系统。效果是双击 exe 时不会弹出黑色控制台窗口——桌面小工具的标准做法。代价是println!等 stdout 输出不显示,因此代码里所有错误都改用文件日志来记录。
依赖的 crate
| 导入 | 用途 |
|---|---|
bridge::time::BeijingDateTime | 自建库,取北京时间 |
windows::core::{w, HSTRING} | w!宏生成宽字符串字面量PCWSTR;HSTRING是 Windows 堆分配 UTF-16 字符串 |
windows::Win32::System::Com | COM 初始化/销毁、创建对象、释放 COM 内存 |
windows::Win32::UI::Shell | 文件保存对话框、FOLDERID_Desktop已知文件夹、SIGDN_FILESYSPATH |
mod asset; mod terminal; | 两个业务模块:Excel 写入、终端信息采集 |
三、main()主流程
letinfo=terminal::Terminal::detect();// ①采集letnow=time::BeijingDateTime::now();// ②获取北京日期let(year,month,day)=now.ymd();letdate_str=format!("{:04}{:02}{:02}",year,month,day);// 例:20250115letdesktop=desktop_path().unwrap_or_else(||{// ③桌面路径,失败回退"."write_error_log("获取桌面路径失败,退回到当前目录");".".to_string()});letdefault_path=format!("{}\\设备信息登记表{}.xlsx",desktop,date_str);ifletSome(save_path)=ask_save_path(&default_path){// ④弹窗ifletErr(e)=asset::save(&info,&save_path){// ⑤落盘write_error_log(&format!("Excel 导出失败: {}",e));}}设计要点
- 默认文件名中嵌入日期,避免每日盘点覆盖上次结果。
- 桌面路径获取失败时降级到当前目录,程序仍能运行。
- 用户点"取消"返回
None,什么都不做直接退出(无提示,也是无窗口程序的常见风格)。
四、write_error_log()— 错误日志
fnwrite_error_log(msg:&str){letlog_path=std::env::temp_dir().join("pc_ledger_error.log");let_=std::fs::write(log_path,msg);}- 写入
%TEMP%\pc_ledger_error.log,避开当前工作目录(双击运行时工作目录可能是System32或不可写的位置)。 std::fs::write是覆盖写,而非追加——即只保留最后一次错误,简单但也意味着历史错误会丢。- 忽略返回值的
let _ =,防止日志本身出错时再引发 panic。
五、desktop_path()— 取桌面路径(Win32)
unsafe{letpath_ptr=SHGetKnownFolderPath(&FOLDERID_Desktop,KF_FLAG_DEFAULT,None).ok()?;ifpath_ptr.is_null(){returnNone;}letpath_result=path_ptr.to_string();// ①先转 StringCoTaskMemFree(Some(path_ptr.0as*const_));// ②无条件释放letpath_string=path_result.ok()?;// ③再判成败ifpath_string.is_empty(){None}else{Some(path_string)}}关键点 1:用SHGetKnownFolderPath而不是%USERPROFILE%\Desktop
- 用户可能把桌面重定向到 OneDrive、D 盘等,硬编码路径不可靠。
SHGetKnownFolderPath返回CoTaskMem 分配的宽字符串指针PWSTR,必须手动CoTaskMemFree释放,否则泄漏。
关键点 2:转换 → 释放 → 判结果的顺序
pwstr.to_string()可能因为字符串内非法 UTF-16(如孤立代理项)而返回Err。- 如果写成
path_ptr.to_string().ok()?,遇到错误会提前return,跳过CoTaskMemFree→ 内存泄漏。 - 因此代码把"释放"放到转换和结果判定之间,保证无论如何都释放。这是很细心的正确写法。
关键点 3:path_ptr.0 as *const _
PWSTR是pub struct PWSTR(pub *mut u16)的元组结构体。CoTaskMemFree签名是Option<*const c_void>,所以取.0再 cast。Some(...)而不是裸指针,因为 API 定义如此。
六、ask_save_path()— COM 生命周期管理
unsafe{ifCoInitializeEx(None,COINIT_APARTMENTTHREADED).is_err(){...;returnNone;}letresult=show_dialog(default_path);CoUninitialize();result}COINIT_APARTMENTTHREADED=STA(单线程套间)。文件对话框、Shell 对象都要求 STA 模式,用 MTA 会失败或行为怪异。- 严格初始化 / 反初始化成对。注意:如果
show_dialog内部发生 panic,CoUninitialize不会执行——但 Rust 默认panic=abort在windowscrate +windows_subsystem下通常也不会跨 FFI 回溯,所以这里可接受,但严谨写法可用 RAII guard(struct ComScope+Drop)自动反初始化。
七、show_dialog()— 真正的对话框实现
letdialog:IFileSaveDialog=CoCreateInstance(&FileSaveDialog,None,CLSCTX_INPROC_SERVER).ok()?;CoCreateInstance创建IFileSaveDialog接口实例。FileSaveDialog是 CLSID,CLSCTX_INPROC_SERVER表示进程内 DLL。- 这是 Vista+ 的 COM 文件对话框(比旧的
GetSaveFileNameW灵活,支持自定义控件、过滤、扩展名)。
7.1 设置对话框属性
let_=dialog.SetTitle(&HSTRING::from("保存资产盘点表"));letfilter=COMDLG_FILTERSPEC{pszName:w!("Excel 文件 (*.xlsx)"),// 显示给用户的名字pszSpec:w!("*.xlsx"),// 实际过滤模式};let_=dialog.SetFileTypes(&[filter]);let_=dialog.SetDefaultExtension(w!("xlsx"));w!宏用于编译期常量宽字符串;HSTRING::from用于动态构造(SetTitle需要HSTRING)。COMDLG_FILTERSPEC的字段是PCWSTR,所以用w!。- 所有
Set*调用都用let _ =忽略错误——属性设置失败不致命,对话框仍能显示。
7.2 预设默认文件名 & 目录
letpath_obj=std::path::Path::new(default_path);ifletSome(file_name)=path_obj.file_name(){let_=dialog.SetFileName(&HSTRING::from(file_name.to_string_lossy().as_ref()));}ifletSome(folder_path)=path_obj.parent(){ifletOk(folder_item)=SHCreateItemFromParsingName::<_,_,IShellItem>(&HSTRING::from(folder_path.to_string_lossy().as_ref()),None,){let_=dialog.SetFolder(&folder_item);}}- 用
std::path::Path解析"C:\Users\x\Desktop\设备信息登记表20250115.xlsx",分别取出文件名、父目录。 SHCreateItemFromParsingName把文件系统路径转成IShellItem(Shell 命名空间对象),因为SetFolder要的是IShellItem。- 泛型
<_, _, IShellItem>让编译器推断前两个参数类型、显式指定输出类型。 - 若
SetFolder失败(路径不存在等),对话框仍会打开,只是默认位置不是桌面。
7.3 显示模态对话框并取结果
ifdialog.Show(None).is_ok(){// None = 无 owner 窗口ifletOk(result_item)=dialog.GetResult(){// 拿到用户选择ifletOk(path_ptr)=result_item.GetDisplayName(SIGDN_FILESYSPATH){if!path_ptr.is_null(){letpath_result=path_ptr.to_string();CoTaskMemFree(Some(path_ptr.0as*const_));letpath_string=path_result.ok()?;ifpath_string.is_empty(){returnNone;}returnSome(path_string);}}}}NoneShow(None):模态对话框,无 owner 窗口(因为程序无主窗口)。SIGDN_FILESYSPATH:请求文件系统路径形式的显示名(相对的还有SIGDN_NORMALDISPLAY友好名、SIGDN_URL等)。- 同样采用转换 → 释放 → 判成败的顺序,原因同前。
- 用户点取消时
Show返回Err(HRESULT_FROM_WIN32(ERROR_CANCELLED)),这里统一走None分支。
八、安全性与内存管理要点汇总
| 场景 | 处理 |
|---|---|
SHGetKnownFolderPath返回的PWSTR | CoTaskMemFree释放,且放在转换后、判结果前 |
GetDisplayName返回的PWSTR | 同上 |
| COM 初始化 | CoInitializeEx(STA)+CoUninitialize成对 |
| STA 要求 | COINIT_APARTMENTTHREADED,文件对话框必须 |
| 指针空判 | is_null()检查后再to_string() |
| 错误日志 | 写%TEMP%,不依赖 cwd;let _ =忽略返回值 |
九、可以改进的地方(供参考)
- 错误日志用覆盖写:建议改
OpenOptions::new().append(true).create(true),保留多次运行历史。 - 失败时无 UI 反馈:
asset::save失败、COM 初始化失败,用户看不到任何提示,只写日志。可考虑MessageBoxW。 - COM 反初始化未 RAII 化:若
show_dialogpanic,CoUninitialize会漏掉。可用带Drop的 guard。 write_error_log中的let _ =是好的;但多错误时相互覆盖,可考虑追加时间戳。SIGDN_FILESYSPATH结果为空串检查:return None是对的,但空串理论上等价于取消,可以合并逻辑。- filter 生命周期:
COMDLG_FILTERSPEC数组传引用&[filter],栈上对象在SetFileTypes调用期间有效即可(Shell 内部复制),当前写法正确。 - 文件路径混用
/和\:default_path用\\拼接(Windows 路径),后续Path::parent在 Windows 上能正确处理;但若桌面路径尾部已含\也无碍,只是可读性偏 Windows 风格。 asset::save调用前建议检查扩展名:用户可能手输foo.xls,SetDefaultExtension只补全无扩展名情况。
十、一句话总结
这是一段结构清晰、内存管理严谨的 Win32+COM Rust 代码:利用
#![windows_subsystem]做成无窗口的小工具,通过SHGetKnownFolderPath精确定位桌面、通过IFileSaveDialog提供原生保存体验,并非常细致地处理了COM 内存释放顺序与STA 线程模型两个最容易出错的点。