返回博客

合盖之后还能继续跑:MacBook 合盖不休眠怎么设

macossleeppmsetlaunchdcodesign

一句话结论:合盖和空闲是两条不同的休眠路径,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。

形状是这样:

  1. 在 App bundle 里放一个小的 helper 可执行文件。
  2. 运行时用 SMAppService.daemon 注册它。
  3. 用户在「系统设置 → 通用 → 登录项」里授权一次。
  4. 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 装。

常见问题

MacBook 可以合着盖子保持唤醒吗?

可以,但 `caffeinate` 做不到。合盖走的是另一条休眠路径,唯一管得着它的是 `SleepDisabled`,而这个设置只有 root 能改,还会活过重启。手动设用 `sudo pmset -a disablesleep 1`;或者用 App 在会话期间持有它,结束后再放回去。

caffeinate 能在合盖之后保持唤醒吗?

不能。`caffeinate` 拿的是电源断言,而电源断言只覆盖空闲休眠。`caffeinate -s` 能在交流电源下阻止系统休眠,合盖依然会睡。man page 对每个参数写了断言内容,没有一个提到盖子。

怎么查看我的 Mac 是不是禁止了休眠?

运行 `pmset -g`,看 `SleepDisabled` 那一行。`0` 是正常的合盖行为。`1` 表示合着盖子也会继续运行,而且这个设置会活过重启,直到有东西把它写回 `0`。

一直合盖运行对 MacBook 有影响吗?

合上盖子之后机器没有地方散热,而且它就放在你随手放的那个表面上。尽量放在硬质台面上、别开太久,不需要屏幕亮着时就用只阻止系统休眠的模式。

怎么撤销合盖不休眠?

运行 `sudo pmset -a disablesleep 0`。先用 `pmset -g` 看一眼:这个设置会活过设置它的进程,所以中途崩溃的 App 可能留下一台永远不睡的机器和一个不知道原因的主人。