PATH 是那种平时想不起来、出事时解释不清的配置
上周我想搞清楚自己电脑上到底装了几个 Python。终端里敲 where python,出来四行,第一行指向的目录我两个月前就删了。
这不是什么事故,更像两年的自然沉积:PATH 像一只谁都能往里扔东西的抽屉,安装器只管扔,没人负责整理,也没人告诉你抽屉里已经躺着四十多件了。装 Python 塞一行,装 Node 塞一行,装 JDK 塞一行,装 Git 再塞一行。后来为了跑某个老项目又装了一个 JDK,于是 PATH 里并排站着两个 java.exe。一半的条目指向早就不存在的目录,剩下那一半里,你也记不清哪条是自己装的、哪条是某个安装器顺手捎带的。
要说清它为什么容易出事,得先弄明白 PATH 在干什么。一句话:你在终端敲一个名字,系统按顺序去这些目录里挨个找同名的 .exe,找到第一个就用它。顺序就是优先级,排在前面的赢,后面的永远轮不到。你敲 python 跑起来的到底是哪一个,完全取决于谁排在前面。
Windows 自带的编辑入口藏在「系统属性 → 高级 → 环境变量」。我早年一直用它,直到有一次手滑把一整行路径拖到了别的位置。那个窗口就是一个细长的输入框,所有路径挤在一行里用分号拼起来,不能拖动排序,看不到合并之后真正的生效顺序,没有撤销,改完点一下确定就直接写进注册表。
注册表里没有 Ctrl+Z。
麻烦还有一层,来自注册表本身的结构。Windows 的 PATH 不止一份:系统 PATH 存在 HKLM,用户 PATH 存在 HKCU。这两份分别叫 hive(注册表的顶层分支)。HKLM 那份对整台机器生效,改它要管理员权限;HKCU 那份只管你当前这个 Windows 账户,普通权限就能写。当初把 PATH 拆成两份,是为了让一台机器上的几个账户各管各的工具链,谁也不影响谁。两份分开存着,开终端的时候才合并成你真正在用的那一条。
所以很多人改完 PATH 发现没动静——你改的那份被另一份盖住了,合并后的顺序也跟你以为的不一样。更麻烦的是它不报错。路径写错一个字,系统不会提醒你,只会接着往下找,让别的同名程序顶上。
我写 PathEditor 的起点就在这儿。它先回答的是我到底有哪些路径在生效、改完之后会发生什么。我不打算给注册表再套一个输入框,也不打算取代系统自带的那个窗口。至于安装包怎么打、怎么分发,之前单独写过一篇,这里不重复。

一个 isAdmin 推不出两个 PATH 的权限
很多人第一次写这类工具,会用一个全局的「是不是管理员」去推断两个 PATH 能不能写,这是最容易埋下的坑。管理员当然两边都能改,可普通用户打开程序时,系统 PATH 是只读的,用户 PATH 却能改。一条全局标志位一挡,普通用户连自己的 PATH 都动不了。
我做的是按 hive 分开探测:系统那边问一次权限,用户那边再问一次,界面上分别告诉你哪个能写、哪个得提权。这条决定的收益很直白——绝大多数人日常只需要调自己的 PATH,不该因为没开管理员就整个功能废掉。权限理清了,下一个问题才是它真正想解决的。
它算的是「改完会发生什么」
自带编辑器最大的问题是只让你看见「存了什么」,看不见「会生效成什么样」。注册表里两段值,合起来是什么顺序、有没有重复、哪条先命中,全靠你自己在脑子里拼。
我让合并预览按「系统在前、用户在后」拼出最终顺序,重复的去掉,右边标出每条来自哪个 hive。你打开就能知道「我到底有哪些路径在生效」,不用再去两个窗口之间来回对。

冲突分析更狠一点:它扫遍 PATH 里每个目录,找同名的可执行文件,标出实际会执行的那一个,其余全打上「被遮蔽」。装完 JDK 又装一个 JDK,PATH 里躺着两个 java.exe,你敲 java -version 出来的版本,可能跟你以为的完全不是一回事。
PATH 出问题从来不是报错,是静默地跑错东西。
路径验证我做成了用颜色说话:红色是目录根本不存在,橙色是列表里重复了。这些都能一键清理,但清理前会先留一份完整快照,清错了还能回来。清理是个批量动作,批量动作必须可撤销,这条没什么可商量的。

