SwiftmacOSmTinker

谁把我的 Mac 顶住不睡——从一个错的「保持唤醒」到一张责任方清单

「保持唤醒」原本只持有一个电源断言,于是屏幕照样熄灭,与它的名字对不上。修它的过程顺带把整个电源断言表读明白了:AssertionTrueType 和 AssertType 不是一回事、音频断言由 coreaudiod 代持而责任方另有其人、守护进程要按可执行文件路径认而不是按名字、带不带 TimeoutSeconds 决定这条断言会不会自己停。最后做成 mTinker 1.7.0 的「谁在阻止休眠」。

Mac 到点不睡,风扇整晚转,第二天早上电量见底。想知道是谁干的,系统没有给任何界面——只有 pmset -g assertions,一屏机制术语。

mTinker 1.7.0 加了「谁在阻止休眠」,把这张表变成一句人话加一个按钮。这篇讲它怎么读出来的,以及做它之前先修掉的一个自己的 bug。

先修自己的错

mTinker 早就有「保持唤醒」。它的实现是持有一个 IOKit 电源断言:

IOPMAssertionCreateWithName(
    kIOPMAssertionTypePreventUserIdleSystemSleep as CFString,
    IOPMAssertionLevel(kIOPMAssertionLevelOn),
    "mTinker 保持 Mac 运行" as CFString,
    &assertionID
)

PreventUserIdleSystemSleep 的语义是「系统不因空闲进入休眠」,它不管屏幕。屏幕仍按系统设置该熄就熄。

当时这是刻意的:mTinker 另有一个「关闭屏幕」功能,想要「机器跑着、屏幕黑着」这个常驻场景。用 PreventUserIdleDisplaySleep 会把屏幕强行点亮,和「关闭屏幕」打架。

但「保持唤醒」这四个字,用户读到的是屏幕也别睡。功能和名字对不上,这就是个 bug——哪怕代码里写着理由。

修法是两个断言分开持有:

private var systemAssertionID: IOPMAssertionID = 0
private var displayAssertionID: IOPMAssertionID = 0

/// 屏幕断言是否因「关闭屏幕」被临时让路——唤醒后据此决定要不要补回。
private var displayAssertionSuspended = false

打开时两个都拿。PreventUserIdleDisplaySleep 拿不到不算失败——系统断言已经成立,至少机器不会休眠,降级比整个功能失效好。

「关闭屏幕」时临时释放屏幕断言给它让路,系统断言保留,于是「机器跑着、屏幕黑着」依然成立。屏幕被唤醒后再把屏幕断言补回来:

NSWorkspace.shared.notificationCenter.addObserver(
    forName: NSWorkspace.screensDidWakeNotification, ...
) { _ in
    Task { @MainActor in
        KeepAwakeManager.shared.resumeDisplayAssertionIfNeeded()
    }
}

两个功能不再互斥,「关闭屏幕」造成的降级只持续到下次唤醒。

断言名必须是 ASCII

顺带发现的一条:原来的断言名是 "mTinker 保持 Mac 运行",带中文。而 pmset -g assertions非 ASCII 的断言名显示为空串

也就是说,当用户拿系统工具排查「谁把我的 Mac 顶住不睡」时,mTinker 自己是一条匿名的断言。改成:

private static let systemAssertionName = "mTinker Keep Awake (system)"
private static let displayAssertionName = "mTinker Keep Awake (display)"

你的程序会出现在别人的排查视野里。留个能读的名字是基本礼貌——尤其当你正要做一个专门列出别人名字的功能。

读电源断言表

数据源是 IOPMCopyAssertionsByProcess(),powerd 维护的断言表,和 pmset -g assertions 同源。用它而不是 fork 一个 pmset 去解析文本:没有子进程,没有文本格式的兼容负担。

var out: Unmanaged<CFDictionary>?
guard IOPMCopyAssertionsByProcess(&out) == kIOReturnSuccess,
      let byPID = out?.takeRetainedValue() as? [AnyHashable: Any] else { return [] }

返回的是「pid → 该进程的断言数组」。读它的过程里有四个坑。

一、类型字段有两个,要读生效的那个

