深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

我的第一个 macOS 应用:不跳就锁——用心率广播实现人走锁屏

背景:人走必须锁屏,但我总会忘

🐶公司办公区有比较严格的安全规范:离开工位必须锁屏。道理都懂,可实际执行起来总是磕磕绊绊——去洗手间、接水、开会,经常起身就走了,屏幕还亮着。
clip_1784758448046_n4br1c.png
理想中的人走锁屏应该满足几个条件:

  • 无感:不需要我手动操作,也不需要我佩戴额外硬件。
  • 及时:人离开工位后几秒内就要锁屏。
  • 不误判:坐在电脑前正常工作时不应该突然锁屏。
  • 隐私:不要把我的生物特征上传到云端。

我手里正好有一块支持心率广播的智能手表,每天戴着它跑步和监测睡眠。既然它已经 24 小时戴在手上,能不能让它充当”人体存在传感器”?答案是可以。

核心思路:用心率广播 + 信号强度判断”人还在不在”

BLE(Bluetooth Low Energy)心率设备会广播标准心率服务 0x180D,其中的心率测量特征 0x2A37 每秒推送一次当前心率。只要手表在 Mac 的蓝牙覆盖范围内,Mac 就能收到稳定的心率数据。

当我的身体离开 Mac 一段距离后:

  1. 心率广播中断,Mac 超过一定时间收不到心率。
  2. 即使还在广播,信号强度 RSSI 也会显著下降。

于是判定逻辑很简单:

  • 心率超时:超过 10 秒没收到心率,认为设备失联,锁屏。
  • 弱信号:连续 3 秒 RSSI 低于 -75 dBm,认为人已经走远,锁屏。

这两种条件互为补充:前者应对手表被取下或蓝牙关闭,后者应对人走远但广播仍在的情况。

项目结构

我把应用拆成了三个部分:

HeartRateLockWidget/
├── HeartRateLock/ # macOS 状态栏后台 App
│ ├── HeartRateLockApp.swift # 应用入口、状态栏菜单、设置窗口
│ ├── BLEHeartRateManager.swift # CoreBluetooth 扫描/绑定/连接/心率订阅
│ └── LockScreenManager.swift # 触发 macOS 锁屏
├── HeartRateWidget/ # 桌面/侧边小组件
│ ├── HeartRateWidget.swift # 实时心率 Widget
│ ├── HeartRateCurveWidget.swift # 心率曲线 Widget
│ └── HeartRateWidgetBundle.swift # Widget 入口
└── HeartRateLockShared/ # App 与 Widget 共享代码
└── SharedConfig.swift # App Group、UserDefaults Key、设置项

HeartRateLock 是一个没有 Dock 图标的状态栏应用,启动后躲在菜单栏里持续扫描和监听心率。HeartRateWidget 是 WidgetKit 扩展,负责把心率展示在桌面或通知中心。

关键实现

1. 扫描与绑定设备

macOS 的 CoreBluetooth 扫描时不指定服务 UUID,这样可以列出周围所有 BLE 广播设备。用户从菜单栏的「选择设备」里按信号强度排序找到手表,点击完成绑定。绑定信息会持久化,下次启动自动重连。

private let heartRateServiceUUID = CBUUID(string: "0x180D")
private let heartRateMeasurementUUID = CBUUID(string: "0x2A37")

func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
// 收集设备名、RSSI,按信号强度排序展示
}

这里有一个坑:macOS 出于隐私保护,不会把 BLE 设备的 MAC 地址暴露给 App,只会给一个随机生成的 CBPeripheral.identifier。所以无法通过 MAC 地址过滤,只能让用户手动选择。另外很多手表在广播里不暴露名字,首次绑定可能显示为「未知设备」,连接成功后我会再读取 GAP 服务 0x2A00 里的真实设备名来校正。

2. 解析心率数据

心率测量特征的通知数据格式是 BLE 标准:

