起因:Mac 的磁盘又满了
做开发的 Mac 用不了多久,磁盘就会被各种缓存塞满:Xcode 的编译产物、模拟器、node_modules、uv / pip / Go 的包缓存、各种 Electron app 的缓存......每次清理都得手动翻目录,或者在网上搜「哪些东西可以删」。
GitHub 上有个很好用的开源命令行工具 Mole(作者 tw93),能分析磁盘、清理缓存、卸载 app、看系统状态。但我更想要一个图形界面的桌面 app:看得见磁盘被什么占满,清理前能逐项勾选确认。
于是有了 Vole。名字取自田鼠(vole),它是鼹鼠(mole)的近亲,同样爱挖洞翻土。

Vole 能做什么
- 分析:用矩形树图(treemap)展示磁盘占用,点进任意目录下钻,查看最大的文件。
- 清理:从 9 类里找缓存和残留:App 缓存、系统缓存、日志、开发者缓存、Xcode 与模拟器、AI 工具、浏览器、通讯工具、废纸篓。每一项都由你勾选确认后才删除。
- 软件:卸载 app,连同它的缓存、日志、偏好设置等残留一起清理,全部移到废纸篓,删错了能找回。
- 状态:实时的 CPU、内存、磁盘、网络、电池,还有进程列表。
- 菜单栏面板:关掉主窗口后常驻菜单栏,点一下田鼠图标就能看系统概况。



