Clamshell

合盖之后,
为什么它还醒着

一台 MacBook 合上盖子就该睡。想让它别睡,整个系统只有一个开关——sudo pmset -a disablesleep 1。 Clamshell 把这个开关搬进菜单栏,并且补上了它唯一真正危险的漏洞:你会忘了关。

版本
1.0
平台
macOS 13+
构建
Swift 6 · SwiftUI
依赖
无
许可
MIT
仓库
wang-junjian/clamshell
01 —— 问题

合盖不是"闲着",所以拦不住

大部分人第一次尝试解决这个问题,用的都是 caffeinate:

# 常见的三种写法,全部无效
caffeinate -i make          # 防止空闲睡眠
caffeinate -d make          # 连显示器一起防
caffeinate -s make          # 接电源时防止系统睡眠

合上盖子,这些进程照样被冻住。原因不是命令写错了,而是它们拦的东西不对。

系统有两个互相独立的睡眠入口。一个是"闲着太久":计时器到点,系统问一遍所有进程「谁还需要我醒着」,此时任何一方持有的 IOPMAssertion 断言都能投一张否决票,caffeinate 干的就是这件事。 另一个是"盖子合上了":转轴传感器把信号交给 SMC,走的是硬件路径,这条路上没有任何用户态投票环节。

入口 A · 系统空闲 空闲计时器到点 入口 B · 合盖 转轴传感器 → SMC IOPMAssertion 投票 有断言 → 不睡 这个环节被整段跳过 断言在这里没有投票权 SleepDisabled = 1 由 pmset -a disablesleep 写入 · 唯一能拦住入口 B 的闸 = 1 → 不睡,继续跑 = 0 → clamshell sleep
两条路径最终都要经过 SleepDisabled。区别在于:入口 A 在中途多一道可被投票的闸门,入口 B 直接穿过去。 这就是为什么 caffeinate 对合盖无效,而 disablesleep 有效——它站在两条路的下游。

所以这个问题的解法不在"如何让进程别被冻住",而在"如何关掉那个总闸"。整个 macOS 只暴露了一个入口:pmset 的 disablesleep 设置项。

这个开关的三个属性

全局——它不属于任何进程,改的是系统级设置,App 退出后依然生效。

持久——重启之后仍然是 1,不会自动恢复。它不跟着任何进程的生命周期走。

需要 root——写入要管理员权限,这就是所有麻烦的起点。

顺带说清楚一件事:合盖睡眠不会杀死进程。它把进程冻结在原地,CPU 时间暂停、内存继续供电, 开盖后解冻继续跑。如果你的需求只是"回来时程序还在、不用重启",其实什么都不用做。 真正需要 disablesleep 的,是"任务必须在我离开的这几个小时里持续推进"——下载、编译、训练、渲染。

02 —— 动机

一条命令的两个坑,第二个更危险

既然只要一行命令,为什么还要写一个 App?因为这条命令在实际使用中有两个坑,而且它们严重程度不对等。

坑一

记不住

sudo pmset -a disablesleep 1 里有三个词都是低频词。不常用的人每次都要去搜,搜到了还要确认拼写。 这是摩擦,但只是摩擦——多花两分钟而已。

坑二 · 更麻烦

忘了关

合盖不睡意味着出风口被挡住、机器还在满载。放进包里它会持续发热;靠电池跑,电量耗尽会直接关机, 进行中的任务一起没。这是数据丢失级别的后果。

这个不对称决定了产品该做什么。如果只是"记不住",一个 Shell 别名或一段 AppleScript 就够了,做成 App 是过度设计。 真正值得做成 App 的理由是:需要有一个东西替你记着"现在还开着"。

所以 Clamshell 的定位不是"更短的命令",而是一个不会让你忘了关的开关。 功能清单里的低电量保护、退出前询问、未接电源警告,全部服务于这一件事。

03 —— 形态

菜单栏,不是窗口

它是一个开关,不是一个"应用"。所以没有 Dock 图标、没有主窗口、没有欢迎页,点开只有一张 320pt 宽的面板。

Info.plist 里 LSUIElement 设为 true,进程就不会出现在 Dock 和 Cmd-Tab 里; main.swift 里再显式设置 setActivationPolicy(.accessory)。 这两步有先后顺序要求——激活策略必须在 app.run() 之前设定,事后修改不会生效。 这是开发过程中真实踩到的坑:窗口迟迟不出现,原因就是策略设晚了。