byte 0: flags
bit 0: 心率值是 uint8 还是 uint16
byte 1: 心率值(uint8
byte 1-2: 心率值(uint16

解析代码:

private func parseHeartRateMeasurement(_ data: Data) -> Int? {
guard !data.isEmpty else { return nil }
let flags = data[0]
let isUInt16 = (flags & 0x01) != 0
if isUInt16, data.count >= 3 {
return Int(data[1]) | (Int(data[2]) << 8)
} else if data.count >= 2 {
return Int(data[1])
}
return nil
}

3. 锁屏触发

每次收到心率或读到 RSSI 时,都会更新 lastAliveAt。每秒检查一次:

private func checkTimeout() {
guard let reference = lastAliveAt ?? connectAttemptAt else { return }
if Date().timeIntervalSince(reference) > SharedConfig.timeoutSeconds, !didLockForTimeout {
didLockForTimeout = true
LockScreenManager.lockScreen()
}
}

弱信号判定类似:连续低于阈值秒数达到设定值就锁屏,避免 RSSI 抖动导致误锁。

4. 触发 macOS 锁屏

最早我用的是 /System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession -suspend,但新版 macOS 已经移除了这个路径。为了兼容不同系统版本,我做了三层兜底:

  1. 调用 login.framework 的私有接口 SACLockScreenImmediate
  2. 如果找不到,再尝试 CGSession -suspend
  3. 如果都不行,最后用 pmset displaysleepnow 熄屏,配合系统设置里的「唤醒时需要密码」实现等效锁屏。
enum LockScreenManager {
static func lockScreen() {
if lockViaPrivateFramework() { return }
if FileManager.default.fileExists(atPath: lockCommand) {
// CGSession -suspend
return
}
// pmset displaysleepnow 兜底
}
}

5. 小组件与 App 共享数据

Widget 扩展和主 App 属于不同进程,不能直接共享内存。我用了一个小技巧:主 App(非沙盒)直接写 Widget 容器内的 plist 文件,Widget 读取自己的容器。这样比 App Group 的 UserDefaults(suiteName:) 更稳定,也绕过了 macOS AppData TCC 策略对 Widget 读取的限制。

public enum SharedStore {
private static let plistURL: URL = {
if isSandboxed {
// Widget:自己容器内的 Application Support
} else {
// 主 App:写 Widget 容器内的同一个文件
}
}()
}

Widget 每秒请求一次时间线刷新,实际刷新频率由系统调度决定,所以主 App 负责实时性,Widget 只是显示层。

还加了哪些功能

空闲锁屏兜底

万一哪天没戴手表,应用还内置了键鼠空闲检测。通过 CGEventSource.secondsSinceLastEventType 读取键盘、鼠标最后一次活动时间,超过设定秒数也锁屏。这个兜底还可以设置只在指定时间段生效,比如只在工作时间开启。

开机自启

SMAppService.mainApp.register() 注册登录项,实现开机自动启动,不需要在「系统设置 > 通用 > 登录项」里手动添加。

设置面板

状态栏点击「设置…」可以调整:

  • 信号弱锁屏阈值(-90 ~ -40 dBm)
  • 连续弱信号秒数
  • 心率超时秒数
  • 键鼠空闲锁屏秒数及时段
  • 开机自启开关

实际使用效果

戴上手表坐在工位,状态栏会显示实时心率。起身去洗手间,走出两三米后 RSSI 开始下降,大约 3 秒后屏幕自动锁定。回来后解锁电脑,手表会重新连上,继续显示心率。

误触发的情况极少,因为同时满足「信号持续弱」和「心率中断」才会锁屏。坐在椅子上正常打字时,即使偶尔 RSSI 波动一下,也不会触发锁屏。

踩过的坑

  1. macOS 不暴露 MAC 地址:无法自动识别手表,必须手动绑定一次。
  2. 部分华为手表不广播标准心率服务:它们只走华为运动健康私有协议,这种手表用不了。
  3. CGSession 被移除:新版系统锁屏 API 变了,需要多层兜底。
  4. App Group 共享存储被 TCC 拦截:主 App 写 App Group 的 UserDefaults 后,Widget 读不到,最后改用直接写 Widget 容器 plist。
  5. 沙盒权限:锁屏需要辅助功能权限,首次运行要在系统设置里允许。
文章作者:阿文
文章链接: https://www.awen.me/post/9ad176eb.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️