让手滑有退路
编辑配置这件事,改错其实不可怕,可怕的是改完找不到回去的路。所以我在几个地方都留了口子。
撤销重做覆盖 9 种操作,最多记 50 步。上移下移、增删改、批量清理这些动作走完发现不对,Ctrl+Z 能救回来。
保存前自动备份注册表,默认保留最近 20 份,想改数量可以去 config.ini 里调 path_backup_keep。PATH 本身有 32767 字符的上限,快超了会提醒你,而不是等你点了保存才甩一个系统错误。
保存中途失败会告诉你卡在哪个 hive——系统那边权限不够,和用户那边写不进去,处理方式是两码事。
环境变量不止 PATH。别的变量也能统一管,敏感名字(TOKEN、KEY、SECRET、密码、API 这类)默认打码,得点「显示」才看得到明文;windir、ComSpec、PATHEXT 这种系统关键变量硬锁只读,改都不让改。这些值改坏了机器直接起不来,索性锁死。

说到底,它先把结果算给你看,再把退路准备好。
同一个动作,两个入口
前面这些判定,最终都落在同一处代码上。GUI 是我自己点,CLI 是脚本和远程用。两边都能改 PATH,就必须改出一模一样的结果。
麻烦不在代码重复,在判定重复。「保留名不能改」「受保护变量不能碰」「这条路径的类型是 Unsupported」「revision 对不上就拒绝」——规则写两遍,迟早有一处先改。
v6 的做法很干脆:判定全部下沉到一个 Rust core,两个入口各留一层壳。
四层 core:依赖只能往下走
core 分四层,依赖单向:app → db → platform → domain。
domain 在最底下,只有契约和端口。PathEntry { path, enabled }、PathSnapshot、CoreError、HistorySink 都在这里。零 OS、零 IO、零数据库——不带 Windows API,不读文件,不碰 SQL。这层测试不需要任何环境。
platform 是外部世界适配层。注册表读写、备份、按 hive 独立探测权限的 PathCapabilities、路径验证、冲突扫描全在这儿。和机器打交道的代码都长在这里。
db 是 v6 加的 SQLCipher 数据层,它不认识注册表,只管自己的库。
app 是编排层,也是唯一同时依赖 platform 和 db 的一层。save_path_with_db 在这里,管五件事:写前备份、并发校验、依次写 system 和 user、广播环境变更、往 DB 落快照和事件。数据库打不开就退到 save_path_registry_only。想知道哪些代码碰了注册表,看目录就够了。
壳薄,是因为决定权不在壳里
src-tauri/src/commands/ 那十几个文件只做参数转换和错误呈现。命令进来,转成 core 的类型,调 core 的函数,把 CoreError 转成前端能读的形状。业务规则一行不写。
前端这边,services/backend.ts 是唯一 IPC 入口,除了发请求还做运行时形状校验——类型断言管不住跨语言边界,被 mock 骗过一次。
React 侧也分层。src/gui/web/src/core/ 是纯函数,undo-redo.ts、path-entry.ts、env-var.ts 都在里面,零框架依赖,能脱离 React 单测。组件和 Store 不许直接 invoke。
CLI 零安全判定。保留名、保护名单、Unsupported 类型、hive 写权限、revision 校验全在 core,CLI 只做参数解析、命令分派和错误文本透传。新增命令时复制判定逻辑是被明令禁止的。
壳连「这个路径能不能删」都不用想,只负责把 core 的结论翻译成人话。
分层买来的麻烦
core 变重了,这是我最直接的感受。改一条判定规则要动 core,Rust 和前端两套测试都得跑。新增一个 Unsupported 类型,看着只是一个 match 分支,实际要确认两个入口的提示文本、退出码、序列化形状都对得上。
调试要跨三层看。用户点保存报错,你得从组件摸到 store,再到 backend.ts,进 Tauri command,进 app::service,最后落到 platform::registry 或 db。每层都薄,链条很长。
platform 内部也不完全干净。registry 和 backup 互相引用:写之前要备份,备份时还要回读注册表比对。这是仓库里唯一的层内强连通分量。层与层之间能管住,registry 和 backup 这种同层互调就管不住了。
注册表只能存「现在」,存不下时间线
注册表是个只存当前值的地方。你打开 HKLM 或 HKCU 下的 Path,看到的就是此刻生效的那一串。上周改过什么、哪次改动把两个 java.exe 排反了、某个变量被哪些配置引用——它一概不记。
我第一次想做「历史」功能时就撞上这堵墙:没有地方落盘,撤销重做只能活在内存里,关掉窗口就没了。后来要的东西越来越多——配置版本、全文检索、统计、引用图、跨设备同步——每一样都得有地方存。v6 于是引入一个 SQLCipher 加密库,落在 ~/.patheditor/patheditor.db。选加密版而不是裸 SQLite,是因为里面有整条 PATH 的历史和变量名,明文摊在用户目录下,等于把系统配置又抄了一份。
但有个顺序问题必须先定:谁是主库。我的答案是注册表,库只是 sidecar(配在主数据旁边的辅助库)。

