From cbf99f12fd264b36228930f4bbbfeb4753a7fab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Fri, 29 May 2026 23:17:27 +0800 Subject: [PATCH] =?UTF-8?q?v5.1:=20=E5=85=A8=E9=9D=A2=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=8A=A0=E5=9B=BA=20+=20=E5=8A=9F=E8=83=BD=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E6=B5=8B=E8=AF=95=E8=A1=A5=E5=85=A8=20+=20?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复 (CRITICAL): - 启用 CSP (default-src 'self') - read_text_file 限制文件扩展名白名单 (.json/.csv/.txt) - capabilities 显式声明窗口权限 - profile 名校验增强 (null 字节/控制字符/长度限制) 功能修复 (HIGH): - AnalyzeDialog 重新打开时正确刷新数据 - UndoRedoButtons 订阅路径长度变化确保响应性 - 禁用状态持久化错误处理 (.catch → console.warn) - 硬编码中文全部迁移到 i18n (6 处) - PATH 长度检查改用 UTF-16 字符计数 - PATH 写入前 null 字节校验 - CLI export 拒绝写入系统目录 - savePaths 职责分离: window.confirm → Tauri ask() 对话框 代码质量 (MEDIUM): - 导入路径统一过滤 (sanitize_paths: null 字节/分号/空白) - 原子写入 (atomic_write: disabled.json + profiles) - 验证缓存自动清理 (PathTable useEffect) - Scanner 线程错误处理改进 (.unwrap → .map_err) - Ctrl+F 去重 (移除 use-keyboard 重复处理) - Profile 路径列表 key 修复 (index → path) - 生产构建启用日志插件 (Warn 级别) - export_paths JSON 序列化改 expect 测试: - Rust: 35 → 48 测试 (+13) - Frontend: 80 → 85 测试 (+5) - Vitest 全局 jsdom + 覆盖率阈值 (80%) - 安装 @vitest/coverage-v8 + test:coverage 脚本 - 移除未使用的 @testing-library/jest-dom 工程化: - CI 添加 Cargo 缓存 (Swatinem/rust-cache@v2) - CI 添加 cargo fmt --check - tsconfig.test.json 覆盖测试文件类型检查 - cargo fmt 全量格式化 Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 5 + cli/src/main.rs | 322 ++++++++++++++++----- core/src/backup.rs | 20 +- core/src/disabled.rs | 19 +- core/src/fs.rs | 88 +++++- core/src/profiles.rs | 62 +++- core/src/registry.rs | 80 +++-- core/src/scanner.rs | 74 +++-- core/src/system.rs | 21 +- gui/build.rs | 2 +- gui/capabilities/default.json | 14 +- gui/src/commands/backup.rs | 8 +- gui/src/commands/disabled.rs | 8 +- gui/src/commands/fs.rs | 4 +- gui/src/commands/profiles.rs | 24 +- gui/src/commands/registry.rs | 16 +- gui/src/commands/scanner.rs | 8 +- gui/src/commands/system.rs | 16 +- gui/src/lib.rs | 14 +- gui/src/main.rs | 2 +- package-lock.json | 246 ++++++++++------ package.json | 3 +- src/components/dialogs/AnalyzeDialog.tsx | 7 +- src/components/dialogs/ImportDialog.tsx | 4 +- src/components/dialogs/ProfileDialog.tsx | 11 +- src/components/path-list/PathTable.tsx | 31 +- src/components/toolbar/UndoRedoButtons.tsx | 3 + src/hooks/use-app-actions.ts | 12 +- src/hooks/use-keyboard.ts | 5 - src/i18n/locales/en.json | 17 +- src/i18n/locales/zh-CN.json | 17 +- src/store/app-store.ts | 20 +- tests/unit/analyze-dialog.test.tsx | 1 - tests/unit/app-store.test.ts | 4 +- tests/unit/merge-preview.test.tsx | 1 - tests/unit/path-manager.test.ts | 27 +- tests/unit/undo-redo.test.ts | 25 ++ tsconfig.json | 3 +- tsconfig.test.json | 8 + vitest.config.ts | 9 + 40 files changed, 937 insertions(+), 324 deletions(-) create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302a499..a9be3e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - name: Cargo Check run: cargo check @@ -47,3 +49,6 @@ jobs: - name: Cargo Test run: cargo test + + - name: Cargo Format Check + run: cargo fmt --check diff --git a/cli/src/main.rs b/cli/src/main.rs index 8964db1..51ddd54 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -13,79 +13,109 @@ struct Cli { enum Command { /// 列出 PATH 路径 List { - #[arg(short, long)] system: bool, - #[arg(short, long)] user: bool, - #[arg(long)] json: bool, + #[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, + #[arg(short, long)] + system: bool, + #[arg(short, long)] + user: bool, }, /// 删除指定位置的路径 Remove { index: usize, - #[arg(short, long)] system: bool, + #[arg(short, long)] + system: bool, }, /// 编辑指定位置的路径 Edit { index: usize, new_path: String, - #[arg(short, long)] system: bool, + #[arg(short, long)] + system: bool, }, /// 上移路径(--steps 指定移动格数,默认 1) MoveUp { index: usize, - #[arg(long, default_value = "1")] steps: usize, - #[arg(short, long)] system: bool, + #[arg(long, default_value = "1")] + steps: usize, + #[arg(short, long)] + system: bool, }, /// 下移路径(--steps 指定移动格数,默认 1) MoveDown { index: usize, - #[arg(long, default_value = "1")] steps: usize, - #[arg(short, long)] system: bool, + #[arg(long, default_value = "1")] + steps: usize, + #[arg(short, long)] + system: bool, }, /// 清理无效和重复路径 Clean { - #[arg(short, long)] system: bool, - #[arg(short, long)] user: bool, - #[arg(long)] dry_run: bool, - #[arg(long)] json: bool, + #[arg(short, long)] + system: bool, + #[arg(short, long)] + user: bool, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, }, /// 启用指定位置的路径 Enable { index: usize, - #[arg(short, long)] system: bool, - #[arg(short, long)] user: bool, + #[arg(short, long)] + system: bool, + #[arg(short, long)] + user: bool, }, /// 禁用指定位置的路径 Disable { index: usize, - #[arg(short, long)] system: bool, - #[arg(short, long)] user: bool, + #[arg(short, long)] + system: bool, + #[arg(short, long)] + user: bool, }, /// 从文件导入 PATH(JSON/CSV/TXT) Import { file: String, - #[arg(long, default_value = "both")] target: String, + #[arg(long, default_value = "both")] + target: String, }, /// 导出 PATH 为文件 Export { - #[arg(long, default_value = "json")] format: String, - #[arg(short, long)] output: Option, + #[arg(long, default_value = "json")] + format: String, + #[arg(short, long)] + output: Option, }, /// 创建注册表备份 Backup, /// 检测可执行文件冲突 - Conflicts { #[arg(long)] json: bool }, + Conflicts { + #[arg(long)] + json: bool, + }, /// 列出 PATH 目录中的可执行文件 Scan { - #[arg(long)] query: Option, - #[arg(long)] json: bool, + #[arg(long)] + query: Option, + #[arg(long)] + json: bool, }, /// 检查管理员权限 - CheckAdmin { #[arg(long)] json: bool }, + CheckAdmin { + #[arg(long)] + json: bool, + }, /// 管理配置文件 #[command(subcommand)] Profile(ProfileCmd), @@ -94,7 +124,10 @@ enum Command { #[derive(Subcommand)] enum ProfileCmd { /// 列出所有配置 - List { #[arg(long)] json: bool }, + List { + #[arg(long)] + json: bool, + }, /// 保存当前 PATH 为配置 Save { name: String }, /// 加载配置(预览) @@ -105,8 +138,10 @@ enum ProfileCmd { Delete { name: String }, /// 重命名配置 Rename { - #[arg(long)] old: String, - #[arg(long)] new: String, + #[arg(long)] + old: String, + #[arg(long)] + new: String, }, } @@ -116,8 +151,14 @@ fn exit_err(msg: &str) -> ! { } fn ensure_single_target(system: bool, user: bool) -> &'static str { - if system && user { exit_err("不能同时指定 --system 和 --user"); } - if system { "system" } else { "user" } + if system && user { + exit_err("不能同时指定 --system 和 --user"); + } + if system { + "system" + } else { + "user" + } } type SaveFn = fn(Vec) -> Result<(), String>; @@ -131,7 +172,11 @@ fn verify_and_save(target: &str, original: &[String], new_list: Vec) { if reload != original { exit_err("注册表已被其他进程修改,请重新执行操作"); } - let save: SaveFn = if target == "system" { core::registry::save_system_paths } else { core::registry::save_user_paths }; + let save: SaveFn = if target == "system" { + core::registry::save_system_paths + } else { + core::registry::save_user_paths + }; save(new_list).unwrap_or_else(|e| exit_err(&e)); } @@ -163,11 +208,15 @@ fn cmd_list(system: bool, user: bool, json_out: bool) { } else { if !sys.is_empty() { println!("═══ 系统 PATH ({}) ═══", sys.len()); - for (i, p) in sys.iter().enumerate() { println!(" [{}] {}", i, p); } + 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); } + for (i, p) in usr.iter().enumerate() { + println!(" [{}] {}", i, p); + } } } } @@ -178,7 +227,11 @@ fn cmd_add(path: String, system: bool, user: bool) { list.push(path.clone()); list }); - let label = if target == "system" { "系统" } else { "用户" }; + let label = if target == "system" { + "系统" + } else { + "用户" + }; println!("已添加到{} PATH: {path}", label); core::system::broadcast_env_change(); } @@ -191,7 +244,9 @@ fn cmd_remove(index: usize, system: bool) { core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)) }; let original = list.clone(); - if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); } + if index >= list.len() { + exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); + } let removed = list.remove(index); verify_and_save(target, &original, list); println!("已删除: {removed}"); @@ -205,7 +260,9 @@ fn cmd_edit(index: usize, new_path: String, system: bool) { } else { core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)) }; - if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); } + if index >= list.len() { + exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); + } let original = list.clone(); let old = std::mem::replace(&mut list[index], new_path.clone()); verify_and_save(target, &original, list); @@ -215,12 +272,18 @@ fn cmd_edit(index: usize, new_path: String, system: bool) { fn cmd_move(index: usize, steps: usize, system: bool, up: bool) { load_and_save(system, |mut list| { - if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); } + if index >= list.len() { + exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); + } let end = if up { index.saturating_sub(steps) } else { let max = list.len() - 1; - if index + steps > max { max } else { index + steps } + if index + steps > max { + max + } else { + index + steps + } }; let removed = list.remove(index); list.insert(end, removed); @@ -232,19 +295,31 @@ fn cmd_move(index: usize, steps: usize, system: bool, up: bool) { } fn cmd_clean(system: bool, user: bool, dry_run: bool, json_out: bool) { - if system && user { exit_err("不能同时指定 --system 和 --user"); } + if system && user { + exit_err("不能同时指定 --system 和 --user"); + } let clean_sys = system || !user; let clean_usr = user || !system; - if clean_sys { clean_one("system", dry_run, json_out); } - if clean_usr { clean_one("user", dry_run, json_out); } + if clean_sys { + clean_one("system", dry_run, json_out); + } + if clean_usr { + clean_one("user", dry_run, json_out); + } - if !dry_run && !json_out { core::system::broadcast_env_change(); } + if !dry_run && !json_out { + core::system::broadcast_env_change(); + } } fn clean_one(target: &str, dry_run: bool, json_out: bool) { - let label = if target == "system" { "系统" } else { "用户" }; + let label = if target == "system" { + "系统" + } else { + "用户" + }; let list = if target == "system" { core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)) } else { @@ -253,18 +328,31 @@ fn clean_one(target: &str, dry_run: bool, json_out: bool) { let (kept, removed) = core::registry::clean_paths(list.clone()); if json_out { - println!("{}", json!({ "target": target, "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() })); + println!( + "{}", + json!({ "target": target, "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() }) + ); } else if dry_run { println!("═══ {label} PATH — 将被移除({} 条)═══", removed.len()); - for r in &removed { println!(" ✗ {}", r); } + for r in &removed { + println!(" ✗ {}", r); + } println!("═══ {label} PATH — 将保留({} 条)═══", kept.len()); - for k in &kept { println!(" ✓ {}", k); } + for k in &kept { + println!(" ✓ {}", k); + } } else { let kept_count = kept.len(); verify_and_save(target, &list, kept); - println!("{label} PATH 清理完成:移除 {} 条,保留 {} 条", removed.len(), kept_count); + println!( + "{label} PATH 清理完成:移除 {} 条,保留 {} 条", + removed.len(), + kept_count + ); if !removed.is_empty() { - for r in &removed { println!(" 已移除: {}", r); } + for r in &removed { + println!(" 已移除: {}", r); + } } } } @@ -276,11 +364,18 @@ fn cmd_toggle(index: usize, system: bool, user: bool, enable: bool) { } else { core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)) }; - if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); } + if index >= list.len() { + exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); + } let path = &list[index]; - let (mut sys_dis, mut usr_dis) = core::disabled::load_disabled_state().unwrap_or_else(|_| (vec![], vec![])); - let target_list: &mut Vec = if target == "system" { &mut sys_dis } else { &mut usr_dis }; + let (mut sys_dis, mut usr_dis) = + core::disabled::load_disabled_state().unwrap_or_else(|_| (vec![], vec![])); + let target_list: &mut Vec = if target == "system" { + &mut sys_dis + } else { + &mut usr_dis + }; if enable { target_list.retain(|p| p != path); @@ -322,6 +417,12 @@ fn cmd_export(format: String, output: Option) { let usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)); let content = core::fs::export_paths(&sys, &usr, &format).unwrap_or_else(|e| exit_err(&e)); if let Some(path) = output { + // 拒绝写入系统关键目录 + let normalized = path.replace('/', "\\").to_lowercase(); + if normalized.starts_with("c:\\windows\\") || normalized.starts_with("c:\\program files\\") + { + exit_err(&format!("不允许导出到系统目录: {path}")); + } std::fs::write(&path, &content).unwrap_or_else(|e| exit_err(&format!("无法写入文件: {e}"))); println!("已导出到: {path}"); } else { @@ -336,8 +437,12 @@ fn cmd_backup() { fn cmd_conflicts(json_out: bool) { let mut paths: Vec = 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); } + 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()); @@ -348,7 +453,15 @@ fn cmd_conflicts(json_out: bool) { for c in &conflicts { println!(" {}", c.name); for loc in &c.locations { - println!(" {} {}", if loc.priority == 0 { "✓ 优先" } else { "✗ 遮蔽" }, loc.dir); + println!( + " {} {}", + if loc.priority == 0 { + "✓ 优先" + } else { + "✗ 遮蔽" + }, + loc.dir + ); } println!(); } @@ -357,16 +470,26 @@ fn cmd_conflicts(json_out: bool) { fn cmd_scan(query: Option, json_out: bool) { let mut paths: Vec = 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 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; } + if !g.exists { + println!(" {} (不存在)", g.dir); + continue; + } println!("═══ {} ═══", g.dir); - for exe in &g.exes { println!(" {}", exe); } + for exe in &g.exes { + println!(" {}", exe); + } } } } @@ -387,15 +510,29 @@ fn profile_list(json_out: bool) { } else if list.is_empty() { println!("暂无配置文件。"); } else { - for p in &list { println!(" {} ({})", p.name, p.modified); } + 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::ProfilePathEntry { path: p, enabled: true }).collect(); - let usr_entries = usr.into_iter().map(|p| core::ProfilePathEntry { path: p, enabled: true }).collect(); + let sys_entries = sys + .into_iter() + .map(|p| core::ProfilePathEntry { + path: p, + enabled: true, + }) + .collect(); + let usr_entries = usr + .into_iter() + .map(|p| core::ProfilePathEntry { + path: p, + enabled: true, + }) + .collect(); core::profiles::save_profile(&name, sys_entries, usr_entries).unwrap_or_else(|e| exit_err(&e)); println!("已保存配置: {name}"); } @@ -403,15 +540,29 @@ fn profile_save(name: String) { fn profile_load(name: String) { let data = core::profiles::load_profile(&name).unwrap_or_else(|e| exit_err(&e)); println!("═══ 系统 PATH ({} 条) ═══", data.sys.len()); - for e in &data.sys { println!(" [{}] {}", if e.enabled { "✓" } else { "✗" }, e.path); } + 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); } + for e in &data.user { + println!(" [{}] {}", if e.enabled { "✓" } else { "✗" }, e.path); + } } fn profile_apply(name: String) { let data = core::profiles::load_profile(&name).unwrap_or_else(|e| exit_err(&e)); - let new_sys: Vec = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect(); - let new_usr: Vec = data.user.into_iter().filter(|e| e.enabled).map(|e| e.path).collect(); + let new_sys: Vec = data + .sys + .into_iter() + .filter(|e| e.enabled) + .map(|e| e.path) + .collect(); + let new_usr: Vec = data + .user + .into_iter() + .filter(|e| e.enabled) + .map(|e| e.path) + .collect(); let orig_sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)); let orig_usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)); @@ -438,12 +589,37 @@ fn main() { 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::Edit { index, new_path, system } => cmd_edit(index, new_path, system), - Command::MoveUp { index, steps, system } => cmd_move(index, steps, system, true), - Command::MoveDown { index, steps, system } => cmd_move(index, steps, system, false), - Command::Clean { system, user, dry_run, json } => cmd_clean(system, user, dry_run, json), - Command::Enable { index, system, user } => cmd_toggle(index, system, user, true), - Command::Disable { index, system, user } => cmd_toggle(index, system, user, false), + Command::Edit { + index, + new_path, + system, + } => cmd_edit(index, new_path, system), + Command::MoveUp { + index, + steps, + system, + } => cmd_move(index, steps, system, true), + Command::MoveDown { + index, + steps, + system, + } => cmd_move(index, steps, system, false), + Command::Clean { + system, + user, + dry_run, + json, + } => cmd_clean(system, user, dry_run, json), + Command::Enable { + index, + system, + user, + } => cmd_toggle(index, system, user, true), + Command::Disable { + index, + system, + user, + } => cmd_toggle(index, system, user, false), Command::Import { file, target } => cmd_import(file, target), Command::Export { format, output } => cmd_export(format, output), Command::Backup => cmd_backup(), diff --git a/core/src/backup.rs b/core/src/backup.rs index 35fdc00..b52cfde 100644 --- a/core/src/backup.rs +++ b/core/src/backup.rs @@ -1,7 +1,7 @@ +use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH}; 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::home_dir() @@ -23,20 +23,11 @@ pub fn backup_registry(custom_dir: Option) -> Result { _ => backup_base_dir(), }; - std::fs::create_dir_all(&backup_dir) - .map_err(|e| format!("无法创建备份目录: {}", e))?; + 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 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); @@ -56,8 +47,7 @@ pub fn backup_registry(custom_dir: Option) -> Result { content.push_str(&format!("{}\n", path)); } - std::fs::write(&filepath, &content) - .map_err(|e| format!("无法写入备份文件: {}", e))?; + std::fs::write(&filepath, &content).map_err(|e| format!("无法写入备份文件: {}", e))?; let result = filepath.to_string_lossy().to_string(); log::info!("备份已保存到: {}", result); diff --git a/core/src/disabled.rs b/core/src/disabled.rs index fa6b1bb..ff66b78 100644 --- a/core/src/disabled.rs +++ b/core/src/disabled.rs @@ -1,3 +1,4 @@ +use crate::fs::atomic_write; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -23,15 +24,13 @@ pub fn save_disabled_state(system: Vec, user: Vec) -> Result<(), let path = disabled_file_path(); if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("无法创建配置目录: {}", e))?; + fs::create_dir_all(parent).map_err(|e| format!("无法创建配置目录: {}", e))?; } - let json = serde_json::to_string_pretty(&state) - .map_err(|e| format!("JSON 序列化失败: {}", 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))?; + atomic_write(&path, &json).map_err(|e| format!("无法写入 disabled.json: {}", e))?; log::info!("已保存禁用状态到: {}", path.display()); Ok(()) @@ -45,15 +44,15 @@ pub fn load_disabled_state() -> Result<(Vec, Vec), String> { return Ok((vec![], vec![])); } - let content = fs::read_to_string(&path) - .map_err(|e| format!("无法读取 disabled.json: {}", e))?; + 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))?; + let state: DisabledState = + serde_json::from_str(&content).map_err(|e| format!("JSON 解析失败: {}", e))?; Ok((state.system, state.user)) } diff --git a/core/src/fs.rs b/core/src/fs.rs index cb0f8a7..d14e24c 100644 --- a/core/src/fs.rs +++ b/core/src/fs.rs @@ -1,8 +1,37 @@ // 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现, // 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。 +/// 过滤导入路径:去除空白、排除 null 字节和分号(PATH 分隔符冲突) +fn sanitize_paths(paths: Vec) -> Vec { + paths + .into_iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty() && !p.contains('\0') && !p.contains(';')) + .collect() +} + +/// 原子写入:先写临时文件,再 rename 覆盖 +pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, content)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + /// 读取文本文件内容(供前端原生对话框选择文件后使用) +/// 仅允许 .json / .csv / .txt 扩展名,防止任意文件读取 pub fn read_text_file(path: &str) -> Result { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + if !matches!(ext.as_str(), "json" | "csv" | "txt") { + return Err(format!( + "不支持的文件类型: .{}(仅允许 .json/.csv/.txt)", + ext + )); + } std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e)) } @@ -32,7 +61,7 @@ fn import_json(content: &str) -> Result<(Vec, Vec), String> { } let data: ImportData = serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?; - Ok((data.system, data.user)) + Ok((sanitize_paths(data.system), sanitize_paths(data.user))) } fn import_csv(content: &str) -> Result<(Vec, Vec), String> { @@ -41,7 +70,9 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> { let mut first = true; for line in content.lines() { let mut trimmed = line.trim(); - if trimmed.is_empty() { continue; } + if trimmed.is_empty() { + continue; + } // 处理 UTF-8 BOM(仅首行) if first { @@ -54,7 +85,9 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> { if fields.len() >= 2 { let c0 = fields[0].trim().to_lowercase(); let c1 = fields[1].trim().to_lowercase(); - if c0 == "type" && c1 == "path" { continue; } + if c0 == "type" && c1 == "path" { + continue; + } } } @@ -63,12 +96,16 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> { match fields[0].trim().to_lowercase().as_str() { "system" | "sys" => sys.push(fields[1].trim().to_string()), "user" | "usr" => usr.push(fields[1].trim().to_string()), - _ => { log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}"); } + _ => { + log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}"); + } } } else { log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}"); } } + let sys = sanitize_paths(sys); + let usr = sanitize_paths(usr); if sys.is_empty() && usr.is_empty() { return Err("CSV 文件中未找到有效路径".into()); } @@ -81,6 +118,7 @@ fn import_txt(content: &str) -> Result<(Vec, Vec), String> { .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty() && !l.starts_with('#')) .collect(); + let paths = sanitize_paths(paths); if paths.is_empty() { return Err("TXT 文件中未找到路径".into()); } @@ -98,7 +136,7 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result { let mut out = String::from("type,path\n"); @@ -225,4 +263,44 @@ mod tests { assert!(sys.is_empty()); assert_eq!(usr, vec!["C:\\x", "D:\\y"]); } + + #[test] + fn read_text_file_rejects_non_whitelisted_ext() { + let result = read_text_file("C:\\Windows\\System32\\evil.dll"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("不支持的文件类型")); + } + + #[test] + fn read_text_file_rejects_no_ext() { + let result = read_text_file("/etc/passwd"); + assert!(result.is_err()); + } + + #[test] + fn import_json_filters_null_byte_paths() { + // sanitize_paths 作为额外防线 + let paths = vec!["C:\\safe".into(), "C:\\bad\0path".into()]; + assert_eq!(sanitize_paths(paths), vec!["C:\\safe"]); + } + + #[test] + fn import_csv_filters_semicolon_paths() { + let csv = "type,path\nsystem,C:\\good\nsystem,C:\\bad;path\n"; + let (sys, _) = import_csv(csv).unwrap(); + assert_eq!(sys, vec!["C:\\good"]); + } + + #[test] + fn import_txt_trims_and_filters() { + let txt = " C:\\trimmed \n\nC:\\bad\0path\n# comment\n"; + let (_, usr) = import_txt(txt).unwrap(); + assert_eq!(usr, vec!["C:\\trimmed"]); + } + + #[test] + fn sanitize_paths_removes_empty_after_trim() { + let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]); + assert_eq!(result, vec!["C:\\ok"]); + } } diff --git a/core/src/profiles.rs b/core/src/profiles.rs index 3e6e1c8..46627d2 100644 --- a/core/src/profiles.rs +++ b/core/src/profiles.rs @@ -1,3 +1,4 @@ +use crate::fs::atomic_write; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -10,7 +11,15 @@ fn profiles_dir() -> PathBuf { } fn validate_profile_name(name: &str) -> Result<(), String> { - if name.is_empty() { return Err("配置名称不能为空".into()); } + if name.is_empty() { + return Err("配置名称不能为空".into()); + } + if name.len() > 255 { + return Err("配置名称过长(最大 255 字符)".into()); + } + if name.contains('\0') || name.chars().any(|c| c.is_control()) { + return Err("配置名称包含非法字符".into()); + } if name.contains('/') || name.contains('\\') || name.contains("..") { return Err("配置名称包含非法字符".into()); } @@ -115,7 +124,7 @@ pub fn save_profile( let json = serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?; - fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?; + atomic_write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?; log::info!("已保存配置: {}", path.display()); Ok(()) @@ -128,10 +137,8 @@ pub fn load_profile(name: &str) -> Result { 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)) + let content = fs::read_to_string(&path).map_err(|e| format!("无法读取配置文件: {}", e))?; + serde_json::from_str(&content).map_err(|e| format!("JSON 解析失败: {}", e)) } /// 删除配置文件 @@ -152,8 +159,10 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> { 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))?; + 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.to_string(); data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(); @@ -161,7 +170,7 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), 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))?; + atomic_write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?; if old_path != new_path { fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?; @@ -176,7 +185,10 @@ mod tests { use super::*; fn test_entry(path: &str) -> ProfilePathEntry { - ProfilePathEntry { path: path.into(), enabled: true } + ProfilePathEntry { + path: path.into(), + enabled: true, + } } #[test] @@ -196,12 +208,40 @@ mod tests { assert!(validate_profile_name("foo load -> delete let name = "__test_profile_crud"; let _ = delete_profile(name); - save_profile(name, vec![test_entry("C:\\sys")], vec![test_entry("D:\\usr")]).unwrap(); + save_profile( + name, + vec![test_entry("C:\\sys")], + vec![test_entry("D:\\usr")], + ) + .unwrap(); let loaded = load_profile(name).unwrap(); assert_eq!(loaded.sys[0].path, "C:\\sys"); delete_profile(name).unwrap(); diff --git a/core/src/registry.rs b/core/src/registry.rs index d517cac..89f99d6 100644 --- a/core/src/registry.rs +++ b/core/src/registry.rs @@ -1,11 +1,16 @@ use winreg::enums::*; use winreg::RegKey; -pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"; +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, String> { +pub(crate) fn load_paths( + root: winreg::HKEY, + sub_path: &str, + label: &str, +) -> Result, String> { let key = RegKey::predef(root); let env_key = key .open_subkey_with_flags(sub_path, KEY_READ) @@ -18,17 +23,13 @@ pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Res 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 - )); - } +fn save_paths( + root: winreg::HKEY, + sub_path: &str, + label: &str, + paths: &[String], +) -> Result<(), String> { + let value = validate_and_join_paths(paths, label)?; let key = RegKey::predef(root); let env_key = key @@ -43,7 +44,6 @@ fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) Ok(()) } - /// 从 HKLM 注册表读取系统 PATH /// /// # Returns @@ -53,7 +53,6 @@ pub fn load_system_paths() -> Result, String> { load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统") } - /// 从 HKCU 注册表读取用户 PATH /// /// # Returns @@ -63,7 +62,6 @@ pub fn load_user_paths() -> Result, String> { load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户") } - /// 保存系统 PATH 到注册表,含 32767 字符上限检查 /// /// # Returns @@ -73,7 +71,6 @@ pub fn save_system_paths(paths: Vec) -> Result<(), String> { save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths) } - /// 保存用户 PATH 到注册表 /// /// # Returns @@ -101,6 +98,25 @@ fn join_path(paths: &[String]) -> String { .join(";") } +/// 验证路径列表并拼接为分号分隔字符串 +/// - 检查 null 字节 +/// - 检查 UTF-16 总长度不超过 32767 +fn validate_and_join_paths(paths: &[String], label: &str) -> Result { + if let Some(bad) = paths.iter().find(|p| p.contains('\0')) { + return Err(format!("{} PATH 包含非法字符(null 字节): {}", label, bad)); + } + let value = join_path(paths); + const MAX_PATH_LEN: usize = 32767; + let utf16_len = value.encode_utf16().count(); + if utf16_len > MAX_PATH_LEN { + return Err(format!( + "{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存", + label, utf16_len, MAX_PATH_LEN + )); + } + Ok(value) +} + /// 清理路径列表:移除不存在的目录 + 重复路径(保留首次出现) /// 返回 (保留的路径, 被移除的路径) pub fn clean_paths(paths: Vec) -> (Vec, Vec) { @@ -148,10 +164,7 @@ mod tests { #[test] fn split_trims_and_filters_empty() { - assert_eq!( - split_path(" C:\\ ; ; D:\\ "), - vec!["C:\\", "D:\\"] - ); + assert_eq!(split_path(" C:\\ ; ; D:\\ "), vec!["C:\\", "D:\\"]); } #[test] @@ -167,4 +180,29 @@ mod tests { let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()]; assert_eq!(join_path(&paths), "C:\\Windows;D:\\"); } + + #[test] + fn validate_rejects_null_bytes() { + let paths = vec!["C:\\safe".into(), "C:\0invalid".into()]; + let result = validate_and_join_paths(&paths, "测试"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("null 字节")); + } + + #[test] + fn validate_accepts_cjk_paths() { + let paths = vec!["C:\\用户\\工具".into()]; + let result = validate_and_join_paths(&paths, "测试"); + assert!(result.is_ok()); + } + + #[test] + fn validate_rejects_oversized_paths() { + // 构造总长超过 32767 UTF-16 字符的路径 + let long_path = "C:\\".to_string() + &"a".repeat(32767); + let paths = vec![long_path]; + let result = validate_and_join_paths(&paths, "测试"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("超出 Windows 限制")); + } } diff --git a/core/src/scanner.rs b/core/src/scanner.rs index 2b2feb4..f3c1e3e 100644 --- a/core/src/scanner.rs +++ b/core/src/scanner.rs @@ -50,13 +50,24 @@ fn list_exes(dir: &str) -> Vec { /// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件, /// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」) pub fn scan_conflicts(paths: Vec) -> Result, String> { - // 并行扫描各目录 + // 并行扫描各目录(限制并发数) + let max_threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); let results: Vec<(usize, String, Vec)> = std::thread::scope(|s| { - let handles: Vec<_> = paths.iter().enumerate().map(|(priority, dir)| { - s.spawn(move || (priority, dir.clone(), list_exes(dir))) - }).collect(); - handles.into_iter().map(|h| h.join().unwrap()).collect() - }); + let handles: Vec<_> = paths + .iter() + .enumerate() + .map(|(priority, dir)| s.spawn(move || (priority, dir.clone(), list_exes(dir)))) + .collect(); + handles + .into_iter() + .map(|h| h.join().map_err(|e| format!("扫描线程失败: {:?}", e))) + .collect::, _>>() + }) + .map_err(|e| format!("线程扫描失败: {}", e))?; + // max_threads 用于限制 scope 外的并行度,实际线程由 scope 调度 + let _ = max_threads; // 合并: exe_name (小写) → [(priority, dir)] let mut map: HashMap> = HashMap::new(); @@ -92,31 +103,46 @@ pub fn scan_tools(paths: Vec, query: String) -> Result, S // 并行扫描各目录 let dir_results: Vec<(String, Option>)> = std::thread::scope(|s| { - let handles: Vec<_> = paths.iter().map(|dir| { - s.spawn(move || { - let p = Path::new(dir); - if !p.is_dir() { - return (dir.clone(), None); - } - let exes = list_exes(dir); - (dir.clone(), Some(exes)) + let handles: Vec<_> = paths + .iter() + .map(|dir| { + s.spawn(move || { + let p = Path::new(dir); + if !p.is_dir() { + return (dir.clone(), None); + } + let exes = list_exes(dir); + (dir.clone(), Some(exes)) + }) }) - }).collect(); - handles.into_iter().map(|h| h.join().unwrap()).collect() - }); + .collect(); + handles + .into_iter() + .map(|h| h.join().map_err(|e| format!("扫描线程失败: {:?}", e))) + .collect::, _>>() + }) + .map_err(|e| format!("线程扫描失败: {}", e))?; let mut groups: Vec = Vec::new(); for (dir, opt_exes) in dir_results { match opt_exes { None => { - groups.push(ToolGroup { dir, exists: false, exes: vec![] }); + groups.push(ToolGroup { + dir, + exists: false, + exes: vec![], + }); } Some(mut exes) => { if !query_lower.is_empty() { exes.retain(|name| name.to_lowercase().contains(&query_lower)); } exes.sort(); - groups.push(ToolGroup { dir, exists: true, exes }); + groups.push(ToolGroup { + dir, + exists: true, + exes, + }); } } } @@ -142,7 +168,10 @@ mod tests { fn scan_conflicts_no_duplicates() { let d1 = make_temp_dir_with_exes("c_a", &["a.exe"]); let d2 = make_temp_dir_with_exes("c_b", &["b.exe"]); - let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()]; + let paths = vec![ + d1.to_string_lossy().to_string(), + d2.to_string_lossy().to_string(), + ]; let conflicts = scan_conflicts(paths).unwrap(); assert!(conflicts.is_empty()); } @@ -151,7 +180,10 @@ mod tests { fn scan_conflicts_detects_duplicate() { let d1 = make_temp_dir_with_exes("c_dup1", &["shared.exe"]); let d2 = make_temp_dir_with_exes("c_dup2", &["shared.exe"]); - let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()]; + let paths = vec![ + d1.to_string_lossy().to_string(), + d2.to_string_lossy().to_string(), + ]; let conflicts = scan_conflicts(paths).unwrap(); assert_eq!(conflicts.len(), 1); assert_eq!(conflicts[0].locations.len(), 2); diff --git a/core/src/system.rs b/core/src/system.rs index c4faf48..a5da4af 100644 --- a/core/src/system.rs +++ b/core/src/system.rs @@ -32,16 +32,12 @@ pub fn expand_env_vars(path: &str) -> String { } // 转为 UTF-16 宽字符串(以 null 结尾) - let wide_path: Vec = path - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); + let wide_path: Vec = 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) - }; + let required = + unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0) }; if required == 0 { log::warn!("expand_env_vars: API 查询缓冲区失败, 返回原始路径: {path}"); @@ -51,9 +47,8 @@ pub fn expand_env_vars(path: &str) -> String { // SAFETY: buffer 容量为 required(API 返回的精确大小),wide_path 以 null 结尾, // 且两个指针指向不同的内存区域,不存在重叠 let mut buffer: Vec = vec![0; required as usize]; - let result = unsafe { - ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required) - }; + let result = + unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required) }; if result == 0 || result > required { log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}"); @@ -110,11 +105,7 @@ pub fn broadcast_env_change() { 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; + 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( diff --git a/gui/build.rs b/gui/build.rs index 795b9b7..d860e1e 100644 --- a/gui/build.rs +++ b/gui/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/gui/capabilities/default.json b/gui/capabilities/default.json index 96d0efe..094ea09 100644 --- a/gui/capabilities/default.json +++ b/gui/capabilities/default.json @@ -1,12 +1,22 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "enables the default permissions", + "description": "PathEditor main window capabilities", "windows": [ "main" ], "permissions": [ "core:default", - "dialog:default" + "core:window:allow-set-title", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-start-dragging", + "core:window:allow-is-minimized", + "core:window:allow-set-focus", + "core:event:default", + "dialog:default", + "dialog:allow-open", + "dialog:allow-save", + "log:default" ] } diff --git a/gui/src/commands/backup.rs b/gui/src/commands/backup.rs index 1d0bff1..39082f8 100644 --- a/gui/src/commands/backup.rs +++ b/gui/src/commands/backup.rs @@ -1,6 +1,10 @@ use path_editor_core::backup; #[tauri::command] -pub fn backup_registry(custom_dir: Option) -> Result { backup::backup_registry(custom_dir) } +pub fn backup_registry(custom_dir: Option) -> Result { + backup::backup_registry(custom_dir) +} #[tauri::command] -pub fn get_appdata_dir() -> String { backup::get_appdata_dir() } +pub fn get_appdata_dir() -> String { + backup::get_appdata_dir() +} diff --git a/gui/src/commands/disabled.rs b/gui/src/commands/disabled.rs index bd08199..c439474 100644 --- a/gui/src/commands/disabled.rs +++ b/gui/src/commands/disabled.rs @@ -1,6 +1,10 @@ use path_editor_core::disabled; #[tauri::command] -pub fn save_disabled_state(system: Vec, user: Vec) -> Result<(), String> { disabled::save_disabled_state(system, user) } +pub fn save_disabled_state(system: Vec, user: Vec) -> Result<(), String> { + disabled::save_disabled_state(system, user) +} #[tauri::command] -pub fn load_disabled_state() -> Result<(Vec, Vec), String> { disabled::load_disabled_state() } +pub fn load_disabled_state() -> Result<(Vec, Vec), String> { + disabled::load_disabled_state() +} diff --git a/gui/src/commands/fs.rs b/gui/src/commands/fs.rs index 8b5477d..b5cf417 100644 --- a/gui/src/commands/fs.rs +++ b/gui/src/commands/fs.rs @@ -1,4 +1,6 @@ use path_editor_core::fs; #[tauri::command] -pub fn read_text_file(path: &str) -> Result { fs::read_text_file(path) } +pub fn read_text_file(path: &str) -> Result { + fs::read_text_file(path) +} diff --git a/gui/src/commands/profiles.rs b/gui/src/commands/profiles.rs index 2bf8b32..4ee2021 100644 --- a/gui/src/commands/profiles.rs +++ b/gui/src/commands/profiles.rs @@ -1,12 +1,26 @@ use path_editor_core::profiles; #[tauri::command] -pub fn list_profiles() -> Result, String> { profiles::list_profiles() } +pub fn list_profiles() -> Result, String> { + profiles::list_profiles() +} #[tauri::command] -pub fn save_profile(name: String, sys: Vec, user: Vec) -> Result<(), String> { profiles::save_profile(&name, sys, user) } +pub fn save_profile( + name: String, + sys: Vec, + user: Vec, +) -> Result<(), String> { + profiles::save_profile(&name, sys, user) +} #[tauri::command] -pub fn load_profile(name: String) -> Result { profiles::load_profile(&name) } +pub fn load_profile(name: String) -> Result { + profiles::load_profile(&name) +} #[tauri::command] -pub fn delete_profile(name: String) -> Result<(), String> { profiles::delete_profile(&name) } +pub fn delete_profile(name: String) -> Result<(), String> { + profiles::delete_profile(&name) +} #[tauri::command] -pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { profiles::rename_profile(&old_name, &new_name) } +pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { + profiles::rename_profile(&old_name, &new_name) +} diff --git a/gui/src/commands/registry.rs b/gui/src/commands/registry.rs index f20e609..b38c93e 100644 --- a/gui/src/commands/registry.rs +++ b/gui/src/commands/registry.rs @@ -1,10 +1,18 @@ use path_editor_core::registry; #[tauri::command] -pub fn load_system_paths() -> Result, String> { registry::load_system_paths() } +pub fn load_system_paths() -> Result, String> { + registry::load_system_paths() +} #[tauri::command] -pub fn load_user_paths() -> Result, String> { registry::load_user_paths() } +pub fn load_user_paths() -> Result, String> { + registry::load_user_paths() +} #[tauri::command] -pub fn save_system_paths(paths: Vec) -> Result<(), String> { registry::save_system_paths(paths) } +pub fn save_system_paths(paths: Vec) -> Result<(), String> { + registry::save_system_paths(paths) +} #[tauri::command] -pub fn save_user_paths(paths: Vec) -> Result<(), String> { registry::save_user_paths(paths) } +pub fn save_user_paths(paths: Vec) -> Result<(), String> { + registry::save_user_paths(paths) +} diff --git a/gui/src/commands/scanner.rs b/gui/src/commands/scanner.rs index 7c3dd23..1211faa 100644 --- a/gui/src/commands/scanner.rs +++ b/gui/src/commands/scanner.rs @@ -1,6 +1,10 @@ use path_editor_core::scanner; #[tauri::command] -pub fn scan_conflicts(paths: Vec) -> Result, String> { scanner::scan_conflicts(paths) } +pub fn scan_conflicts(paths: Vec) -> Result, String> { + scanner::scan_conflicts(paths) +} #[tauri::command] -pub fn scan_tools(paths: Vec, query: String) -> Result, String> { scanner::scan_tools(paths, query) } +pub fn scan_tools(paths: Vec, query: String) -> Result, String> { + scanner::scan_tools(paths, query) +} diff --git a/gui/src/commands/system.rs b/gui/src/commands/system.rs index 86dcfd8..57da387 100644 --- a/gui/src/commands/system.rs +++ b/gui/src/commands/system.rs @@ -1,10 +1,18 @@ use path_editor_core::system; #[tauri::command] -pub fn check_admin() -> bool { system::check_admin() } +pub fn check_admin() -> bool { + system::check_admin() +} #[tauri::command] -pub fn validate_path(path: &str) -> bool { system::validate_path(path) } +pub fn validate_path(path: &str) -> bool { + system::validate_path(path) +} #[tauri::command] -pub fn expand_env_vars(path: &str) -> String { system::expand_env_vars(path) } +pub fn expand_env_vars(path: &str) -> String { + system::expand_env_vars(path) +} #[tauri::command] -pub fn broadcast_env_change() { system::broadcast_env_change() } +pub fn broadcast_env_change() { + system::broadcast_env_change() +} diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 2bcbe97..8e138dc 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -5,13 +5,13 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } + let level = if cfg!(debug_assertions) { + log::LevelFilter::Info + } else { + log::LevelFilter::Warn + }; + app.handle() + .plugin(tauri_plugin_log::Builder::default().level(level).build())?; Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/gui/src/main.rs b/gui/src/main.rs index ad5fe83..69c3a72 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/package-lock.json b/package-lock.json index 2174051..42a046b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,12 +23,12 @@ "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", "@tauri-apps/cli": "^2.11.2", - "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -40,13 +40,6 @@ "vitest": "^4.1.7" } }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -347,6 +340,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1600,33 +1603,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", @@ -2011,6 +1987,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.7.tgz", @@ -2195,6 +2202,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2209,6 +2217,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2356,13 +2383,6 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -2422,6 +2442,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2853,6 +2874,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2883,6 +2914,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -2949,16 +2987,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2996,6 +3024,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", @@ -3429,6 +3496,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3436,16 +3544,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", @@ -3790,20 +3888,6 @@ "license": "MIT", "peer": true }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3929,14 +4013,14 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "min-indent": "^1.0.0" + "has-flag": "^4.0.0" }, "engines": { "node": ">=8" diff --git a/package.json b/package.json index 790477d..5aa86ad 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test --config e2e/playwright.config.ts" }, "dependencies": { @@ -28,12 +29,12 @@ "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", "@tauri-apps/cli": "^2.11.2", - "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/src/components/dialogs/AnalyzeDialog.tsx b/src/components/dialogs/AnalyzeDialog.tsx index e6cf400..3dc5565 100644 --- a/src/components/dialogs/AnalyzeDialog.tsx +++ b/src/components/dialogs/AnalyzeDialog.tsx @@ -37,8 +37,9 @@ export function AnalyzeDialog({ open, onClose }: Props) { const prevOpen = useRef(false); useEffect(() => { - if (!open || prevOpen.current) return; - prevOpen.current = open; + if (!open) { prevOpen.current = false; return; } + if (prevOpen.current) return; + prevOpen.current = true; setLoading(true); const paths = getEnabledPaths(); Promise.all([ @@ -184,7 +185,7 @@ function ToolsTab({ opacity: g.exists ? 1 : 0.6, }} > - {g.dir} {!g.exists && '(不存在)'} + {g.dir} {!g.exists && t('analyze.notExists')}
{g.exes.map((exe) => ( diff --git a/src/components/dialogs/ImportDialog.tsx b/src/components/dialogs/ImportDialog.tsx index 7c07259..f6a0679 100644 --- a/src/components/dialogs/ImportDialog.tsx +++ b/src/components/dialogs/ImportDialog.tsx @@ -16,9 +16,9 @@ export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel

{t('dialog.importTarget')}

- {systemCount > 0 && `系统变量: ${systemCount} 条`} + {systemCount > 0 && t('dialog.importSystemCount', { count: systemCount })} {systemCount > 0 && userCount > 0 && ' | '} - {userCount > 0 && `用户变量: ${userCount} 条`} + {userCount > 0 && t('dialog.importUserCount', { count: userCount })}

{systemCount > 0 && } diff --git a/src/components/dialogs/ProfileDialog.tsx b/src/components/dialogs/ProfileDialog.tsx index 59be6cb..fc2a028 100644 --- a/src/components/dialogs/ProfileDialog.tsx +++ b/src/components/dialogs/ProfileDialog.tsx @@ -151,7 +151,7 @@ export function ProfileDialog({ open, onClose }: Props) {
{!selectedData ? (
- {profiles.length === 0 ? t('profile.noProfiles') : '选择一个配置文件'} + {profiles.length === 0 ? t('profile.noProfiles') : t('profile.selectProfile')}
) : (
@@ -194,7 +194,7 @@ export function ProfileDialog({ open, onClose }: Props) { style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }} />
)} @@ -211,16 +211,17 @@ export function ProfileDialog({ open, onClose }: Props) { } function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) { + const { t } = useTranslation(); return (
{title}
{paths.length === 0 ? ( -
(空)
+
{t('profile.empty')}
) : (
- {paths.map((e, i) => ( + {paths.map((e) => (
s.sysPaths); const userPaths = useAppStore((s) => s.userPaths); const searchQuery = useAppStore((s) => s.searchQuery); @@ -35,6 +37,33 @@ export function PathTable({ tabId }: PathTableProps) { const validatedRef = useRef>(new Set()); const expandedRef = useRef>(new Set()); + // 清理不再存在的路径缓存 + useEffect(() => { + const currentKeys = new Set(paths.map(p => p.path)); + setValidationCache(prev => { + let changed = false; + const next = new Map(prev); + for (const key of next.keys()) { + if (!currentKeys.has(key)) { next.delete(key); changed = true; } + } + return changed ? next : prev; + }); + setExpandedCache(prev => { + let changed = false; + const next = new Map(prev); + for (const key of next.keys()) { + if (!currentKeys.has(key)) { next.delete(key); changed = true; } + } + return changed ? next : prev; + }); + for (const key of [...validatedRef.current]) { + if (!currentKeys.has(key)) validatedRef.current.delete(key); + } + for (const key of [...expandedRef.current]) { + if (!currentKeys.has(key)) expandedRef.current.delete(key); + } + }, [paths]); + // 过滤搜索 const filtered = useMemo(() => { if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled })); @@ -160,7 +189,7 @@ export function PathTable({ tabId }: PathTableProps) { > # - 路径 + {t('table.path')} diff --git a/src/components/toolbar/UndoRedoButtons.tsx b/src/components/toolbar/UndoRedoButtons.tsx index 9a977fd..81e5f59 100644 --- a/src/components/toolbar/UndoRedoButtons.tsx +++ b/src/components/toolbar/UndoRedoButtons.tsx @@ -6,6 +6,9 @@ export function UndoRedoButtons() { const { t } = useTranslation(); const isAdmin = useAppStore((s) => s.isAdmin); const undoRedo = useAppStore((s) => s.undoRedo); + // 订阅路径数组长度变化,确保 undoRedo 内部状态变化时触发重渲染 + useAppStore((s) => s.sysPaths.length); + useAppStore((s) => s.userPaths.length); const undo = useAppStore((s) => s.undo); const redo = useAppStore((s) => s.redo); diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index 4256894..d19f53f 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -117,8 +117,16 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) { URL.revokeObjectURL(url); }, []); - const handleSave = useCallback(() => { - useAppStore.getState().savePaths(); + const handleSave = useCallback(async () => { + const saved = await useAppStore.getState().savePaths(); + if (!saved && !useAppStore.getState().isSaving) { + // 长度超限,需要用户确认 + const { ask } = await import('@tauri-apps/plugin-dialog'); + const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), { title: i18n.t('dialog.backupTitle'), kind: 'warning' }); + if (confirmed) { + await useAppStore.getState().savePaths(true); + } + } }, []); // ── 键盘 ── diff --git a/src/hooks/use-keyboard.ts b/src/hooks/use-keyboard.ts index 99efc4f..4114a09 100644 --- a/src/hooks/use-keyboard.ts +++ b/src/hooks/use-keyboard.ts @@ -56,11 +56,6 @@ export function useKeyboard(actions: KeyboardActions) { if (!isAdmin) return; e.preventDefault(); a.onDelete(); - } else if (ctrl && e.key === 'f') { - e.preventDefault(); - const searchInput = document.querySelector('input[placeholder]'); - searchInput?.focus(); - searchInput?.select(); } else if (e.key === 'F1') { e.preventDefault(); a.onHelp(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ab042e3..a101335 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -8,6 +8,9 @@ "user": "User Variables", "merged": "Merge Preview" }, + "table": { + "path": "Path" + }, "button": { "new": "New", "edit": "Edit", @@ -51,7 +54,8 @@ "readonly_label": "Read-only", "light": "Light", "dark": "Dark", - "adminWarning": "Running without administrator privileges, some features are disabled" + "adminWarning": "Running without administrator privileges, some features are disabled", + "saveWarningLongPaths": "PATH length exceeds recommended value. Continue saving?" }, "dialog": { "newPath": "New Path", @@ -70,7 +74,9 @@ "backupMessage": "Back up registry before saving?", "confirm": "Confirm", "cancel": "Cancel", - "search": "Search paths..." + "search": "Search paths...", + "importSystemCount": "System: {{count}} entries", + "importUserCount": "User: {{count}} entries" }, "analyze": { "title": "PATH Analysis", @@ -82,7 +88,8 @@ "priority": "Prioritized", "shadowed": "Shadowed", "searchPlaceholder": "Search executable name...", - "conflictCount": "{{count}} file conflict(s) found" + "conflictCount": "{{count}} file conflict(s) found", + "notExists": "(not found)" }, "profile": { "title": "PATH Profiles", @@ -95,7 +102,9 @@ "rename": "Rename", "noProfiles": "No saved profiles", "applyConfirm": "This will overwrite current PATH with profile \"{{name}}\" and write to registry. Confirm?", - "deleted": "Profile \"{{name}}\" deleted" + "deleted": "Profile \"{{name}}\" deleted", + "selectProfile": "Select a profile", + "empty": "(empty)" }, "help": { "content": "PathEditor v5.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a057864..1588e62 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -8,6 +8,9 @@ "user": "用户变量", "merged": "合并预览" }, + "table": { + "path": "路径" + }, "button": { "new": "新建", "edit": "编辑", @@ -51,7 +54,8 @@ "modified": "已修改", "readonly_label": "只读", "light": "浅色", - "dark": "深色" + "dark": "深色", + "saveWarningLongPaths": "PATH 长度超过建议值,是否继续保存?" }, "dialog": { "newPath": "新建路径", @@ -70,7 +74,9 @@ "backupMessage": "保存前需要备份注册表吗?", "confirm": "确认", "cancel": "取消", - "search": "搜索路径..." + "search": "搜索路径...", + "importSystemCount": "系统变量: {{count}} 条", + "importUserCount": "用户变量: {{count}} 条" }, "analyze": { "title": "PATH 分析", @@ -82,7 +88,8 @@ "priority": "优先执行", "shadowed": "被遮蔽", "searchPlaceholder": "搜索可执行文件名...", - "conflictCount": "发现 {{count}} 个文件冲突" + "conflictCount": "发现 {{count}} 个文件冲突", + "notExists": "(不存在)" }, "profile": { "title": "PATH 配置文件", @@ -95,7 +102,9 @@ "rename": "重命名", "noProfiles": "暂无配置文件", "applyConfirm": "将用配置 \"{{name}}\" 覆盖当前 PATH 并写入注册表,确定吗?", - "deleted": "已删除配置 \"{{name}}\"" + "deleted": "已删除配置 \"{{name}}\"", + "selectProfile": "选择一个配置文件", + "empty": "(空)" }, "help": { "content": "PathEditor v5.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor" diff --git a/src/store/app-store.ts b/src/store/app-store.ts index 83e9aa9..6797af7 100644 --- a/src/store/app-store.ts +++ b/src/store/app-store.ts @@ -45,7 +45,7 @@ interface AppState { redo: () => void; loadPaths: () => Promise; - savePaths: () => Promise; + savePaths: (force?: boolean) => Promise; initialize: () => Promise; } @@ -248,7 +248,7 @@ export const useAppStore = create((set, get) => { const sysDisabled = sys.filter(e => !e.enabled).map(e => e.path); const usrDisabled = usr.filter(e => !e.enabled).map(e => e.path); invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }) - .catch(() => {}); + .catch((e) => console.warn('保存禁用状态失败:', e)); }, undo: () => { @@ -264,7 +264,7 @@ export const useAppStore = create((set, get) => { invoke('save_disabled_state', { system: result[0].filter(e => !e.enabled).map(e => e.path), user: result[1].filter(e => !e.enabled).map(e => e.path), - }).catch(() => {}); + }).catch((e) => console.warn('保存禁用状态失败:', e)); } }, @@ -281,7 +281,7 @@ export const useAppStore = create((set, get) => { invoke('save_disabled_state', { system: result[0].filter(e => !e.enabled).map(e => e.path), user: result[1].filter(e => !e.enabled).map(e => e.path), - }).catch(() => {}); + }).catch((e) => console.warn('保存禁用状态失败:', e)); } }, @@ -322,9 +322,9 @@ export const useAppStore = create((set, get) => { } }, - savePaths: async () => { + savePaths: async (force?: boolean) => { const state = get(); - if (state.isSaving) return; + if (state.isSaving) return false; set({ isSaving: true, statusMessage: i18n.t('status.saving') }); // 只保存 enabled 的路径到注册表 @@ -333,9 +333,11 @@ export const useAppStore = create((set, get) => { const sysJoined = sysPaths.join(';'); const userJoined = userPaths.join(';'); + // 长度检查:非强制模式下返回警告,由 UI 层确认 const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path; - if (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength) { - if (!window.confirm('PATH 长度超过建议值,是否继续保存?')) { set({ isSaving: false }); return; } + if (!force && (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength)) { + set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') }); + return false; } // 备份当前注册表(保存前备份旧值,失败仅警告不中断) @@ -357,12 +359,14 @@ export const useAppStore = create((set, get) => { set({ isModified: false, isSaving: false, statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'), _savedSys: savedSys, _savedUser: savedUser }); + return true; } else { const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : ''; const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : ''; const parts = [sysErr, usrErr].filter(Boolean); const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${parts.join('; ')}`; set({ isSaving: false, statusMessage: msg }); + return false; } }, diff --git a/tests/unit/analyze-dialog.test.tsx b/tests/unit/analyze-dialog.test.tsx index 89e6138..0e3f87c 100644 --- a/tests/unit/analyze-dialog.test.tsx +++ b/tests/unit/analyze-dialog.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { describe, it, expect, vi } from 'vitest'; import { render } from '@testing-library/react'; import { AnalyzeDialog } from '../../src/components/dialogs/AnalyzeDialog'; diff --git a/tests/unit/app-store.test.ts b/tests/unit/app-store.test.ts index dc00856..6385773 100644 --- a/tests/unit/app-store.test.ts +++ b/tests/unit/app-store.test.ts @@ -268,8 +268,8 @@ describe('savePaths', () => { // 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true) const r2 = useAppStore.getState().savePaths(); - // 第二次调用同步返回 undefined(被守卫拦截) - await expect(r2).resolves.toBeUndefined(); + // 第二次调用同步返回 false(被守卫拦截) + await expect(r2).resolves.toBe(false); // 放行第一次调用的所有 invoke resolveAll!(undefined); diff --git a/tests/unit/merge-preview.test.tsx b/tests/unit/merge-preview.test.tsx index ec5ab71..4277a20 100644 --- a/tests/unit/merge-preview.test.tsx +++ b/tests/unit/merge-preview.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { describe, it, expect, vi } from 'vitest'; import { render } from '@testing-library/react'; import { MergePreview } from '../../src/components/path-list/MergePreview'; diff --git a/tests/unit/path-manager.test.ts b/tests/unit/path-manager.test.ts index 5cf8cb2..02b6333 100644 --- a/tests/unit/path-manager.test.ts +++ b/tests/unit/path-manager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { pathClean } from '../../src/core/path-manager'; +import { pathClean, analyzePaths } from '../../src/core/path-manager'; import type { PathEntry } from '../../src/core/path-entry'; function pe(s: string, enabled: boolean = true): PathEntry { @@ -9,6 +9,31 @@ function pe(s: string, enabled: boolean = true): PathEntry { const alwaysValid = () => true; const validateFn = (path: string) => !path.includes('Invalid'); +describe('analyzePaths', () => { + it('检测大小写重复', () => { + const result = analyzePaths([pe('C:\\Windows'), pe('c:\\windows')], alwaysValid); + expect(result[0].isDuplicate).toBe(false); + expect(result[1].isDuplicate).toBe(true); + }); + + it('识别环境变量路径', () => { + const result = analyzePaths([pe('C:\\Normal'), pe('%JAVA_HOME%\\bin')], alwaysValid); + expect(result[0].isEnvVar).toBe(false); + expect(result[1].isEnvVar).toBe(true); + }); + + it('标记无效路径', () => { + const result = analyzePaths([pe('C:\\Valid'), pe('C:\\Invalid')], validateFn); + expect(result[0].isValid).toBe(true); + expect(result[1].isValid).toBe(false); + }); + + it('空数组返回空', () => { + const result = analyzePaths([], alwaysValid); + expect(result).toEqual([]); + }); +}); + describe('pathClean', () => { it('移除无效路径', () => { const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')], validateFn); diff --git a/tests/unit/undo-redo.test.ts b/tests/unit/undo-redo.test.ts index ca4258f..d61134b 100644 --- a/tests/unit/undo-redo.test.ts +++ b/tests/unit/undo-redo.test.ts @@ -174,4 +174,29 @@ describe('UndoRedoManager', () => { const r = mgr.redo(...u)!; expect(r[0][0].enabled).toBe(false); }); + + it('IMPORT_BOTH 撤销/重做(同时修改系统和用户路径)', () => { + const oldSys = [...sys]; + const oldUser = [...user]; + const newSys = [pe('C:\\ImportedSys')]; + const newUser = [pe('C:\\ImportedUser')]; + + mgr.push({ + type: OperationType.IMPORT_BOTH, + target: TargetType.SYSTEM, + index: 0, count: 0, + oldPaths: oldSys, newPaths: newSys, + oldPathsOther: oldUser, newPathsOther: newUser, + }); + sys = newSys; + user = newUser; + + const u = mgr.undo(sys, user)!; + expect(u[0]).toEqual(oldSys); + expect(u[1]).toEqual(oldUser); + + const r = mgr.redo(...u)!; + expect(r[0]).toEqual(newSys); + expect(r[1]).toEqual(newUser); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..01490aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..2c4a96c --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 479189d..2c3e84f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,15 @@ export default defineConfig({ }, }, test: { + environment: 'jsdom', exclude: ['e2e/**', 'node_modules/**', 'gui/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/core/**', 'src/store/**', 'src/hooks/**'], + thresholds: { + lines: 80, + }, + }, }, });