一句话结论:合盖和空闲是两条不同的休眠路径,
caffeinate建的那些断言都够不到合盖。要让 MacBook 合着盖子继续跑,只能改SleepDisabled:只有 root 能改,而且会活过重启。这就是为什么一行命令做不到,也是为什么真正能做到的 App 都带一个特权 helper。
你想让一个下载、一次测试或者一次编译在你合上盖子走开之后跑完。盖子合上了,Mac 还是睡了,任务停在那里。
网上搜到的做法是跑 caffeinate。它不管用。原因值得弄清,因为搞懂它,你就知道那些真正管用的工具在做什么。
两种休眠,两套机制
macOS 让 Mac 睡眠的原因彼此无关。
空闲休眠:一段时间没有任何操作,系统就把自己关掉。应用程序靠持有电源断言(power assertion)阻止它,caffeinate 就是替你去拿这些断言。
合盖休眠:你把盖子合上了。这是硬件事件,走的是另一条路径,电源断言够不到。
man page 里每个参数写了什么很清楚:
| 命令 | 断言内容 | 管得着合盖吗 |
|---|---|---|
caffeinate -i |
系统不因空闲休眠 | 否 |
caffeinate -d |
显示器不因空闲休眠 | 否 |
caffeinate -m |
磁盘不因空闲休眠 | 否 |
caffeinate -s |
系统不休眠(仅交流电源) | 否 |
sudo pmset -a disablesleep 1 |
关掉合盖休眠 | 是 |
caffeinate 回答的是另一个问题。它回答「Mac 醒着但没人用它」。合盖问的是「盖子合上了」。
你能直接看到系统怎么判断
pmset -g 会打出当前电源状态,并列出正在持有它的进程。这是我正在打字的这台 Mac 的真实输出:
$ pmset -g
sleep 5 (sleep prevented by coreaudiod, caffeinate, powerd)
displaysleep 10 (display sleep prevented by Kipless)
SleepDisabled 0
两处值得看。括号里那几个名字是断言的持有者,当 Mac 莫名其妙不睡的时候,就是靠这一行找到原因。SleepDisabled 那一行管的是盖子。
SleepDisabled 是存下来的,不是拿着的
断言属于进程。进程退出,断言跟着消失。这个性质有用:崩掉的工具不可能让你的 Mac 永远睡不着。
SleepDisabled 反过来,它是被存下来的设置。
- 只有 root 能改。
- 会活过重启。今天设上,明天开机的 Mac 依然无视盖子。
- 设置它的进程死了,没有任何东西会去清掉它。
最后一条要紧。helper 把 SleepDisabled 设成 1,然后崩了,下次登录被卸载,留下一台永远不睡的机器和一个不知道原因的主人。写这个 helper 的人必须自己负责释放,系统不会替他负责。
手动做就是一行:
$ sudo pmset -a disablesleep 1
$ pmset -g | grep SleepDisabled
SleepDisabled 1
撤销就是把 1 换成 0:
$ sudo pmset -a disablesleep 0
App 要做的部分
App 不能在自己的进程里改这个设置,因为它不以 root 运行,也不该以 root 运行。它必须把事情交给一个有权限的东西,官方支持的做法是经由 SMAppService 注册一个 LaunchDaemon。
形状是这样:
- 在 App bundle 里放一个小的 helper 可执行文件。
- 运行时用
SMAppService.daemon注册它。 - 用户在「系统设置 → 通用 → 登录项」里授权一次。
- App 通过 XPC 跟这个 daemon 说话。
接口设计是成败所在。daemon 以 root 运行,它接受什么都等于 root 在跑什么。所以给它一张固定的操作清单,不要给它一个「执行命令」的口子。Kipless 的 daemon 只接受三件事:用 /usr/bin/pmset -g 报告当前状态,以及把 disablesleep 设成 1 或 0。从这个接口到任意 shell 之间没有路径。
lease 还有三件事必须做对,都是写错过的版本教我的:
- 别覆盖不是自己改的设置。 会话开始时
SleepDisabled已经是 1,说明这个设置不是这个会话改的,释放时就不能写 0。否则退出 App 会悄悄否掉别人的配置。 - 写完要读回来。
pmset失败时不一定给出有用的退出码,唯一能证明设置生效的方式是再读一次。 - 连接断了要释放。 XPC invalidation 是 daemon 得知 App 已经走了的那一刻。没人处理它,设置就留在打开状态且无人认领。
签名那个坑
这一段跟 pmset 没关系,但它花了我一整天。
daemon 注册好了,用户也授权了,launchd 一尝试启动它就死。/Library/Logs/DiagnosticReports/ 里的崩溃报告每次都是同一句:
signal = SIGKILL (Code Signature Invalid)
termination.indicator = "Launch Constraint Violation"
violation 的 namespace 是 CODESIGNING,指向签名而不是代码。我以为是 ad-hoc 签名的问题,猜错了。
答案在 launch constraint 本身:
$ launchctl print system/<label>
...
LWCR = { "reqs" => { "cdhash" => { "$in" => [ ] } } }
把这个要求逐字读一遍。系统在要求 daemon 的代码哈希属于一个空列表。没有任何二进制能满足它,换签名也没用。BTM 数据库里那条记录坏了,而它每次启动都会被重新套用一遍。
重新注册 daemon 确实会重算这个约束。那一次修好了,然后又一次失败,因为重算出来的要求钉住了一个已经跟二进制对不上的签名 identifier。我在两次尝试之间改了 identifier,系统已经记住了旧的那个。
最后站住的规则是:label、Mach service 名、plist 文件名、代码签名 identifier,四者必须是同一个字符串。
把 identifier 弄对本身还有一个坑。codesign 从内嵌的 Info.plist 决定 identifier。工具没有 Info.plist 时,它退回用文件名,取最后一个点之前的部分。我的 helper 叫 com.kaoru.kipless.lidsleep,于是被签成了 com.kaoru.kipless,正好是 App 自己的 bundle identifier。App 和它的 root daemon 在声称同一个身份。给 helper target 打开内嵌 Info.plist 并显式设置 PRODUCT_BUNDLE_IDENTIFIER 之后,这件事才结束。
还有两条约束,都是实测出来的:
- 可执行文件必须在
Contents/MacOS。 我原本放在Contents/Resources,launchd 连 exec 都做不到,约束检查根本没跑到。 - 必须是 Developer ID 签名。 launch constraint 里带着
validation-category = 6,ad-hoc 签名是另一档,过不去。Developer ID 本来就是分发所需,在这里它同时也是 daemon 在自己机器上能起来的条件。
如果你自己在查这个问题,launchctl print system/<label> 和 DiagnosticReports 里的崩溃报告比反复重装有用得多。把那个约束打出来,逐字读它的要求。
一直开着之前
合着盖子、没有外接显示器的 MacBook,热量没有地方去。屏幕是关的,风扇还是得让空气穿过一个合上的机身,而机器就放在你随手放的那个表面上。床上的一场合盖会话比桌上同样一场要糟。
走开之前看一眼状态:
$ pmset -g | grep SleepDisabled
SleepDisabled 1
回来之后再确认一次,尤其是你用的那个 App 已经不在了的时候。
Kipless
我做 Kipless 就是为了这件事。它在菜单栏里,提供三种唤醒模式(系统、显示器、合盖),会话按你设的时长结束。合盖模式先拿一个空闲休眠断言,再向 helper 申请 SleepDisabled 的 lease,任何一步失败都会把两步一起回滚。会话结束时设置回落到之前的值,包括原本就已经开着的情况。
MPL-2.0 开源,brew install --cask ShiinaLabs/apps/kipless 装。