Article
做 Mac 清理工具,难的是证明这些文件没人在用
$TMPDIR 里的 5.2 万个条目
Cleanzo 是我 10 月 10 日下午开始写的 macOS 清理工具,后端用 Go,界面是网页,用 MyGo 打包成一个约 8 MB 的原生应用。0.1.0 签了名、过了苹果公证,10 月 11 日凌晨 1 点半发了出去。
等公证的那段时间,我让 Claude Code 把整个项目审一遍;发布之后,又截了一张垃圾清理页的图问它:
这个功能做的有没有问题?合理吗?和腾讯的柠檬,以及mole 呢
审查的第一条,就是「临时文件」这一项。它的路径是 os.TempDir(),也就是 /var/folders/…/T,标着「建议清理」,默认勾选。这台 Mac 上这个目录有 5.2 万个条目,其中正被打开着的有:OrbStack 虚拟机管理进程的锁文件,openclaw 的 gateway 锁文件,一个已经被加载的 .node 原生模块,一个 Electron 应用的 IPC socket。把锁文件挪走,同一个程序可能会再启动一份;把 socket 挪走,进程之间的通信就断了。
安全检查也没有拦住它。safeToClean 只拦 /private/ 开头的路径,而这里写的是 /var/folders。/var 其实是指向 /private/var 的符号链接,可 filepath.Abs 不解析符号链接,路径原样放行。测试更直接:main_test.go 里有一条断言,明确把临时目录判成可以清理。这个 bug 在测试里是被当成预期写下来的。
修法分两层。第一层是路径:先解析符号链接、统一成小写再比较,/etc、/tmp、符号链接、/Users/me/library 这类写法都绕不过去;能清理的范围收到主目录和用户临时目录两处;钥匙串、iCloud Drive、邮件、信息、照片图库、~/.ssh 整个受保护,文稿、桌面这类文件夹本身不能清,里面的单个文件可以。第二层是临时目录本身:只清 7 天没改动、也没被任何进程打开的条目,socket 和管道一律跳过,而且不再默认勾选。修完之后,用这台 Mac 的真实数据只读扫了一遍,符合条件的临时文件是 0 个。

「没在用」要怎么证明
审查的第二条是「缓存」:整个 ~/Library/Caches 一起挪,11.5 GB,默认勾选,不管对应的应用开没开着。浏览器、IDE、Electron 应用开着的时候被挪走缓存,轻的只是重建,重的会出异常。
Mole 在这一点上吃过真亏。它的 SECURITY_AUDIT.md(我看的是 49475f1d 这个版本)记着 issue #1390:删掉一个还开着的 SQLite 缓存之后,所属的后台进程一直往已经删掉的文件里写,直到把磁盘写满,出事的是 Autodesk Fusion 的后台助手。所以 Mole 判断属主进程时分三种结果:在运行,不删;判断不了,也不删;读不出进程表,从来不当成空闲。
Cleanzo 照这个思路判断「正在使用」:被进程打开着的文件(用 lsof 查),以及以运行中应用的 bundle ID 命名的缓存目录(用 lsappinfo 查),一律跳过,计为使用中。探测本身失败,就把所有条目都当成在用,不盲删。这条规则不只用在垃圾清理上,应用缓存视图、相似照片,以及全盘扫描、大文件、残留里的每一个「移到废纸篓」按钮,都先过它。
用真实数据重扫,「应用缓存」从 11.5 GB 变成 1.7 GB。差出来的部分里,约 2.2 GB 是重复计算:go-build、pip、Homebrew、pnpm 的缓存就在 ~/Library/Caches 下面,又各自单独算了一遍,所以首页顶上那个「立即清理 29.6 GB」本来就偏高;约 7.7 GB 属于正在运行的应用,WorkBuddy 2.3 GB,Chrome 1.4 GB,Trae 的更新缓存 1.4 GB,GitHub Desktop 0.7 GB,退出这些应用再扫一次,就可以清。
进了废纸篓,不等于能恢复
0.1.0 的清理是把所有东西 rename 进 ~/.Trash。听起来稳妥,问题有好几层。
用户点了「立即清理」,磁盘空间却没变,东西只是换了个目录;npm 缓存是几十万个小文件,进了废纸篓,清空的时候会很慢。直接 rename 不记录原来的位置,访达里的「放回原处」用不了;跨磁盘的文件会失败;同名的文件可能互相覆盖,原来那个函数在某些情况下还会死循环。
另一层更隐蔽。没有「完全磁盘访问权限」的程序读不了 ~/.Trash,列目录会被拒绝,往里移文件却可以。扫描时这个错误被当成了 0 字节,于是「废纸篓」一项永远显示「很干净」,清空它也会失败。Mole 的 README 里有一句说的就是这类情况:一个标着 unavailable 的 0,并不表示目录是空的。
还有一个顺序问题:同一次清理里,如果先往废纸篓移文件、再清空废纸篓,刚移进去、说好「可恢复」的那些文件会被一起清掉。
现在分成两种处理。内置分类里的垃圾直接删除,空间立刻释放;自定义目录、照片、大文件、残留和卸载这些用户自己的文件,走系统的「移到废纸篓」接口(NSFileManager 的 trashItemAtURL,MyGo 包成了 Shell.TrashItem),能放回原处。同一次清理先清空废纸篓,再往里移文件。确认框把「永久删除」和「移到废纸篓」分成两部分列出;读不了废纸篓时,显示「需要完全磁盘访问权限」和一个去授权的按钮;废纸篓这一项本身默认不勾,因为清空之后无法恢复。Mole 的分工也是这样:mo clean 清理垃圾时默认连废纸篓一起清空,只有在 mo analyze 里人手动选中的东西,确认之后才移进废纸篓。