Clamshell 已关闭 合盖进入睡眠 点按即可启用 电池 100% · 已接电源 低电量保护 ⌘ 全局快捷键 ⌃⌥L ⌄ v1.0 退出 状态点 + 状态词 从 pmset -g 读回 整卡 = 点击区域 胶囊是绘制出来的 不是 Toggle 控件 供电状态 IOKit.ps 实时读取 兜底开关 阈值固定 20% Carbon 热键 无需辅助功能授权 退出前拦截 会先问要不要还原
面板按 2× 重绘。高度由 SwiftUI 的固有尺寸驱动,不写死——sizingOptions = [.preferredContentSize] 让系统自己算出 245pt 或 280pt(含警告行时)。

面板里的开关不是 Toggle 控件,而是一个用 Capsule 加 Circle 画出来的形状,用手势挂在整个卡片上。原因很实际: 这个开关的点击目标应该是整张卡(包含图标、标题、副标题),而不是右上角那个 36×21pt 的小胶囊。 原生 Toggle 做不到这件事,硬套反而要把点击区域拆散。

配色也有一处刻意的选择:开启态用琥珀,不用绿色。绿色传达的是"一切正常",而"合盖还在满载"恰恰是需要留意的状态。 给它一个像警示灯的琥珀色,动画里也只有一个颜色从 20% 到 100% 的明度变化。

04 —— 结构

四层,权限只在一层上出问题

整个 App 的作用就是"把用户的意图翻译成一次 pmset 调用",但中间要穿过权限边界。 拆开看是四层,只有最底两层跟 root 有关。

UI 层 NSStatusItem · NSPopover · PanelView SwiftUI 声明式界面,320pt 固定宽 无需权限 状态层 AppModel · @MainActor 状态机 · 30s 轮询 · 兜底判定 · 持久化偏好 无需权限 执行层 PowerService · BatteryService · SudoersService 封装全部进程调用,同步执行,由调用方调度线程 视命令而定 权限层 /etc/sudoers.d/clamshell 两条精确匹配的 NOPASSWD 规则,仅此而已 写入需 root 系统层 pmset · IOKit.ps · SMC macOS 本体
四层之间没有循环依赖,也没有观察者回写:下层永远不通知上层,上层靠重新读取来同步。这个方向性是后面所有状态一致性保证的来源。

这里有个刻意的设计约束:下层不做任何缓存。状态层要什么,每次都往下问一遍。 代价是每次刷新要 fork 一个 pmset 进程(几十毫秒),收益是永远不可能出现"界面说开着、系统其实在睡"。

05 —— 机制

三条流水线

这个 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 或别的什么。

App osascript 系统密码框 securityd root shell do shell script "…" with admin privileges 请求提权 用户输入密码 · 或取消 以 root 执行整段脚本 install -m 0440 visudo -c -f <规则文件> 校验失败 → rm -f 回滚 此后 sudo -n 直接成功,切换零弹窗
三个动作串在同一条 shell 脚本里执行,用 && 和 || 连成原子操作。 完整命令是 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 而不是错误——取消不是失败。

为什么不用特权 helper

苹果官方的现代做法是 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 切换:先真跑一遍,被拒了才授权

这是全项目最重要的一个流程决策。直觉做法是"先检查有没有权限,没有就授权,有就直接切换", 但代码选择了相反的顺序:直接尝试切换,只有真的被拒了才去要权限。

点击卡片 target = !sleepDisabled sudo -n pmset -a disablesleep target 返回? 成功 → refresh() 不信任返回值,重新从系统读一遍 needsAuthorization 弹密码框 → 装规则 → 重试一次 其他错误 面板底部显示提示,3.2 秒后消失
注意左侧那条分支:成功后不直接把内存里的值改掉,而是调用 refresh() 重新执行 pmset -g。 一次成功写入不代表下一次读到的就是它——中间可能有人改过。

为什么先尝试?代码注释给了答案:"探测命令在无终端环境下的返回并不可靠,真实尝试才是唯一可信的信号"。 这句话是被实战验证过的——前面那个 sudo -n -l 的假阳性就是"探测不可信"的具体案例。

先尝试还有第二个好处:绝大多数点击都不需要经过探测。授权装好之后,每次切换只是一次 sudo -n 调用,没有任何额外开销和判断分支。只有在新机器或规则被删的情况下,才会落到授权分支。

06 —— 兜底

三条保证,都针对"忘了关"

机制触发条件动作
低电量保护 电量 < 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 的进程里对话框可能弹在后台,用户根本看不见。

07 —— 决策

几个选型,以及被放弃的那一边

这个项目只有一千多行 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])之后才稳定。 这类"看起来能用、边界情况下崩掉"的实现,比明确的缺失更值得警惕。

08 —— 边界

它做不到什么

散热不是软件能解决的

合盖之后出风口被机身挡住,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。 后两个用于在无人值守环境下验证真实行为,不必手动点。