合盖不是"闲着",所以拦不住
大部分人第一次尝试解决这个问题,用的都是 caffeinate:
# 常见的三种写法,全部无效
caffeinate -i make # 防止空闲睡眠
caffeinate -d make # 连显示器一起防
caffeinate -s make # 接电源时防止系统睡眠
合上盖子,这些进程照样被冻住。原因不是命令写错了,而是它们拦的东西不对。
系统有两个互相独立的睡眠入口。一个是"闲着太久":计时器到点,系统问一遍所有进程「谁还需要我醒着」,此时任何一方持有的
IOPMAssertion 断言都能投一张否决票,caffeinate 干的就是这件事。
另一个是"盖子合上了":转轴传感器把信号交给 SMC,走的是硬件路径,这条路上没有任何用户态投票环节。
SleepDisabled。区别在于:入口 A 在中途多一道可被投票的闸门,入口 B 直接穿过去。
这就是为什么 caffeinate 对合盖无效,而 disablesleep 有效——它站在两条路的下游。
所以这个问题的解法不在"如何让进程别被冻住",而在"如何关掉那个总闸"。整个 macOS 只暴露了一个入口:pmset 的
disablesleep 设置项。
全局——它不属于任何进程,改的是系统级设置,App 退出后依然生效。
持久——重启之后仍然是 1,不会自动恢复。它不跟着任何进程的生命周期走。
需要 root——写入要管理员权限,这就是所有麻烦的起点。
顺带说清楚一件事:合盖睡眠不会杀死进程。它把进程冻结在原地,CPU 时间暂停、内存继续供电,
开盖后解冻继续跑。如果你的需求只是"回来时程序还在、不用重启",其实什么都不用做。
真正需要 disablesleep 的,是"任务必须在我离开的这几个小时里持续推进"——下载、编译、训练、渲染。
一条命令的两个坑,第二个更危险
既然只要一行命令,为什么还要写一个 App?因为这条命令在实际使用中有两个坑,而且它们严重程度不对等。
记不住
sudo pmset -a disablesleep 1 里有三个词都是低频词。不常用的人每次都要去搜,搜到了还要确认拼写。
这是摩擦,但只是摩擦——多花两分钟而已。
忘了关
合盖不睡意味着出风口被挡住、机器还在满载。放进包里它会持续发热;靠电池跑,电量耗尽会直接关机, 进行中的任务一起没。这是数据丢失级别的后果。
这个不对称决定了产品该做什么。如果只是"记不住",一个 Shell 别名或一段 AppleScript 就够了,做成 App 是过度设计。 真正值得做成 App 的理由是:需要有一个东西替你记着"现在还开着"。
所以 Clamshell 的定位不是"更短的命令",而是一个不会让你忘了关的开关。 功能清单里的低电量保护、退出前询问、未接电源警告,全部服务于这一件事。
菜单栏,不是窗口
它是一个开关,不是一个"应用"。所以没有 Dock 图标、没有主窗口、没有欢迎页,点开只有一张 320pt 宽的面板。
Info.plist 里 LSUIElement 设为 true,进程就不会出现在 Dock 和 Cmd-Tab 里;
main.swift 里再显式设置 setActivationPolicy(.accessory)。
这两步有先后顺序要求——激活策略必须在 app.run() 之前设定,事后修改不会生效。
这是开发过程中真实踩到的坑:窗口迟迟不出现,原因就是策略设晚了。
sizingOptions = [.preferredContentSize]
让系统自己算出 245pt 或 280pt(含警告行时)。
面板里的开关不是 Toggle 控件,而是一个用
Capsule 加 Circle 画出来的形状,用手势挂在整个卡片上。原因很实际:
这个开关的点击目标应该是整张卡(包含图标、标题、副标题),而不是右上角那个 36×21pt 的小胶囊。
原生 Toggle 做不到这件事,硬套反而要把点击区域拆散。
配色也有一处刻意的选择:开启态用琥珀,不用绿色。绿色传达的是"一切正常",而"合盖还在满载"恰恰是需要留意的状态。 给它一个像警示灯的琥珀色,动画里也只有一个颜色从 20% 到 100% 的明度变化。
四层,权限只在一层上出问题
整个 App 的作用就是"把用户的意图翻译成一次 pmset 调用",但中间要穿过权限边界。
拆开看是四层,只有最底两层跟 root 有关。
这里有个刻意的设计约束:下层不做任何缓存。状态层要什么,每次都往下问一遍。
代价是每次刷新要 fork 一个 pmset 进程(几十毫秒),收益是永远不可能出现"界面说开着、系统其实在睡"。
三条流水线
这个 App 真正干的事只有三件:读状态、写状态、以及为了写状态而获得权限。三件事对应三条完全不同的实现路径。
5.1 读:永远以 pmset -g 为准
读取不需要任何权限,只要解析 pmset -g 的输出。输出里有一行形如 SleepDisabled 1,
按空白切分后取最后一段判断即可。
static func readSleepDisabled() -> Bool {
let output = Shell.run(pmsetPath, ["-g"])
guard output.ok else { return false }
for line in output.stdout.split(separator: "\n") {
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
guard let first = parts.first, first.lowercased() == "sleepdisabled" else { continue }
return parts.last.map { $0 == "1" } ?? false
}
return false
}
注意两处细节。pmset -g 的输出不是稳定的键值格式,缩进用的是不定长空白,所以必须按空白切分而不是按冒号。
另外这是逐行扫描,遇到第一行匹配就返回——因为不同系统版本里这一行可能出现多次,取第一个就够了。
Sources/Clamshell/PowerService.swift:13
注释里写得很直白:"命令行、其他工具、甚至重启都可能改变它,本地缓存不可信"。
这不是过度谨慎——disablesleep 是持久化设置,它在 App 之外随时可能被改。
5.2 写:-n 是关键的那一个字母
写入走的是 sudo -n pmset -a disablesleep 0|1。-n 是 --non-interactive:
禁止提示输入密码。有免密规则就直接执行,没有就立刻失败退出,绝不挂在那里等输入。
这个字母是整套设计的支点。它让"缺少权限"变成一个可以立即捕获的错误,而不是一个卡住的进程。 App 因此可以先无条件尝试一次,失败了再决定要不要弹授权框——这就是 5.4 那个流程的前提。
失败时的错误文本会被分类:输出里含 password is required 或
a terminal is required 就归为 .needsAuthorization,
其他情况归为普通命令错误。这两种情况的 UI 处理完全不同:前者引导授权,后者直接报错。
Sources/Clamshell/PowerService.swift:28
5.3 授权:一条 shell 脚本,三个动作
授权只需要做一次。它写入 /etc/sudoers.d/clamshell,内容只有两行,精确到命令参数:
# 仅授权下面这一条命令的两个取值,可安全删除本文件以撤销授权
你的用户名 ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
注意它没有写成 /usr/bin/pmset 通配。授权范围是"这一个命令的这两个确切参数",
而不是"这一个命令"。用户没法借这条规则去执行 pmset sleepnow 或别的什么。
&& 和 || 连成原子操作。
完整命令是 install -m 0440 -o root -g wheel 写入、visudo -c -f 校验语法、失败则
rm -f 回滚。中间没有"文件已写入但内容有误"的窗口。
Sources/Clamshell/SudoersService.swift:50
这段脚本被包在一个 osascript 调用里执行,用的是
do shell script "…" with administrator privileges。这是 macOS 提供的标准提权通道,
会弹出系统原生的密码框,不需要自己实现任何 UI。用户取消时 AppleScript 返回 -128,被映射成
.cancelled 而不是错误——取消不是失败。
苹果官方的现代做法是 SMAppService 加 XPC 注册一个 root 守护进程。它确实是"更正确"的方案,
但它要求有效的 Developer ID 签名身份。这台机器上没有任何代码签名证书,ad-hoc 签名下注册成功率无法保证。
选了 sudoers 是因为它零签名依赖、内容可审计、一行命令即可撤销。代价是它动了系统配置文件——所以写入前后都做了校验和回滚。
一个不容易发现的假阳性
判断"规则是否已生效"看似简单:sudo -n -l <命令> 返回 0 就是有权限。实际上这个判断是错的。
管理员账户本身带有 (ALL) ALL 规则,所以即使 /etc/sudoers.d/ 是空的,
sudo -n -l 也会返回成功——它列出的是"你有权做什么",而不是"你现在不需要密码"。
这个假阳性在开发中真的出现过:诊断输出报告"免密已生效",但目标文件根本不存在。
// 错误写法:只看退出码
return Shell.run(sudoPath, ["-n", "-l", pmsetPath, "disablesleep"]).ok
// 正确写法:输出里必须同时出现 NOPASSWD 和目标命令
let text = Shell.run(sudoPath, ["-n", "-l"]).stdout
return text.contains("NOPASSWD") && text.contains("disablesleep")
5.4 切换:先真跑一遍,被拒了才授权
这是全项目最重要的一个流程决策。直觉做法是"先检查有没有权限,没有就授权,有就直接切换", 但代码选择了相反的顺序:直接尝试切换,只有真的被拒了才去要权限。
refresh() 重新执行 pmset -g。
一次成功写入不代表下一次读到的就是它——中间可能有人改过。
为什么先尝试?代码注释给了答案:"探测命令在无终端环境下的返回并不可靠,真实尝试才是唯一可信的信号"。
这句话是被实战验证过的——前面那个 sudo -n -l 的假阳性就是"探测不可信"的具体案例。
先尝试还有第二个好处:绝大多数点击都不需要经过探测。授权装好之后,每次切换只是一次
sudo -n 调用,没有任何额外开销和判断分支。只有在新机器或规则被删的情况下,才会落到授权分支。
三条保证,都针对"忘了关"
| 机制 | 触发条件 | 动作 |
|---|---|---|
| 低电量保护 | 电量 < 20% 且 未接电源 且 开关开启 且 该功能启用 |
自动写回 0,面板提示"电量低于 20%,已自动还原睡眠" |
| 未接电源警告 | 开关开启 且 未接电源 |
纯 UI:面板里多出一行琥珀色警示,不执行任何动作 |
| 退出前拦截 | 退出 App 时 系统仍为 1 |
三按钮对话框:还原并退出 / 保持现状退出 / 取消 |
第一条的判定写成一条 guard,五个条件用逗号连着,任一不满足就直接返回。这种写法比嵌套
if 更不容易漏条件,也让"什么情况下不触发"一眼可读:
guard lowBatteryGuard, // 用户开着这个功能
sleepDisabled, // 系统确实处于合盖不睡
battery.hasBattery, // 排除台式机
!battery.onAC, // 接着电源就没风险
battery.level < Self.lowBatteryThreshold // 20%
else { return }
有一个必须说清楚的细节:这个判定只在 refresh() 里跑,而 refresh() 由三个事件驱动——
30 秒定时器、打开面板、切换成功后。所以低电量保护的最坏响应延迟是 30 秒,不是即时。
对于"防止电池耗尽"这个目标,30 秒的窗口远远够用;但如果有人期待它是秒级的,那是误解。
第三条用的是 NSApplicationDelegate 的终止钩子,返回
.terminateNow 或 .terminateCancel。这里有个容易忽略的点:
在弹出 NSAlert 之前必须先把 App 激活(NSApp.activate()),
否则在 LSUIElement 的进程里对话框可能弹在后台,用户根本看不见。
几个选型,以及被放弃的那一边
这个项目只有一千多行 Swift,但每个技术选择背后都有一次明确的取舍。把被放弃的选项一并写下来,比只写结论有用。
| 决策点 | 选择 | 理由 | 代价 |
|---|---|---|---|
| 提权方式 | sudoers 白名单 | 零签名依赖,内容可审计,一行可撤销 | 修改系统配置文件;比 helper 方案安全性弱一个层级 |
| 全局快捷键 | Carbon RegisterEventHotKey |
不需要"辅助功能"授权,不监听事件流 | 用了一套老 API,Carbon 已停止演进 |
| 工程组织 | SwiftPM + 脚本组 bundle | 构建可复现,没有 pbxproj 合并冲突 | 要自己写 Info.plist、生成 icns、做 ad-hoc 签名 |
| 面板尺寸 | SwiftUI 固有尺寸驱动 | 不写死高度,增删行不会裁切 | 首次显示后要延迟 50ms 再校正一次 |
| 开启态配色 | 琥珀色,不用绿色 | "合盖满载"是需要留意的状态,绿色会给出错误的安心感 | 与系统"开关=绿色"的惯例不一致,需要用户重新学习 |
| 定时自动还原 | 不做 | 产品线保持干净,只留"不会忘"这一条主线 | 长任务仍需自己估时间 |
| 开机自启 | 不做 | SMAppService 在 ad-hoc 签名下注册成功率不保证 |
重启后要手动打开一次 |
最后两行值得单独说。它们不是"没做完",而是主动砍掉的。
开机自启这条尤其典型:技术上可以加一行 SMAppService.mainApp.register(),
在没有开发者证书的环境下它可能成功也可能静默失败。一个看起来存在、实际不生效的开关,比没有这个开关更糟——
用户会以为自己已经配好了,然后在下一次重启后失去这个功能却不自知。
同类判断还出现在开发过程的另一处:面板高度。最初用 view.fittingSize 手动计算,
结果在 popover 显示之前拿不到正确布局,面板被裁成 200×130。改成让 SwiftUI 自己上报固有尺寸
(hosting.sizingOptions = [.preferredContentSize])之后才稳定。
这类"看起来能用、边界情况下崩掉"的实现,比明确的缺失更值得警惕。
它做不到什么
散热不是软件能解决的
合盖之后出风口被机身挡住,14 寸机型长时间满载必然降频、发烫。这不是 Clamshell 的缺陷,是物理限制。 短时任务(几十分钟的下载、编译)没问题;几小时的重负载应该放在桌面上不合盖,或者迁到常开的机器上跑。
签名是 ad-hoc 的
没有 Apple 开发者证书,所以没有公证。首次打开可能被 Gatekeeper 拦下,需要在 Finder 里右键 → 打开,只需一次。 这也是为什么没做自动更新——更新机制同样依赖签名身份。
状态是全局的,不跟着 App 走
disablesleep 不属于任何进程。App 被强杀、崩溃、或者你直接 kill -9,
系统仍然停在"合盖不睡"。App 下一次启动会读回真实状态并如实显示,但这中间的空窗期它管不了。
这是这个开关本身的性质,任何封装都无法改变。
授权是持久的系统改动
/etc/sudoers.d/clamshell 会一直待在那里,直到你手动删除。规则本身只放行了一条命令的两个取值,
风险可控,但它确实是一个 App 在你系统里留下的、超出自身生命周期的痕迹。
撤销方式:sudo rm /etc/sudoers.d/clamshell。
构建与安装
git clone https://github.com/wang-junjian/clamshell.git
cd clamshell && ./build.sh
open dist/Clamshell.app
build.sh 做四件事:swift build -c release --arch arm64 编译、
从 Resources/ 拷 Info.plist、用脚本生成 10 个尺寸的 icns、再 codesign -s - 做 ad-hoc 签名。
整个 bundle 由脚本组装,没有 Xcode 工程文件。
需要本机已装 Xcode 命令行工具,没装会提示 xcode-select --install。
另外在受限环境(比如外层已有沙箱)里编译时,SwiftPM 的 manifest 编译会失败,
需要给 swift build 加 --disable-sandbox——这也是构建脚本里默认带上的参数。
自检模式
二进制支持三个开发期参数:--diagnose 打印状态/电池/授权探测结果,
--toggle-test 走一遍真实切换(设 1 → 读回 → 还原 0 → 读回),
--snapshot 把面板三态离屏渲染成 PNG。
后两个用于在无人值守环境下验证真实行为,不必手动点。