这条线划错,可能把系统配置一起搭进去。所以 DB 打不开时,程序走 fail-open(数据库打不开也不耽误编辑):PATH 照常读写,历史、检索、统计、同步降级,窗口顶部挂一条常驻条幅,告诉你哪些能力暂时不可用。而且我明确不让它静默重建一个空库——那等于把「库坏了」伪装成「你从没改过 PATH」,比报错更糟。
数据库可以坏,PATH 不能坏。
写注册表成功、快照没写进去的情况也会发生,这时落一条 pending,下次启动补写。宁可晚记一点,也不能丢记录。
划一条线:什么是真相,什么可以重算
库里的表我分两类,界线只有一条:这张表没了,能不能从别处重新算出来。
snapshots / events / changes 是真相。快照是某一刻 PATH 的完整样子,事件是「谁在什么时候改了什么」,changes 是给同步用的变更日志。这三张表烧了就是真丢了,没有任何东西能反推。search_fts / daily_stats / var_references 是派生表——全文索引、每日统计、%VAR% 引用图,全都能从真相重算。
有点像账本和报表:账本撕了没法重算,报表烧了照着账本重打一遍就行。
这条分界看着像废话,落地时天天用得上。GUI 启动会按需重建:FTS 索引只在空的时候建,统计和引用图每次重扫。库升级迁移出问题,派生表可以删了重建,真相表必须一行一行迁。判断一张新表放哪边,也只需要问一句:它能不能重算。
密钥绑在 Windows 账户上,换台机器就打不开
数据库密钥存在 ~/.patheditor/keys/db.key.dpapi,整体用 DPAPI 加密,作用域是当前 Windows 用户,DACL 只给当前用户和 SYSTEM。文件名里的 .dpapi 就是这个意思:加密密钥由 Windows 绑在当前账户上。第一次生成密钥走 create_new 原子写入,不给「先检查再写」留竞态窗口。
换机器、换用户,这把密钥解不开。有人会当成 bug,但这是设计——一台机器上的 PATH 历史,本来就不该被另一台机器上随便哪个账户读走。代价也直接:历史不跨设备迁移。真要搬配置,走的是另一套加密同步包。
同步:加密、冲突、不碰系统
加密这块我没留余地:Argon2id 派生密钥(m=64 MiB, t=3, p=1),再套 ChaCha20-Poly1305。AAD 把整个包头都包进认证范围——magic、版本号、header JSON、salt、nonce,改一个字节解密就失败。这串参数你不用记,知道它故意很慢、专门防暴力破解就行。
LWW(last-write-wins,最后写入的赢)平局不静默覆盖。每台设备各带一个只增不减的序号(lamport 时钟),lamport 大的赢;lamport 相同、设备不同、值也不同时,我不替你挑一个,而是写一条 sync_conflicts 让你选。你解决之后会追加一条 lamport 更高的变更,否则第三台设备同步过来还会按原 lamport 再判一次。
导入只改数据库,不写注册表。同步做的是把配置搬过来;系统那头,等你点头再动。你在同步中心点完导入,PATH 一个字符都没变;要生效得自己再应用一次快照,或者走前端那套保存编排。

