不跳就锁——用心率广播实现人走锁屏

awenforlinux 2026-07-27 08:36 1

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

🐶公司办公区有比较严格的安全规范:离开工位必须锁屏。道理都懂,可实际执行起来总是磕磕绊绊——去洗手间、接水、开会,经常起身就走了,屏幕还亮着。
![clip_1784758448046_n4br1c.png]( https://file.awen.me/images/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 广播设备。用户从菜单栏的「选择设备」里按信号强度排序找到手表,点击完成绑定。绑定信息会持久化,下次启动自动重连。

```swift
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 )
```

解析代码:

```swift
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`。每秒检查一次:

```swift
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` 熄屏,配合系统设置里的「唤醒时需要密码」实现等效锁屏。

```swift
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 读取的限制。

```swift
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. **沙盒权限**:锁屏需要辅助功能权限,首次运行要在系统设置里允许。
最新回复 (54)
  • Lockroach 07-27 08:44
    1
    把检测能力对接开放出来,可以做一个末日摇篮系统了😄
  • Siriusie 07-27 08:52
    2
    @Lockroach 执剑人是吧(
  • JerryCat 07-27 09:13
    3
    https://github.com/ts1/BLEUnlock 试试这个吧
  • allinQQQ 07-27 09:21
    4
    既然还是蓝牙,就用蓝牙信号强度就可以了,没必要搞那么复杂,有类似产品我用过
  • remarrexxar 07-27 09:30
    5
    快捷指令的自动化里配置,当 mac 和 airpods 断开连接时,锁定屏幕
  • 340746 07-27 09:30
    6
    心率广播会增加手表耗电吧
  • ijse 07-27 09:38
    7
    @allinQQQ 这个能做到 万一死在了工位上,也会自动锁屏,只用蓝牙检测做不到这一点
  • 94 07-27 09:40
    8
    Win 都有动态锁的功能,Mac 没有提供类似的功能吗?

  • Rache1 07-27 09:42
    9
    人走锁屏都已经成了肌肉记忆了,Ctrl+CMD+Q ,一键的事儿。
  • iv2ex 07-27 09:44
    10
    是不是做一个监测 鼠标+键盘 活跃情况的也行?工作时间坐在电脑前,鼠标和键盘总有一个是活跃的吧。
  • huangmingyou 07-27 09:45
    11
    简单的有用蓝牙检测的.我现在是绑定了一个 F11, 顺手一点就锁,习惯就好.
  • 300 07-27 09:48
    12
    自动 push 全部项目 [:doge]
  • Takashi123 07-27 09:49
    13
    解决了死在工位时电脑不会锁屏的痛点。
  • nc 07-27 09:50
    14
    心脏骤停后自动 git commit git push
  • baizhu 07-27 09:57
    15
    这个走最好是那个走
  • JDog 07-27 09:57
    16
    @94 有~ Apple Watch 支持,不晓得其它设备支不支持
  • shalou8023 07-27 09:58
    17
    @ijse 都死在工位上了,还管他锁不锁屏。。。
  • wsyzzz 07-27 09:59
    18
    鼠标上绑了一个一键锁屏宏就行了,按一下就锁屏很方便
  • peasant 07-27 10:00
    19
    刚看到标题的时候我以为是那个走了,还在想人都走了就锁屏吗,怎么不清空数据
  • yanqing07 07-27 10:06
    20
    @shalou8023 你这是要笑死我吗,赔钱
  • chulei 07-27 10:23
    21
    支持支持
  • AutumnVerse 07-27 10:26
    22
    肌肉记忆+1
    起身的同时就按下锁屏键了
  • AllenZ0 07-27 10:28
    23
    一个触发角锁屏,手指划一下的事儿。
  • shalou8023 07-27 10:29
    24
    @peasant 原来你说的是这个走了,我以为是我想到那个走了
  • cemian 07-27 10:31
    25
    我都是将 mac 设置为鼠标滑到右下角锁屏,走的时候一划就完事了。
  • rich1e 07-27 10:46
    26
    op 麻烦你组织一下内容格式、没有看的心情 @awenforlinux
  • Vegetable 07-27 10:48
    27
    这不是很奇怪吗?为什么非得用广播这种费电的信号?说脱裤子放屁对不起这个点子的可玩性,但是如果只是为了检测锁屏,那完全没有必要用心率。
  • finull 07-27 10:50
    28
    BLEUnlock 我也在用,不过就是周围设备太多了,先找个偏僻的地方设置好设备
  • ostrichb 07-27 10:57
    29
    AI Slop 內文
  • sangbiao 07-27 11:03
    30
    挺好,猝死了直接锁屏,像夜神月的抽屉
  • cvooc 07-27 11:07
    31
    @Lockroach 我对公司高层说话, 我被开除了摇篮系统就会一键删除公司所有数据库,现在我将带着纸箱离开公司
  • quicksandznzn 07-27 11:18
    32
    Apple Watch + BLEUnlock 人走自动锁屏 根据蓝牙实现的
  • wu67 07-27 11:28
    33
    不是哥们, 你们用 AI 开工之前不搜一下有没有成品吗? 何必浪费 token?

    https://github.com/ts1/BLEUnlock#readme
  • bearbest 07-27 11:32
    34
    设置个触发角,一划就完事了
  • SakuraYuki 07-27 11:34
    35
    直接用 wifi 或者蓝牙不就能做到了
  • bush911 07-27 11:36
    36
    程序员 V5 ,我都是用手机蓝牙配对,当手机离开电脑几分钟就自动锁屏
  • junas7 07-27 11:42
    37
    @shalou8023 万一楼主公司抓到一次就要乐捐一次呢🐶
  • SeanChense 07-27 11:52
    38
    广播纪元?
  • liegu0317 07-27 13:11
    39
    “不跳就锁”,佩服楼主才华
  • tf2 07-27 13:15
    40
    快做个约会系统。遇到心动的人就直接匹配加好友。
  • mqnu00 07-27 13:41
    41
    考虑 windows 吗
  • irobbin 07-27 13:51
    42
    我做了一个识别屏幕前人脸的自动锁屏
    话说,op 不会跟我是一个公司的吧
  • ddddd0 07-27 14:06
    43
    很好的想法
  • youknowsomething 07-27 14:10
    44
    人在传感器行不行?
  • lswlray 07-27 14:27
    45
    这属于制度管理问题,想要通过技术来解决,往往的结局是出力不讨好。
  • runking 07-27 14:47
    46
    window 上我搞了这个
    https://github.com/monkey-sking/proximity-lock
  • willygeek007 07-27 15:19
    47
    点子挺好的,可惜已经有人实现好轮子了。
  • isnullstring 07-27 16:50
    48
    心率广播耗电,开一整天的话,手环要两天一冲或者一天一冲
  • archxm 07-27 16:51
    49
    用电脑时,不喜欢戴手表,戴左手影响敲键盘,戴右手影响鼠标。
  • obeyatonce 07-27 16:56
    50
    @Lockroach 心脏停跳就删库是吧
  • Dwayne 07-27 17:02
    51
    🐶我比较需要人走关闭显示器的
  • Chicagoake 07-27 17:03
    52
    @JerryCat 这个好用,离远了自动锁,回来自动解锁,有几次同事都被我吓到了,离工位几步远,电脑自动解锁。就是纯靠蓝牙信号偶尔会有 bug ,就在电脑前突然就锁了。
  • Chicagoake 07-27 17:04
    53
    @finull 有时候能读到设备的名称,有时候又读不到,挺烦的,工位弄这个一大堆设备。
  • 8355 07-27 17:58
    54
    工资没有暗示到帐就主动删库
* 帖子来源V2EX
返回