免费的 Mac 磁盘清理工具来了

为啥又写一个磁盘清理工具,因为没有好用的免费工具

今天上午 df 的读数:512G 的盘已用 400 多 G,又到了空间焦虑期。系统只会弹一句"存储空间不足",再给你一个总数,不告诉你是谁占的。想弄明白只能自己一层层翻:~/Library/Caches 里是各家 App 的缓存,Xcode 那边是 DerivedData 和 iOS Support Files,微信的数据在它的容器目录里,App Store 和系统更新的下载缓存又藏在 /System/Volumes/Data/System/Library/AssetsV2。这些地方一直都在,只是没有一个界面把它们一条条列出来。

现成的工具我也找过。免费的没找到合用的,收费的我舍不得年年交钱——删缓存这活我自己就能干,删错了从废纸篓里拿回来就行。于是自己写了一个:DiskWise,SwiftUI,开源在 https://github.com/DreamOfXM/diskwise。

真正难的是数字准不准、删错了能不能捞回来

扫得到。 系统卷是只读的,你的东西全在 /System/Volumes/Data 底下,Apple 自家的缓存在这条路径下还要多绕一层。少扫一处,界面上的总数就比系统设置里那个"已用"小一截。用的人不会想到是工具少扫了一层,只会觉得这工具算得不准。所以扫描范围只走当前环境允许的最大那一档,扫不到的部分写在屏幕上。

算得准。 APFS 是一个容器里装着好几个卷,而 statfs 对每个挂载点回的都是同一个空间池,挨个加起来必然超过盘的真实容量。df 反过来是少算:恢复卷平时根本不挂载,它的表里没这一项,用 df 拆出来的「启动与恢复分区」会少 1.3 GB。所以卷的账不能按这两个算,得去读 diskutil apfs list 的 plist,按角色拆开。拆完还是对不上的那一块,标成"没量到",不要记进总数。

动手那一半,要两个包并排跑才看得出差别。删的口子只有一处,动手范围由一张 21 条路径的名单限制着。

进了沙盒,少的不止几个功能

同一份源码出两个包,装在同一台机器上:从网页直接下的那个(下面叫直链版)和 App Store 里的那个(下面叫沙盒版)。一个能数出数据卷里 25 GB 的 App Store 缓存,另一个连自己的废纸篓都打不开。

本来以为两个包的差别无非是"少几个功能",真并排跑起来,才发现每一条都能追到函数上去。

扫描范围:少扫 6%,而这 6% 会显示成"没量到"

范围是个枚举,取值看当前进程在不在沙盒里:

public static var effective: ScanScope { HomeAccess.runsSandboxed ? .user : .disk }

沙盒版只能用 .user,也就是家目录加 /Applications。直链版是 .disk,在这之上还能读 /Library、/opt、/private、/usr/local、/System/Volumes/Data/System,以及 /Users 下别人的家目录。最后这一类只参与统计,删除路径由 isDeletable 限制在自己的家目录和 /Applications 之内。

/System/Volumes/Data/System 值得单独说一句。它路径长得像系统卷,其实是数据卷下面的一层,本机昨晚实测那一层装着 25 GB 的 AssetsV2,也就是 App Store 和系统更新攒下的下载缓存。不列它,一块用掉 400 多 G 的盘凭空少 6%。麻烦的地方在于这 6% 会掉进总览页"没量到的地方"那一格:本来是工具没扫到,界面上看着却像盘上真有一块说不清的东西。

界面上没有范围开关,这是刻意的。早先留过两档,同一屏里环形图按用户区画、下面的列表按整盘列,两个口径对不上,用的人只会以为工具算错了。现在只走当前环境能扫到的最大范围,边界写在屏幕上。

要读 ~/Library,得让用户亲手点一次

沙盒里想读 ~/Library,没有第二条路:弹 NSOpenPanel,让用户自己选中家目录,转成 security-scoped bookmark 存进偏好,之后每次启动再续权。

代价是产品形态被改了。没授权之前只有一屏授权页,侧边栏锁死,一个数字都不给。为什么不先扫着?因为沙盒里读一个没权限的路径不会报错,它会安静地返回一个空目录,界面于是显示"你的盘几乎是空的"。报错反而好办。

这里有个坑,花了我一些时间:NSOpenPanel 交回来的那个 URL,直接扔给 startAccessingSecurityScopedResource() 会返回 false。得先把 bookmark 写下去,再解析回来,用解析出来的那个 URL 去 start,系统才认。代码里 grant() 和 adopt() 的分工就是为了这件事。

NSHomeDirectory() 在沙盒里会撒谎

进沙盒以后,NSHomeDirectory() 和 FileManager.default.homeDirectoryForCurrentUser 都会被改写成 ~/Library/Containers/<bundle id>/Data。拿它当家目录,扫的是一层空壳,而且全程不报错。

真实的那个只能去问系统的口令数据库:

public func realHomeDir() -> URL {
    guard let pw = getpwuid(getuid()) else {
        return FileManager.default.homeDirectoryForCurrentUser
    }
    return URL(fileURLWithPath: String(cString: pw.pointee.pw_dir), isDirectory: true)
}