// AssertType 是申报类型,AssertionTrueType 是实际生效类型(旧名会被归一化)。
let type = (entry[Key.trueType] as? String)
    ?? (entry[kIOPMAssertionTypeKey] as? String)

AssertType 是进程申报的类型,AssertionTrueType 是 powerd 归一化之后实际生效的类型。旧名会在这一步被折叠——比如 NoIdleSleepAssertion 这种老写法。只读申报类型会漏掉用老 API 的进程。

要匹配的类型集合也得把旧名算进去:

private static let systemSleepTypes: Set<String> = [
    kIOPMAssertionTypePreventUserIdleSystemSleep,
    kIOPMAssertionTypePreventSystemSleep,
    kIOPMAssertionTypeNoIdleSleep
]

private static let displaySleepTypes: Set<String> = [
    kIOPMAssertionTypePreventUserIdleDisplaySleep,
    kIOPMAssertionTypeNoDisplaySleep,
    "InternalPreventDisplaySleep"
]

最后那个 InternalPreventDisplaySleep 没有公开常量,是 powerd 延迟关屏时用的内部代理断言。字符串字面量写在这里不好看,但漏掉它就会有一批「屏幕不熄」的情况解释不出来。

不在这两个集合里的断言一律跳过。UserIsActive(用户刚碰过键鼠)必须跳——它不是「某个程序在阻止」,留着就是每次都有一条噪音。

二、持有者不等于责任方

在 Mac 上放一段音频,顶住休眠的断言不在播放器名下,而在 coreaudiod 名下。系统音频服务代表用音频的 App 持有断言。

如果照 pid 直接归属,用户看到的是「coreaudiod 正在阻止休眠」——一个他既不认识也不该去结束的进程。

断言条目里有 AssertionOnBehalfOfPID 指向真正的责任方:

var owner = raw.pid
var via: String? = nil
if let behalf = raw.onBehalfOfPID,
   behalf > 0,
   behalf != raw.pid,
   NSRunningApplication(processIdentifier: behalf) != nil {
    owner = behalf
    via = raw.processName   // 持有者退为「代持者」
}

于是那一行显示的是播放器的名字,并注明「由 coreaudiod 代持」。责任方和持有者都说清楚,用户才不会困惑于「我明明在放音乐,列表里怎么是个陌生进程」。

三个前置条件都必要:behalf > 0 排掉无效值,behalf != raw.pid 排掉自指,而 NSRunningApplication(processIdentifier:) 非空是确认那个 pid 现在还活着——断言比进程活得久的情况是有的,把责任推给一个已经退出的 pid 只会显示一行空名字。

三、守护进程要按路径认,不能按名字认

列表里必须区分三类:GUI App(可以请求它退出)、用户的命令行进程(可以发信号)、系统守护进程(只展示,不给任何按钮)。

第一版想法是维护一份进程名黑名单。它不成立,反例是 sharingd(隔空投送 / 接力):它跑在当前用户的 uid 下,名字也不带任何系统特征。靠 uid 认不出来,靠名字要穷举。

改成按可执行文件路径判断:

private static let daemonPathPrefixes = [
    "/System/", "/usr/libexec/", "/usr/sbin/", "/sbin/", "/Library/Apple/"
]

这里有一个刻意的排除:/usr/bin 不在列表里caffeinatersyncffmpeg 都住在那儿,而它们恰恰是最常见的元凶,还都是用户自己跑起来的——给它们一个「结束」按钮完全合理。把 /usr/bin 当系统目录会把最该处理的一类变成只能看。

四、带不带 TimeoutSeconds,是两种完全不同的东西

有的断言到点自动解除,有的一直挂着。这个区别决定用户要不要出手,但表里没有一个「会不会自己停」的字段,得自己算:

var expiresAt: Date?
if let timeout = (entry[Key.timeoutSeconds] as? NSNumber)?.doubleValue, timeout > 0 {
    let base = (entry[Key.timeoutUpdate] as? Date) ?? since
    expiresAt = base.addingTimeInterval(timeout)
}

基准时间要用 AssertionTimeoutUpdateTime 而不是创建时间——超时可以被续期,拿创建时间算会得出一个早就过去的解除时刻。

聚合到进程这一层时,语义取最坏情况:

// 只要有一条不带超时,这个进程整体就是「不会自己停」。
if raw.expiresAt == nil {
    agg.neverExpires = true
} else if let e = raw.expiresAt {
    agg.latestExpiry = max(agg.latestExpiry ?? e, e)
}

一个进程持有五条断言,四条两分钟后解除、一条无限期,那么这个进程就是「一直挡着」。取最长超时或者取平均都会给出一个会过期的假象。

不会自己停的排在前面。会自动解除的显示「约 4 分钟后解除」,而且不足一分钟就说「即将解除」——否则会出现「约 0 分钟后解除」。

用后果说话,不用机制说话

技术上这是一张断言表,每条有类型、有责任方、有超时。但用户要的答案是一句话:现在会发生什么,我该动手吗。

所以分组不按断言类型,按后果:

  • 「屏幕不会熄灭」
  • 「Mac 不会自动休眠」

折叠状态下只给一行摘要,而且优先报那个数字:

var summary: String {
    guard totalCount > 0 else { return loc("没有程序在阻止,Mac 会正常入睡") }
    let base = displayBlockers.isEmpty ? loc("Mac 不会自动休眠") : loc("屏幕不会熄灭")
    let pending = needsAttentionCount
    return pending > 0 ? loc("%@ · %d 项要你处理", base, pending) : loc("%@ · 都会自动解除", base)
}

「都会自动解除」和「2 项要你处理」是两种完全不同的心情。摘要如果只说「4 个程序正在阻止休眠」,用户还得展开去逐条看哪条要管。

无界面进程再给一句人话。caffeinate 这个名字对绝大多数人毫无意义:

private static let processNotes: [String: String] = [
    "caffeinate": "命令行防休眠工具",
    "sshd": "有人通过 SSH 连着这台机器",
    "rsync": "正在同步文件",
    "screencapture": "正在录屏",
    ...
]

这张表刻意只收含义确定的。猜错一句解释比不解释更糟。

还有一条:mTinker 自己的「保持唤醒」开着时,它如实出现在列表里,归为 selfApp 类。做一个列举别人的功能,把自己藏起来是最廉价的不诚实。

结束的两种方式

「结束」按钮只给 GUI App 和用户的命令行进程,而两者的正确做法不同:

case .app:
    // 走 terminate():正常退出流程,给它保存的机会
    show(app.terminate() ? loc("已请求「%@」退出", ...) : loc("「%@」拒绝了退出请求", ...))
case .userProcess:
    // 命令行进程没有这套流程,发 SIGTERM
    if kill(blocker.pid, SIGTERM) == 0 { ... }
    else if errno == EPERM { show(loc("无法结束「%@」:权限不足", ...)) }
    else { show(loc("进程「%@」已不存在", ...)) }

NSRunningApplication.terminate() 走的是正常退出流程,App 有机会存盘、也有机会拒绝——所以返回值要如实反馈,不能报「已结束」。命令行进程没有这套协商机制,SIGTERM 是它能得到的最温和的通知。

kill 失败要分两种:EPERM 是权限不足(这个进程不归你),其他基本就是进程已经不在了。合并成一句「结束失败」会让用户以为是 mTinker 出了问题。

采样只在面板可见期间跑,3 秒一次,关闭即停。一个诊断工具本身成为耗电源头是很蠢的。

三条可迁移的

读系统表要读「生效值」而不是「申报值」。 AssertTypeAssertionTrueType 并存不是冗余,后者是归一化的结果。同类设计在很多系统 API 里都有,只读前者会漏掉用老接口的调用方。

身份判断用路径,不用名字。 名字是给人看的,可以重复、可以伪装、可以恰好撞上。sharingd 用当前用户 uid 跑着一个系统守护进程这件事,只有它的安装位置能说明。

聚合的时候想清楚要哪个语义。 多条超时合成一个「会不会自己停」,答案是取最坏而不是取最长——因为用户问的是「我能不管它吗」,而只要有一条不会停,答案就是不能。

顺带一条给所有会持有系统资源的程序:给你的断言、锁、文件句柄留一个 ASCII 的可读名字。 别人排查问题时会看到它。mTinker 自己就当了很久的匿名嫌疑人。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。