审查列出的其余问题里,还有两处会真的弄坏东西。一处是相似照片:扫描会钻进照片图库,把图库里的原图和预览移进废纸篓,会把图库弄坏;现在扫描跳过照片图库、iPhoto 图库、应用、虚拟机、磁盘映像这类「包」目录。另一处是卸载:可以借一个构造出来的 bundle ID,用 .. 指到主目录里的任意文件夹;现在只卸载「应用程序」文件夹里的应用,bundle ID 里不能带 ..,应用本体移不动时,它的数据也不动。
九个分类清不到真正占地方的东西
前面这些修的都是「别删错」。0.1.2 发出去之后,我回头看首页那 9 个预设分类:应用缓存、日志、临时文件、废纸篓、npm/pnpm、Go、pip、Homebrew、Xcode。我的判断是:
我觉得应该改,9个分类这种不合理,也无法真的清理到需要清理的内容。你可以再去调研下竞品,然后再出方案
只读统计的结果也是这样。预设覆盖的开发缓存约 17 GB,却漏了约 22 GB:~/.cache/uv 8.1 GB,~/.npm/_npx 7.7 GB,~/.bun/install/cache 2.1 GB,pnpm store 1.9 GB,~/.cargo/registry 1.4 GB,~/.cache/puppeteer 1.0 GB;Xcode 那一行在这台机器上是空的。很多应用的缓存也根本不在 ~/Library/Caches:Electron 和 Chromium 系的应用把缓存放在 ~/Library/Application Support/<应用>/ 下的 Cache、Code Cache、GPUCache 里,Trae CN 5.2 GB,Claude 705 MB,语雀 543 MB,原来完全扫不到;647 个沙盒应用的缓存在 ~/Library/Containers/<id>/Data/Library/Caches,也扫不到。「应用缓存」本身又是一个 9.8 GB 的大桶,旧版本的更新包、浏览器缓存、各家应用的缓存混在一起,只能整桶删。
几家竞品放在一起看,共同点很清楚:一张很大的已知位置规则表,分组和条目两级;只显示这台机器上存在的;只默认勾选能重新生成的;应用缓存按应用逐个列;工具自己能清的,交给工具。柠檬清理的规则就是 XML 文件,每个条目有「推荐」与否的标记,跟某个应用绑定的条目带着它的 bundle ID;Mole 光开发工具就有一百多条规则,有官方清理命令就用官方命令,工具正在运行就跳过,另有一份硬保护名单。
0.1.3 把九个分类换成了规则表。规则写在 Go 代码里,是一张数据表,每一条有这些字段:稳定的 ID;所属分组;路径模式和作用方式,清目录里的条目、清路径本身,或者只清名字匹配某个模式的条目;所属应用的 bundle ID,没装、也没数据时不显示,应用在运行时整项跳过;是否默认勾选;直接删除还是进废纸篓;多久没改动才算,临时文件 7 天,旧安装包 30 天;以及可选的、工具自带的清理命令。
工具自己管的数据交给工具:brew cleanup --prune=30、pnpm store prune、go clean -modcache、xcrun simctl delete unavailable。这些命令报的是估算或上限,工具没装、正忙、失败或超时,都不挡别的条目。重新下载代价大的缓存默认不勾,比如 Playwright、Puppeteer、Go 模块、Gradle、Maven;Spotify、网易云音乐、QQ 音乐的缓存也默认不勾。一条具体规则认领了的路径,不再计入通用的应用缓存;用户忽略一条规则、或者关掉一个分组,它的路径仍然被占着,不会被另一项顺手清走。

lsof 只能证明此刻
从开始写到 0.1.3,不到 30 个小时,发了四个版本:0.1.0 在 10 月 11 日凌晨 1 点半,0.1.1 在凌晨 3 点 55 分,0.1.2 在上午 11 点半,0.1.3 在晚上 6 点 20 分,之后开源。第一轮修复新增了 17 个 Go 测试,都是先写测试、看着它失败再实现;又故意把 17 个关键行为逐个改坏,每一个都被测试抓到了。
这些测试证明的是规则在按设计执行,证明不了规则本身是对的。lsof 能证明的,只是此刻有没有进程打开着这个文件;lsappinfo 能证明的,只是此刻有没有一个对应的应用在运行。一个现在没在运行的应用,下次启动会不会因为缓存没了而重新下载几个 GB,扫描时查不出来,只能靠规则表里那一行「默认不勾」,和界面上写出来的代价。npm 缓存在这台机器上有 13 GB,删掉之后下次装依赖要全部重新下载,这句话现在就写在它旁边。
Keep Reading