技术栈:纯 Go + mygo
Vole 全部用 Go 写,界面用的是 mygo:一个用 Go 写桌面 app 的框架。它有两种模式:一种是用系统 webview 加载网页前端(类似 Tauri);另一种是原生 UI 模式,不用 HTML、不用 webview,由 mygo 自己在 GPU 上把界面画出来,而且是纯 Go、不需要 cgo。Vole 用的是后者。
mygo 的原生 UI 是「即时模式」(immediate mode),和游戏界面常用的 Dear ImGui、egui 是同一类:界面就是一个以状态为输入的函数,每一帧重新构建一遍 。思路上很像 React 的 UI = f(state),只是没有虚拟 DOM。官方的计数器例子大概长这样:
go
type counter struct{ n int }
func (s *counter) view(c *ui.Context) {
ui.Column(c).Fill().Center().Gap(12).Children(func() {
ui.Text(c, fmt.Sprint(s.n)).FontSize(40).Bold()
if ui.PrimaryButton(c, "Increment").Clicked() {
s.n++ // 改状态,下一帧自动重画
}
})
}
布局用的是 flexbox(Row / Column / Grow / Gap),写过前端的话上手很快。不带浏览器内核,打包出来的 Vole.app 只有 22 MB,而且这里面还包括两个 Mole 的命令行工具。菜单栏图标、贴着图标弹出的面板、开机自启、dmg 打包、签名,mygo 都有现成的接口。
是怎么做出来的
1. 代码分层
bash
main.go → internal/app(装配)
internal/shell 窗口外壳、顶部 tab、菜单栏图标和面板
internal/features 每个 tab 一个包:analyze / clean / apps / status / menubar
internal/kit 主题、组件、treemap 布局算法、插画
internal/task 后台任务:跑完把结果送回主线程
internal/core 纯 Go 后端,不依赖任何 UI
cmd/vole-cli 命令行工具,不开界面也能验证后端
核心原则是后端和界面分开,依赖只能往下指 。core 完全不知道界面的存在,所以能单独写测试,也能用 vole-cli 在命令行里验证扫描结果。加一个新 tab,就是新写一个实现 Module 接口的包,再在模块列表里加一行。
2. 不卡界面:后台任务回主线程
桌面 app 的铁律是界面只能在主线程改。Vole 写了一个很小的 task 包:耗时操作放在 goroutine 里跑,跑完通过 mygo 的 win.Update 把结果排回主线程。每个任务带一个 key,同一个 key 再启动会自动取消旧任务、丢掉旧结果,这样快速连点几个目录,也不会出现旧结果覆盖新结果的情况。
3. 复用 Mole:以子进程调用
Mole 的「磁盘分析」和「系统状态」是两个独立的 Go 程序,支持输出 JSON。Vole 从源码把它们编译出来、打包进 app,以子进程方式调用:
go
cmd := exec.CommandContext(ctx, binary, "--json", path)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // 子进程自成一个进程组
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // 取消时杀掉整组
}
设置独立进程组,是因为 analyze 内部还会再起 du 等子进程,取消时要一起杀掉,不留孤儿进程。系统状态则是 status --watch 每秒输出一行 JSON,Vole 逐行读取、推给界面。状态 tab 和菜单栏面板共用同一个采集进程,没人看的时候就停掉,实测后台常驻时 CPU 是 0%。
4. 清理:怎么保证不删错
清理工具最怕删错。Mole 的清理是交互式 bash 脚本,没法被图形界面直接调用,所以 Vole 把它的规则读懂后用 Go 重写,并且把安全做得更严:
- 扫描只读,只产出清单;
- 只删用户勾选的项,确认框里写清楚删什么、怎么删;
- 每一项删之前再做四道检查 :
- 重新按规则扫一遍,确认它仍在候选里;
- 路径必须在家目录内、不是系统关键路径、上级目录没有经过符号链接;
- 用 dev 和 inode 核对它还是扫描时那个文件,防止中途被替换(TOCTOU);
- 重新检查相关 app 是否在运行,在运行就跳过。
- 会自动重建的缓存永久删除,用户自己的内容移到废纸篓 。废纸篓用
renameatx_np(RENAME_EXCL)改名进去,保证不会覆盖同名文件; - 装了 uv、go、npm 等工具时,优先调用工具自带的清理命令,因为工具最清楚哪些缓存还在用;
- 每一项的结果都写进操作日志。
规则本身写成数据而不是一堆 if,以后加规则基本就是加一条结构体。
5. 卸载 app
扫描 /Applications,读每个 app 的 Info.plist,再按 bundle id 去 ~/Library 下十几个固定位置找关联文件。缓存和日志默认勾选,个人数据(设置、聊天记录、虚拟机镜像等)默认不勾,避免误删。正在运行的 app 会先请它正常退出,等 5 秒,不行再强制结束,还退不掉就跳过。App Store 装的这类 root 拥有的 app,交给 Finder 去删,由系统自己弹管理员密码框,Vole 全程不碰 sudo。
用 AI 开发的一些体会
我本身不是 Go 背景,Vole 是和 AI(Claude Code)一起做出来的。几点体会:
- 先调研、再设计、最后写代码。每个大功能都先让 AI 读 Mole 的源码写调研文档,列出需要我拍板的问题,比如「缓存是永久删除还是进废纸篓」「个人数据默认勾不勾」,确认后才写代码。
- 人负责方向和把关:需求范围、关键取舍、真删真卸的验证、所有 git 提交和发布,都由我来做。
- 参考产品驱动 UI:截取参考 app 的界面,让 AI 读图整理成界面要点再照着实现;app 里的田鼠插画也是用 AI 出图的。
- 排查问题先读源码:开发中遇到菜单栏面板弹到屏幕左下角的问题,最后是读 mygo 源码定位的:面板只在创建时按图标位置定一次位置,而我的刘海屏 Mac 上图标一开始被挡住了。修法是每次弹出前触发一次重新定位。
- 复用好的开源项目能省很多事:Mole 的规则是社区验证过的,Vole 直接移植过来,再在安全上做加法。
安装和开源
- GitHub:github.com/hellozhangr... (GPL-3.0)
- 下载:在 Releases 下载 dmg,拖进「应用程序」。
- 目前只支持 Apple 芯片的 Mac(macOS 12 及以上),界面是中文。
- Vole 还没有经过 Apple 公证,第一次打开会被系统拦下:macOS 15 及以上到「系统设置 › 隐私与安全性」点「仍要打开」;macOS 14 及以下右键点 Vole 选「打开」。
- 建议授予「完全磁盘访问」,否则废纸篓、Safari 等目录无法统计和清理。