代价,和还没解决的问题
构建成本先摆出来。加密库链到 vendored OpenSSL,SQLCipher 和 OpenSSL 的 C 源码都随 crate 一起编译,首次全量构建要几十分钟,二进制大 1–2 MB。换来的是不依赖你机器上装了什么版本的 OpenSSL。我认为值,但这不是零成本。
测试上还有个怪约束:同一个进程里跑第二个用 SQLCipher 的测试会栈溢出,--test-threads=1 也救不回来。只能一个测试文件放一个 #[test],靠文件布局隔离。
还没解决的问题我照实列,一个都不打算美化:
- 用 CLI 改过 PATH 之后,派生表不会重建,GUI 里的引用图和统计要重启才对齐。
- 配置合并是整实体 LWW,两端同时改同一条配置会互相覆盖,还没做到字段级。
- 同步包是增量包,不是全量快照,新设备必须从第一份开始按顺序应用。
这三条在路线图上都排着,但今天就是没做完。写出来比藏起来好——你知道边界在哪,才不会在出错时怀疑是玄学。这些设计落到你手上,就是三条装法。
装法只有三条
不想编译就去 Releases 页下 PathEditor_6.2.0_x64-setup.exe,双击装完。
习惯包管理的走 Scoop:先 scoop bucket add lhy https://github.com/LHY0125/scoop-bucket,再 scoop install lhy/patheditor-gui。只想要命令行那一半,把最后一条换成 scoop install lhy/patheditor-cli。
想改代码就克隆下来,npm install 之后 npx tauri build;CLI 单独装用 cargo install --path cli。头一次慢在 vendored OpenSSL,得现编。
三条路装出来是同一个程序,区别只在以后怎么升级。硬要求是 Windows 10 以上,WebView2 系统自带,不用另外装。GUI 和 CLI 互不依赖,只想在脚本里调,装 CLI 那一个就够。
三条命令,先看再写
patheditor list --system --json
patheditor conflicts
patheditor env set JAVA_HOME --value "D:\settings\Language\Java\jdk-25.0.1" --revision <R>
第一条吐出系统 PATH 的结构化快照,顺序和每一条路径都在里面,接给脚本随便切。第二条扫两个 hive 里的同名 exe,标出哪个真的会被执行、哪些被遮蔽。这两条都只读,不碰注册表。
--json 是给脚本吃的,去掉它换成人看的排版,同一份数据两种面孔。conflicts 也认这个参数,你想在别的地方拦「一个名字被两个目录抢」的情况,有地方挂了。
第三条要写,所以它逼你选并发模式:--revision <R> 或 --force,二选一,缺一个 clap 直接报错。R 从 env list --json 里取。
为什么非得选?Windows 注册表没有 CAS(compare-and-swap,先比再写),读和写是两次独立调用,中间隔着几毫秒。带 --revision 时,这期间有别的进程改过这个变量,写入就被拒绝,退出码 3;带 --force 就是最后写入者胜。我宁愿它拒绝我一次,也不想默默盖掉别人刚写的值。这只是把窗口收窄,不是原子性。
顺带记一句:env get 是命令行唯一的明文出口,stdout 只打裸值,管道友好;要写敏感变量就用 --stdin 或 --value-file,别让它留在 shell 历史里。

它凭什么说没坏
提交前我跑 npm run verify:Prettier 检查、ESLint、类型检查加 Vite 构建、Vitest 覆盖率、cargo fmt --check、clippy -D warnings、cargo test --workspace,一道不过就停。覆盖率门槛 80% 行。npm run verify:all 在它之上再跑 Playwright。现在的账本是 Rust 327 个测试、前端 413 个、E2E 54 个,加起来七百多。
有件事得摊开说:E2E 用的是 mock IPC,不碰真实注册表。它证明的是「点了保存之后前端走了哪条路」,不是「这台机器上的 PATH 真被改了」。真机上那一步我另外手工做过,操作前后各存一份快照比对。它也没进 CI,只有本地跑 verify:all 才带上。
~/.patheditor/ 下面是全部家当:patheditor.db 是那个加密库,keys/db.key.dpapi 是它的密钥,backups/ 里存着每次保存前自动写的 path_backup_*.txt,config.ini 管保留份数。PATH 被删干净了也不用重装——历史面板点一条快照恢复,或者从 backups/ 里挑最近那份,都能回去。
backups/ 里的 PATH 备份是明文,别往云盘或仓库里同步;轮换只删它自己写的 path_backup_*.txt,你手动扔进去的文件不会被碰。环境变量那套备份默认是 DPAPI 加密的 .pebak,绑当前 Windows 用户,换个账户就打不开了。
路线图上还压着跨平台和字段级合并两个候选,都还没动。我现在的习惯是改 PATH 前先打开它看一眼合并顺序,改完保存,关掉。
那几个 Python 后来弄清楚了:四个,排第一的那条指向两个月前就删掉的目录,真正跑起来的偏偏是排在后面那个。合并预览把这件事直接摆在了我面前。
评论交流
欢迎留下你的想法