refactor: 提取 core 库 + 新增 CLI 版本

- 创建 Cargo workspace(core / src-tauri / cli 三 crate)
- core: 纯 Rust 库,零 Tauri 依赖,包含所有业务逻辑
- src-tauri/commands: 改为薄包装,调用 core 函数
- cli: 基于 clap 的命令行工具,支持 JSON 输出
- CLI 命令: list, add, remove, conflicts, scan, profile, check-admin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:13:28 +08:00
parent 5a864c41b2
commit cd896d389b
22 changed files with 6307 additions and 650 deletions
Generated
+5329
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = [
"core",
"src-tauri",
"cli",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "patheditor-cli"
version = "5.0.0"
description = "PathEditor CLI — command-line interface for Windows PATH management"
authors = ["刘航宇"]
license = "MIT"
edition = "2021"
[dependencies]
path-editor-core = { path = "../core" }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
[[bin]]
name = "patheditor"
path = "src/main.rs"
+230
View File
@@ -0,0 +1,230 @@
use clap::{Parser, Subcommand};
use path_editor_core as core;
use serde_json::json;
#[derive(Parser)]
#[command(name = "patheditor", version = "5.0.0")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// 列出 PATH 路径
List {
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
#[arg(long)] json: bool,
},
/// 添加一条路径
Add {
path: String,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 删除指定位置的路径
Remove {
index: usize,
#[arg(short, long)] system: bool,
},
/// 检测可执行文件冲突
Conflicts { #[arg(long)] json: bool },
/// 列出 PATH 中可执行文件
Scan {
#[arg(long)] query: Option<String>,
#[arg(long)] json: bool,
},
/// 管理配置文件
#[command(subcommand)]
Profile(ProfileCmd),
/// 检查管理员权限
CheckAdmin { #[arg(long)] json: bool },
}
#[derive(Subcommand)]
enum ProfileCmd {
/// 列出所有配置
List { #[arg(long)] json: bool },
/// 保存当前 PATH 为配置
Save { name: String },
/// 加载配置(预览)
Load { name: String },
/// 应用配置(写入注册表)
Apply { name: String },
/// 删除配置
Delete { name: String },
}
fn exit_err(msg: &str) -> ! {
eprintln!("错误: {msg}");
std::process::exit(1);
}
fn pick_target(system: bool, user: bool) -> &'static str {
if system { "system" } else { "user" }
}
// ── 命令实现 ──
fn cmd_list(system: bool, user: bool, json_out: bool) {
let mut sys: Vec<String> = vec![];
let mut usr: Vec<String> = vec![];
if system || (!system && !user) {
sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
}
if user || (!system && !user) {
usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
}
if json_out {
let output = json!({ "system": { "paths": sys, "count": sys.len() }, "user": { "paths": usr, "count": usr.len() } });
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
if !sys.is_empty() {
println!("═══ 系统 PATH ({}) ═══", sys.len());
for (i, p) in sys.iter().enumerate() { println!(" [{}] {}", i, p); }
}
if !usr.is_empty() {
println!("═══ 用户 PATH ({}) ═══", usr.len());
for (i, p) in usr.iter().enumerate() { println!(" [{}] {}", i, p); }
}
}
}
fn cmd_add(path: String, system: bool, user: bool) {
let target = pick_target(system, user);
let (mut list, save_fn): (Vec<String>, _) = if target == "system" {
(core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)),
core::registry::save_system_paths as fn(Vec<String>) -> Result<(), String>)
} else {
(core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)),
core::registry::save_user_paths as fn(Vec<String>) -> Result<(), String>)
};
list.push(path.clone());
save_fn(list).unwrap_or_else(|e| exit_err(&e));
let label = if target == "system" { "系统" } else { "用户" };
println!("已添加到{} PATH: {path}", label);
}
fn cmd_remove(index: usize, system: bool) {
let target = pick_target(system, false);
let (mut list, save_fn): (Vec<String>, _) = if target == "system" {
(core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)),
core::registry::save_system_paths as fn(Vec<String>) -> Result<(), String>)
} else {
(core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)),
core::registry::save_user_paths as fn(Vec<String>) -> Result<(), String>)
};
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let removed = list.remove(index);
save_fn(list).unwrap_or_else(|e| exit_err(&e));
println!("已删除: {removed}");
}
fn cmd_conflicts(json_out: bool) {
let mut paths: Vec<String> = vec![];
if let Ok(sys) = core::registry::load_system_paths() { paths.extend(sys); }
if let Ok(usr) = core::registry::load_user_paths() { paths.extend(usr); }
let conflicts = core::scanner::scan_conflicts(paths).unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&conflicts).unwrap());
} else if conflicts.is_empty() {
println!("未发现可执行文件冲突。");
} else {
println!("═══ 可执行文件冲突({} 个)═══\n", conflicts.len());
for c in &conflicts {
println!(" {}", c.name);
for loc in &c.locations {
println!(" {} {}", if loc.priority == 0 { "✓ 优先" } else { "✗ 遮蔽" }, loc.dir);
}
println!();
}
}
}
fn cmd_scan(query: Option<String>, json_out: bool) {
let mut paths: Vec<String> = vec![];
if let Ok(sys) = core::registry::load_system_paths() { paths.extend(sys); }
if let Ok(usr) = core::registry::load_user_paths() { paths.extend(usr); }
let groups = core::scanner::scan_tools(paths, query.unwrap_or_default()).unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&groups).unwrap());
} else {
for g in &groups {
if !g.exists { println!(" {} (不存在)", g.dir); continue; }
println!("═══ {} ═══", g.dir);
for exe in &g.exes { println!(" {}", exe); }
}
}
}
fn cmd_check_admin(json_out: bool) {
let is_admin = core::system::check_admin();
if json_out {
println!("{}", json!({"admin": is_admin}));
} else {
println!("管理员权限: {}", if is_admin { "" } else { "" });
}
}
fn profile_list(json_out: bool) {
let list = core::profiles::list_profiles().unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&list).unwrap());
} else if list.is_empty() {
println!("暂无配置文件。");
} else {
for p in &list { println!(" {} ({})", p.name, p.modified); }
}
}
fn profile_save(name: String) {
let sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
let sys_entries = sys.into_iter().map(|p| core::profiles::ProfilePathEntry { path: p, enabled: true }).collect();
let usr_entries = usr.into_iter().map(|p| core::profiles::ProfilePathEntry { path: p, enabled: true }).collect();
core::profiles::save_profile(name.clone(), sys_entries, usr_entries).unwrap_or_else(|e| exit_err(&e));
println!("已保存配置: {name}");
}
fn profile_load(name: String) {
let data = core::profiles::load_profile(name.clone()).unwrap_or_else(|e| exit_err(&e));
println!("═══ 系统 PATH ({} 条) ═══", data.sys.len());
for e in &data.sys { println!(" [{}] {}", if e.enabled { "" } else { "" }, e.path); }
println!("═══ 用户 PATH ({} 条) ═══", data.user.len());
for e in &data.user { println!(" [{}] {}", if e.enabled { "" } else { "" }, e.path); }
}
fn profile_apply(name: String) {
let data = core::profiles::load_profile(name.clone()).unwrap_or_else(|e| exit_err(&e));
let sys: Vec<String> = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
let usr: Vec<String> = data.user.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
core::registry::save_system_paths(sys).unwrap_or_else(|e| exit_err(&e));
core::registry::save_user_paths(usr).unwrap_or_else(|e| exit_err(&e));
core::system::broadcast_env_change();
println!("配置文件 \"{name}\" 已写入注册表。");
}
fn profile_delete(name: String) {
core::profiles::delete_profile(name.clone()).unwrap_or_else(|e| exit_err(&e));
println!("已删除配置: {name}");
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::List { system, user, json } => cmd_list(system, user, json),
Command::Add { path, system, user } => cmd_add(path, system, user),
Command::Remove { index, system } => cmd_remove(index, system),
Command::Conflicts { json } => cmd_conflicts(json),
Command::Scan { query, json } => cmd_scan(query, json),
Command::CheckAdmin { json } => cmd_check_admin(json),
Command::Profile(cmd) => match cmd {
ProfileCmd::List { json } => profile_list(json),
ProfileCmd::Save { name } => profile_save(name),
ProfileCmd::Load { name } => profile_load(name),
ProfileCmd::Apply { name } => profile_apply(name),
ProfileCmd::Delete { name } => profile_delete(name),
},
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "path-editor-core"
version = "5.0.0"
description = "PathEditor core library — shared between GUI and CLI"
authors = ["刘航宇"]
license = "MIT"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
winreg = "0.52"
dirs = "5"
chrono = "0.4"
+68
View File
@@ -0,0 +1,68 @@
use chrono::Local;
use std::path::PathBuf;
use winreg::enums::*;
use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH};
fn backup_base_dir() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join("backups")
}
/// 获取 APPDATA 路径下的备份目录
pub fn get_appdata_dir() -> String {
backup_base_dir().to_string_lossy().to_string()
}
/// 备份当前注册表中的系统 PATH 和用户 PATH
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
let backup_dir = match custom_dir {
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
_ => backup_base_dir(),
};
std::fs::create_dir_all(&backup_dir)
.map_err(|e| format!("无法创建备份目录: {}", e))?;
// 读取当前注册表中的值(保存前的旧值)
let sys_paths = registry::load_paths(
HKEY_LOCAL_MACHINE,
SYS_REG_PATH,
"系统",
)?;
let user_paths = registry::load_paths(
HKEY_CURRENT_USER,
USER_REG_PATH,
"用户",
)?;
let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("path_backup_{}.txt", timestamp);
let filepath = backup_dir.join(&filename);
let mut content = String::new();
content.push_str(&format!(
"PathEditor Backup - {}\n",
Local::now().format("%Y-%m-%d %H:%M:%S")
));
content.push_str("\n[System PATH]\n");
for path in &sys_paths {
content.push_str(&format!("{}\n", path));
}
content.push_str("\n[User PATH]\n");
for path in &user_paths {
content.push_str(&format!("{}\n", path));
}
std::fs::write(&filepath, &content)
.map_err(|e| format!("无法写入备份文件: {}", e))?;
let result = filepath.to_string_lossy().to_string();
log::info!("备份已保存到: {}", result);
Ok(result)
}
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn disabled_file_path() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join("disabled.json")
}
#[derive(Serialize, Deserialize, Default)]
struct DisabledState {
#[serde(default)]
system: Vec<String>,
#[serde(default)]
user: Vec<String>,
}
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
let state = DisabledState { system, user };
let path = disabled_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建配置目录: {}", e))?;
}
let json = serde_json::to_string_pretty(&state)
.map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json)
.map_err(|e| format!("无法写入 disabled.json: {}", e))?;
log::info!("已保存禁用状态到: {}", path.display());
Ok(())
}
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
let path = disabled_file_path();
if !path.exists() {
return Ok((vec![], vec![]));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 disabled.json: {}", e))?;
if content.trim().is_empty() {
return Ok((vec![], vec![]));
}
let state: DisabledState = serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))?;
Ok((state.system, state.user))
}
+5
View File
@@ -0,0 +1,5 @@
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
pub fn read_text_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
+10
View File
@@ -0,0 +1,10 @@
pub mod backup;
pub mod disabled;
pub mod fs;
pub mod profiles;
pub mod registry;
pub mod scanner;
pub mod system;
pub use profiles::{ProfileData, ProfileMeta};
pub use scanner::{ConflictEntry, ConflictLocation, ToolGroup};
+146
View File
@@ -0,0 +1,146 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.join("profiles")
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{}.json", name))
}
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
#[derive(Serialize, Deserialize, Clone)]
pub struct ProfilePathEntry {
pub path: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileMeta {
pub name: String,
pub created: String,
pub modified: String,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileData {
pub name: String,
pub sys: Vec<ProfilePathEntry>,
pub user: Vec<ProfilePathEntry>,
pub created: String,
pub modified: String,
}
/// 列出所有配置文件的元数据
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
let dir = profiles_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut profiles: Vec<ProfileMeta> = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "json") {
continue;
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(ProfileMeta {
name: data.name,
created: data.created,
modified: data.modified,
});
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
/// 保存当前 PATH 为配置文件
pub fn save_profile(
name: String,
sys: Vec<ProfilePathEntry>,
user: Vec<ProfilePathEntry>,
) -> Result<(), String> {
let dir = profiles_dir();
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
let path = profile_path(&name);
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let data = ProfileData {
name,
sys,
user,
created: now.clone(),
modified: now,
};
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
}
/// 加载配置文件
pub fn load_profile(name: String) -> Result<ProfileData, String> {
let path = profile_path(&name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
pub fn delete_profile(name: String) -> Result<(), String> {
let path = profile_path(&name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
}
/// 重命名配置文件
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> {
let old_path = profile_path(&old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.clone();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let new_path = profile_path(&new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
}
log::info!("已重命名配置: {} -> {}", old_name, new_name);
Ok(())
}
+127
View File
@@ -0,0 +1,127 @@
use winreg::enums::*;
use winreg::RegKey;
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
pub(crate) const USER_REG_PATH: &str = "Environment";
const PATH_VALUE: &str = "Path";
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
let key = RegKey::predef(root);
let env_key = key
.open_subkey_with_flags(sub_path, KEY_READ)
.map_err(|e| format!("无法打开{}注册表项: {}", label, e))?;
let value: String = env_key
.get_value(PATH_VALUE)
.map_err(|e| format!("无法读取{} PATH: {}", label, e))?;
Ok(split_path(&value))
}
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
let value = join_path(paths);
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
const MAX_PATH_LEN: usize = 32767;
if value.len() > MAX_PATH_LEN {
return Err(format!(
"{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存",
label, value.len(), MAX_PATH_LEN
));
}
let key = RegKey::predef(root);
let env_key = key
.open_subkey_with_flags(sub_path, KEY_WRITE)
.map_err(|e| format!("无法写入{}注册表(需要管理员权限): {}", label, e))?;
env_key
.set_value(PATH_VALUE, &value)
.map_err(|e| format!("无法写入{} PATH: {}", label, e))?;
log::info!("已保存{} PATH{} 个条目", label, paths.len());
Ok(())
}
pub fn load_system_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
}
pub fn load_user_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
}
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
}
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
}
/// 将分号分隔的 PATH 字符串拆分为数组。
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_path,修改时需同步两端。
fn split_path(raw: &str) -> Vec<String> {
raw.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn join_path(paths: &[String]) -> String {
paths
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_empty() {
assert_eq!(split_path(""), Vec::<String>::new());
}
#[test]
fn split_single() {
assert_eq!(split_path("C:\\Windows"), vec!["C:\\Windows"]);
}
#[test]
fn split_multiple() {
assert_eq!(
split_path("C:\\Windows;D:\\Projects"),
vec!["C:\\Windows", "D:\\Projects"]
);
}
#[test]
fn split_trims_and_filters_empty() {
assert_eq!(
split_path(" C:\\ ; ; D:\\ "),
vec!["C:\\", "D:\\"]
);
}
#[test]
fn join_and_split_roundtrip() {
let paths = vec!["C:\\Windows".to_string(), "D:\\Projects".to_string()];
let joined = join_path(&paths);
let split = split_path(&joined);
assert_eq!(split, paths);
}
#[test]
fn join_trims_entries() {
let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()];
assert_eq!(join_path(&paths), "C:\\Windows;D:\\");
}
}
+114
View File
@@ -0,0 +1,114 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
#[derive(serde::Serialize, Clone)]
pub struct ConflictLocation {
pub dir: String,
pub priority: usize,
}
#[derive(serde::Serialize, Clone)]
pub struct ConflictEntry {
pub name: String,
pub locations: Vec<ConflictLocation>,
}
#[derive(serde::Serialize)]
pub struct ToolGroup {
pub dir: String,
pub exists: bool,
pub exes: Vec<String>,
}
/// 扫描 PATH 中的可执行文件冲突
///
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
// exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
for (priority, dir) in paths.iter().enumerate() {
let p = Path::new(dir);
if !p.is_dir() {
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
let key = name.to_lowercase();
map.entry(key).or_default().push((priority, dir.clone()));
}
}
}
}
let mut results: Vec<ConflictEntry> = map
.into_iter()
.filter(|(_, locs)| locs.len() >= 2)
.map(|(name, locs)| ConflictEntry {
name,
locations: locs
.into_iter()
.map(|(priority, dir)| ConflictLocation { dir, priority })
.collect(),
})
.collect();
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
let query_lower = query.to_lowercase();
let mut groups: Vec<ToolGroup> = Vec::new();
for dir in &paths {
let p = Path::new(dir);
if !p.is_dir() {
groups.push(ToolGroup {
dir: dir.clone(),
exists: false,
exes: vec![],
});
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
let mut exes: Vec<String> = Vec::new();
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) {
exes.push(name.to_string());
}
}
}
}
exes.sort();
groups.push(ToolGroup {
dir: dir.clone(),
exists: true,
exes,
});
}
Ok(groups)
}
+148
View File
@@ -0,0 +1,148 @@
use winreg::enums::*;
use winreg::RegKey;
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
pub fn check_admin() -> bool {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey_with_flags(
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
KEY_WRITE,
)
.is_ok()
}
/// 验证路径是否存在于文件系统中(且是目录)
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
pub fn validate_path(path: &str) -> bool {
if path.contains('%') {
return true;
}
std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false)
}
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin
pub fn expand_env_vars(path: &str) -> String {
if !path.contains('%') {
return path.to_string();
}
// 转为 UTF-16 宽字符串(以 null 结尾)
let wide_path: Vec<u16> = path
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: wide_path 是以 null 结尾的 UTF-16 字符串,lpDst 为 null 且 nSize 为 0
// 根据 MSDN 文档此时 API 只查询所需缓冲区大小而不写入数据
let required = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
};
if required == 0 {
log::warn!("expand_env_vars: API 查询缓冲区失败, 返回原始路径: {path}");
return path.to_string();
}
// SAFETY: buffer 容量为 requiredAPI 返回的精确大小),wide_path 以 null 结尾,
// 且两个指针指向不同的内存区域,不存在重叠
let mut buffer: Vec<u16> = vec![0; required as usize];
let result = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
if result == 0 || result > required {
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
return path.to_string();
}
// 转回 UTF-8 (去掉结尾 null)
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
String::from_utf16_lossy(&buffer[..len])
}
/// 广播环境变量更改通知(WM_SETTINGCHANGE
pub fn broadcast_env_change() {
const HWND_BROADCAST: isize = 0xFFFF;
const WM_SETTINGCHANGE: u32 = 0x001A;
const SMTO_ABORTIFHUNG: u32 = 0x0002;
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: env_str.as_ptr() 指向以 null 结尾的字符串,HWND_BROADCAST 是合法句柄,
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
let result = unsafe {
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0,
env_str.as_ptr() as isize,
SMTO_ABORTIFHUNG,
5000,
std::ptr::null_mut(),
)
};
if result == 0 {
log::warn!("广播 WM_SETTINGCHANGE 失败");
} else {
log::info!("已广播环境变量更改通知");
}
}
// ── 外部 FFI 声明 ──
extern "system" {
/// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-expandenvironmentstringsw
fn ExpandEnvironmentStringsW(
lpSrc: *const u16,
lpDst: *mut u16,
nSize: u32,
) -> u32;
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw
fn SendMessageTimeoutW(
hWnd: isize,
Msg: u32,
wParam: usize,
lParam: isize,
fuFlags: u32,
uTimeout: u32,
lpdwResult: *mut usize,
) -> isize;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_path_env_var_always_valid() {
assert!(validate_path("%JAVA_HOME%\\bin"));
}
#[test]
fn expand_env_vars_no_percent_returns_original() {
let result = expand_env_vars("C:\\Windows");
assert_eq!(result, "C:\\Windows");
}
#[test]
fn expand_env_vars_with_invalid_var_returns_original() {
// 展开不存在的变量可能会回归原始值或产生部分展开;测试是否不会崩溃
let result = expand_env_vars("%__NONEXISTENT_VAR__%");
// 至少不应为空白
assert!(!result.is_empty());
}
#[test]
fn check_admin_returns_bool() {
let result = check_admin();
// 在任意机器上应返回 true 或 false,不应 panic
assert!((result == true) || (result == false));
}
}
+1 -1
View File
@@ -2271,7 +2271,7 @@ dependencies = [
[[package]] [[package]]
name = "patheditor" name = "patheditor"
version = "4.0.0" version = "5.0.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
+1 -5
View File
@@ -16,14 +16,10 @@ crate-type = ["staticlib", "rlib"]
tauri-build = { version = "2.6.2", features = [] } tauri-build = { version = "2.6.2", features = [] }
[dependencies] [dependencies]
path-editor-core = { path = "../core" }
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
tauri = { version = "2.11.2", features = [] } tauri = { version = "2.11.2", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
# Windows API
winreg = "0.52"
dirs = "5"
chrono = "0.4"
+3 -65
View File
@@ -1,68 +1,6 @@
use chrono::Local; use path_editor_core::backup;
use std::path::PathBuf;
use winreg::enums::*;
use crate::commands::registry::{self, SYS_REG_PATH, USER_REG_PATH};
fn backup_base_dir() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join("backups")
}
/// 获取 APPDATA 路径下的备份目录
#[tauri::command] #[tauri::command]
pub fn get_appdata_dir() -> String { pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> { backup::backup_registry(custom_dir) }
backup_base_dir().to_string_lossy().to_string()
}
/// 备份当前注册表中的系统 PATH 和用户 PATH
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
#[tauri::command] #[tauri::command]
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> { pub fn get_appdata_dir() -> String { backup::get_appdata_dir() }
let backup_dir = match custom_dir {
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
_ => backup_base_dir(),
};
std::fs::create_dir_all(&backup_dir)
.map_err(|e| format!("无法创建备份目录: {}", e))?;
// 读取当前注册表中的值(保存前的旧值)
let sys_paths = registry::load_paths(
HKEY_LOCAL_MACHINE,
SYS_REG_PATH,
"系统",
)?;
let user_paths = registry::load_paths(
HKEY_CURRENT_USER,
USER_REG_PATH,
"用户",
)?;
let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("path_backup_{}.txt", timestamp);
let filepath = backup_dir.join(&filename);
let mut content = String::new();
content.push_str(&format!(
"PathEditor Backup - {}\n",
Local::now().format("%Y-%m-%d %H:%M:%S")
));
content.push_str("\n[System PATH]\n");
for path in &sys_paths {
content.push_str(&format!("{}\n", path));
}
content.push_str("\n[User PATH]\n");
for path in &user_paths {
content.push_str(&format!("{}\n", path));
}
std::fs::write(&filepath, &content)
.map_err(|e| format!("无法写入备份文件: {}", e))?;
let result = filepath.to_string_lossy().to_string();
log::info!("备份已保存到: {}", result);
Ok(result)
}
+3 -59
View File
@@ -1,62 +1,6 @@
use serde::{Deserialize, Serialize}; use path_editor_core::disabled;
use std::fs;
use std::path::PathBuf;
fn disabled_file_path() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join("disabled.json")
}
#[derive(Serialize, Deserialize, Default)]
struct DisabledState {
#[serde(default)]
system: Vec<String>,
#[serde(default)]
user: Vec<String>,
}
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
#[tauri::command] #[tauri::command]
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> { pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> { disabled::save_disabled_state(system, user) }
let state = DisabledState { system, user };
let path = disabled_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建配置目录: {}", e))?;
}
let json = serde_json::to_string_pretty(&state)
.map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json)
.map_err(|e| format!("无法写入 disabled.json: {}", e))?;
log::info!("已保存禁用状态到: {}", path.display());
Ok(())
}
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
#[tauri::command] #[tauri::command]
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> { pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> { disabled::load_disabled_state() }
let path = disabled_file_path();
if !path.exists() {
return Ok((vec![], vec![]));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 disabled.json: {}", e))?;
if content.trim().is_empty() {
return Ok((vec![], vec![]));
}
let state: DisabledState = serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))?;
Ok((state.system, state.user))
}
+3 -4
View File
@@ -1,5 +1,4 @@
/// 读取文本文件内容(供前端原生对话框选择文件后使用) use path_editor_core::fs;
#[tauri::command] #[tauri::command]
pub fn read_text_file(path: &str) -> Result<String, String> { pub fn read_text_file(path: &str) -> Result<String, String> { fs::read_text_file(path) }
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
+6 -140
View File
@@ -1,146 +1,12 @@
use serde::{Deserialize, Serialize}; use path_editor_core::profiles;
use std::fs;
use std::path::PathBuf;
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.join("profiles")
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{}.json", name))
}
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
#[derive(Serialize, Deserialize, Clone)]
pub struct ProfilePathEntry {
pub path: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileMeta {
pub name: String,
pub created: String,
pub modified: String,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileData {
pub name: String,
pub sys: Vec<ProfilePathEntry>,
pub user: Vec<ProfilePathEntry>,
pub created: String,
pub modified: String,
}
/// 列出所有配置文件的元数据
#[tauri::command] #[tauri::command]
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> { pub fn list_profiles() -> Result<Vec<profiles::ProfileMeta>, String> { profiles::list_profiles() }
let dir = profiles_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut profiles: Vec<ProfileMeta> = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "json") {
continue;
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(ProfileMeta {
name: data.name,
created: data.created,
modified: data.modified,
});
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
/// 保存当前 PATH 为配置文件
#[tauri::command] #[tauri::command]
pub fn save_profile( pub fn save_profile(name: String, sys: Vec<profiles::ProfilePathEntry>, user: Vec<profiles::ProfilePathEntry>) -> Result<(), String> { profiles::save_profile(name, sys, user) }
name: String,
sys: Vec<ProfilePathEntry>,
user: Vec<ProfilePathEntry>,
) -> Result<(), String> {
let dir = profiles_dir();
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
let path = profile_path(&name);
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let data = ProfileData {
name,
sys,
user,
created: now.clone(),
modified: now,
};
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
}
/// 加载配置文件
#[tauri::command] #[tauri::command]
pub fn load_profile(name: String) -> Result<ProfileData, String> { pub fn load_profile(name: String) -> Result<profiles::ProfileData, String> { profiles::load_profile(name) }
let path = profile_path(&name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
#[tauri::command] #[tauri::command]
pub fn delete_profile(name: String) -> Result<(), String> { pub fn delete_profile(name: String) -> Result<(), String> { profiles::delete_profile(name) }
let path = profile_path(&name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
}
/// 重命名配置文件
#[tauri::command] #[tauri::command]
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { profiles::rename_profile(old_name, new_name) }
let old_path = profile_path(&old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.clone();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let new_path = profile_path(&new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
}
log::info!("已重命名配置: {} -> {}", old_name, new_name);
Ok(())
}
+5 -122
View File
@@ -1,127 +1,10 @@
use winreg::enums::*; use path_editor_core::registry;
use winreg::RegKey;
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
pub(crate) const USER_REG_PATH: &str = "Environment";
pub(crate) const PATH_VALUE: &str = "Path";
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
let key = RegKey::predef(root);
let env_key = key
.open_subkey_with_flags(sub_path, KEY_READ)
.map_err(|e| format!("无法打开{}注册表项: {}", label, e))?;
let value: String = env_key
.get_value(PATH_VALUE)
.map_err(|e| format!("无法读取{} PATH: {}", label, e))?;
Ok(split_path(&value))
}
pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
let value = join_path(paths);
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
const MAX_PATH_LEN: usize = 32767;
if value.len() > MAX_PATH_LEN {
return Err(format!(
"{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存",
label, value.len(), MAX_PATH_LEN
));
}
let key = RegKey::predef(root);
let env_key = key
.open_subkey_with_flags(sub_path, KEY_WRITE)
.map_err(|e| format!("无法写入{}注册表(需要管理员权限): {}", label, e))?;
env_key
.set_value(PATH_VALUE, &value)
.map_err(|e| format!("无法写入{} PATH: {}", label, e))?;
log::info!("已保存{} PATH{} 个条目", label, paths.len());
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn load_system_paths() -> Result<Vec<String>, String> { pub fn load_system_paths() -> Result<Vec<String>, String> { registry::load_system_paths() }
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
}
#[tauri::command] #[tauri::command]
pub fn load_user_paths() -> Result<Vec<String>, String> { pub fn load_user_paths() -> Result<Vec<String>, String> { registry::load_user_paths() }
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
}
#[tauri::command] #[tauri::command]
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> { pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> { registry::save_system_paths(paths) }
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
}
#[tauri::command] #[tauri::command]
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> { pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> { registry::save_user_paths(paths) }
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
}
/// 将分号分隔的 PATH 字符串拆分为数组。
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_path,修改时需同步两端。
pub(crate) fn split_path(raw: &str) -> Vec<String> {
raw.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn join_path(paths: &[String]) -> String {
paths
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_empty() {
assert_eq!(split_path(""), Vec::<String>::new());
}
#[test]
fn split_single() {
assert_eq!(split_path("C:\\Windows"), vec!["C:\\Windows"]);
}
#[test]
fn split_multiple() {
assert_eq!(
split_path("C:\\Windows;D:\\Projects"),
vec!["C:\\Windows", "D:\\Projects"]
);
}
#[test]
fn split_trims_and_filters_empty() {
assert_eq!(
split_path(" C:\\ ; ; D:\\ "),
vec!["C:\\", "D:\\"]
);
}
#[test]
fn join_and_split_roundtrip() {
let paths = vec!["C:\\Windows".to_string(), "D:\\Projects".to_string()];
let joined = join_path(&paths);
let split = split_path(&joined);
assert_eq!(split, paths);
}
#[test]
fn join_trims_entries() {
let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()];
assert_eq!(join_path(&paths), "C:\\Windows;D:\\");
}
}
+3 -111
View File
@@ -1,114 +1,6 @@
use std::collections::HashMap; use path_editor_core::scanner;
use std::fs;
use std::path::Path;
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
#[derive(serde::Serialize, Clone)]
pub struct ConflictLocation {
pub dir: String,
pub priority: usize,
}
#[derive(serde::Serialize, Clone)]
pub struct ConflictEntry {
pub name: String,
pub locations: Vec<ConflictLocation>,
}
#[derive(serde::Serialize)]
pub struct ToolGroup {
pub dir: String,
pub exists: bool,
pub exes: Vec<String>,
}
/// 扫描 PATH 中的可执行文件冲突
///
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
#[tauri::command] #[tauri::command]
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> { pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<scanner::ConflictEntry>, String> { scanner::scan_conflicts(paths) }
// exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
for (priority, dir) in paths.iter().enumerate() {
let p = Path::new(dir);
if !p.is_dir() {
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
let key = name.to_lowercase();
map.entry(key).or_default().push((priority, dir.clone()));
}
}
}
}
let mut results: Vec<ConflictEntry> = map
.into_iter()
.filter(|(_, locs)| locs.len() >= 2)
.map(|(name, locs)| ConflictEntry {
name,
locations: locs
.into_iter()
.map(|(priority, dir)| ConflictLocation { dir, priority })
.collect(),
})
.collect();
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果
#[tauri::command] #[tauri::command]
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> { pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<scanner::ToolGroup>, String> { scanner::scan_tools(paths, query) }
let query_lower = query.to_lowercase();
let mut groups: Vec<ToolGroup> = Vec::new();
for dir in &paths {
let p = Path::new(dir);
if !p.is_dir() {
groups.push(ToolGroup {
dir: dir.clone(),
exists: false,
exes: vec![],
});
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
let mut exes: Vec<String> = Vec::new();
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) {
exes.push(name.to_string());
}
}
}
}
exes.sort();
groups.push(ToolGroup {
dir: dir.clone(),
exists: true,
exes,
});
}
Ok(groups)
}
+5 -143
View File
@@ -1,148 +1,10 @@
use winreg::enums::*; use path_editor_core::system;
use winreg::RegKey;
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
#[tauri::command] #[tauri::command]
pub fn check_admin() -> bool { pub fn check_admin() -> bool { system::check_admin() }
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey_with_flags(
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
KEY_WRITE,
)
.is_ok()
}
/// 验证路径是否存在于文件系统中(且是目录)
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
#[tauri::command] #[tauri::command]
pub fn validate_path(path: &str) -> bool { pub fn validate_path(path: &str) -> bool { system::validate_path(path) }
if path.contains('%') {
return true;
}
std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false)
}
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin
#[tauri::command] #[tauri::command]
pub fn expand_env_vars(path: &str) -> String { pub fn expand_env_vars(path: &str) -> String { system::expand_env_vars(path) }
if !path.contains('%') {
return path.to_string();
}
// 转为 UTF-16 宽字符串(以 null 结尾)
let wide_path: Vec<u16> = path
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: wide_path 是以 null 结尾的 UTF-16 字符串,lpDst 为 null 且 nSize 为 0
// 根据 MSDN 文档此时 API 只查询所需缓冲区大小而不写入数据
let required = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
};
if required == 0 {
log::warn!("expand_env_vars: API 查询缓冲区失败, 返回原始路径: {path}");
return path.to_string();
}
// SAFETY: buffer 容量为 requiredAPI 返回的精确大小),wide_path 以 null 结尾,
// 且两个指针指向不同的内存区域,不存在重叠
let mut buffer: Vec<u16> = vec![0; required as usize];
let result = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
if result == 0 || result > required {
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
return path.to_string();
}
// 转回 UTF-8 (去掉结尾 null)
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
String::from_utf16_lossy(&buffer[..len])
}
/// 广播环境变量更改通知(WM_SETTINGCHANGE
#[tauri::command] #[tauri::command]
pub fn broadcast_env_change() { pub fn broadcast_env_change() { system::broadcast_env_change() }
const HWND_BROADCAST: isize = 0xFFFF;
const WM_SETTINGCHANGE: u32 = 0x001A;
const SMTO_ABORTIFHUNG: u32 = 0x0002;
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: env_str.as_ptr() 指向以 null 结尾的字符串,HWND_BROADCAST 是合法句柄,
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
let result = unsafe {
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0,
env_str.as_ptr() as isize,
SMTO_ABORTIFHUNG,
5000,
std::ptr::null_mut(),
)
};
if result == 0 {
log::warn!("广播 WM_SETTINGCHANGE 失败");
} else {
log::info!("已广播环境变量更改通知");
}
}
// ── 外部 FFI 声明 ──
extern "system" {
/// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-expandenvironmentstringsw
fn ExpandEnvironmentStringsW(
lpSrc: *const u16,
lpDst: *mut u16,
nSize: u32,
) -> u32;
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw
fn SendMessageTimeoutW(
hWnd: isize,
Msg: u32,
wParam: usize,
lParam: isize,
fuFlags: u32,
uTimeout: u32,
lpdwResult: *mut usize,
) -> isize;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_path_env_var_always_valid() {
assert!(validate_path("%JAVA_HOME%\\bin"));
}
#[test]
fn expand_env_vars_no_percent_returns_original() {
let result = expand_env_vars("C:\\Windows");
assert_eq!(result, "C:\\Windows");
}
#[test]
fn expand_env_vars_with_invalid_var_returns_original() {
// 展开不存在的变量可能会回归原始值或产生部分展开;测试是否不会崩溃
let result = expand_env_vars("%__NONEXISTENT_VAR__%");
// 至少不应为空白
assert!(!result.is_empty());
}
#[test]
fn check_admin_returns_bool() {
let result = check_admin();
// 在任意机器上应返回 true 或 false,不应 panic
assert!((result == true) || (result == false));
}
}