startAccessingSecurityScopedResource() 不能用来判断自己在不在沙盒里

这条最容易骗过人。它对不在沙盒里的进程永远返回 true。拿它当探测器,结论正好是反的。

沙盒判定只能自己写。DiskWise 用的是容器路径(HomeAccess.runsSandboxed),自检里有一条断言钉着它:check(!HomeAccess.runsSandboxed, "自检跑在非沙盒环境")。

权限给全了,沙盒版还是清不动废纸篓

清空废纸篓本来就该由访达做。直链版就是这么干的,发一条 Apple Event:

tell application "Finder" to empty the trash

沙盒版该拿的权限全拿了,按理该走同一条路。实测的结果相反:macOS 26.6 的沙盒里,这条事件被 appleeventsd 直接拒掉,系统日志记的是 deny appleevent-send com.apple.finder,授权框从头到尾没弹过。

所以沙盒版不能摆一颗点了永远没动静的按钮。判定就一行(TrashView.swift 的 canCommandFinder):

private var canCommandFinder: Bool { !HomeAccess.runsSandboxed }

直链版才发事件,失败之后按错误号分开处理(finishEmpty() 调 finderError(number:message:)):-1743/-1744 是自动化没放行,界面上就给一个直达「隐私与安全性 → 自动化」的入口;-128 是访达自己的确认框被点了取消,那就说清东西还在;-600/-609 是指令压根没送到访达,报成"访达拒绝执行"是错怪了它,所以单列一类。

沙盒版压根不发这条事件:那颗按钮改名「在访达中清空」,点了只把废纸篓所在的访达窗口打开,由用户按 ⌘⇧⌫,确认框是访达自己的(emptyInFinder())。

沙盒版读不到废纸篓

现象是同一段统计代码:直链版数出 65 项、368 MB;沙盒版调 contentsOfDirectory 直接抛错。原因是 macOS 的沙盒不让 App 读 ~/.Trash,实测把整个家目录的书签都给了,仍然进不去。

代码里因此把 trashInfo() 的返回类型写成可选值:读不到返回 nil,0 只留给"读到了、里面确实没东西"。界面上那句「这里读不到,以访达为准」(TrashView.swift:202)就是这么来的。要是把抛错吞成 0,屏幕上会写着废纸篓是空的,而清空按钮的禁用条件恰好是 info?.items == 0——Swift 里 nil 不等于 0,所以现在读不到的时候按钮还能点;真把错误吞成 0,这颗按钮就永久点不动了。

子进程只剩一条,而且是只读的

沙盒版跑得动的 Process 只有 /usr/sbin/diskutil apfs list -plist,只读,用来算 APFS 卷的账。另一个通用执行器在 ScanJobs.swift 的 runCmd(_:_:),Docker 那一页本来靠它调命令行,但 scanDocker() 一开头就判断了沙盒,这条路径整个不走。

于是两版看到的 Docker 不是一回事。沙盒版枚举 ~/Library/Containers/com.docker.docker 的一级子目录,只报总量;直链版才跑 docker info、docker system df、docker image ls,拿到镜像、容器、卷的分项。界面上会写明这个差别。

只有一条删除路径

全 App 只有一个地方真的移文件:Scanner.swift 的 trashItem(_:),里面就一句 FileManager.trashItem(at:resultingItemURL:)。界面上七个入口(缓存、大文件、很久没动、重复文件、卸载残留、node_modules/Docker、总览页)都走这一个函数,没有第二条。

两道闸门。isProtected(_:) 挡住家目录本体和 ~/Library 这种整体位置,子项可以动;isDeletable(_:) 把动手范围限制在自己的家目录和 /Applications。

撤销是一条内存里只追加的记录,从不落盘,所以退出即失效——这不是写了清理逻辑,而是压根没有持久化路径。放回去撞名就加后缀,不覆盖。

偏好里存的也全是外观和授权状态:皮肤、深浅色、解锁过的皮肤、界面语言,加上沙盒版那条书签。没有一项是用户数据,也都不出本机。

不联网、不上传,这句话是可以 grep 的

这类工具要看你整台机器上都有什么、各占多大,第一件该问的是:这些数会不会被传走。这句话不该听我说,代码里数得出来。

URLSession、NWConnection、import Network、CFNetwork、WebSocket、socket(、dataTask,全部零命中。仓库里出现的 http(s):// 都只是字符串,要打开链接是交给 NSWorkspace.shared.open 丢给系统浏览器的,App 自己不发请求。特权组件同样一个都没有:SMAppService、SMJobBless、privileged helper、XPC、launchd plist,零命中。

所以装完之后,你机器上这些占用数字只在这台机器上算,不会发出去。

这些 API 的返回值不能信

下面这三条都没法从文档里推出来,只能自己跑一遍:startAccessingSecurityScopedResource() 在非沙盒进程恒为 true,NSHomeDirectory() 在沙盒里被换成容器路径,清空废纸篓的授权给全了也照样被 appleeventsd 拒。

代码开源:https://github.com/DreamOfXM/diskwise。上面出现的符号名都可以对着仓库 grep。

最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容