chore: stop tracking planning docs and promo working dirs

把规划文档保留在本地,不再发布到公开仓库;同时把 promo working
目录纳入 ignore,避免误把 node_modules / 视频产物提交上去。
This commit is contained in:
baiqing committed 2026-04-29 09:53:37 +08:00
1 parent e029f0bb17
commit 53cbdfbbff
2 files changed
+5 -244

No files matched your search

+5
View File
@@ -15,3 +15,8 @@ config.local.json
# Local working dirs that don't belong in the repo
SC/
promo/
promo-openless/
docs/old-promo/
# Planning docs are kept local only, not published to the public repo.
docs/plans/
-244
View File
@@ -1,244 +0,0 @@
# 键盘监测实现规划
> 切片范围:实现 push-to-talk 全局键盘监测,作为 OpenLess 主链路的第一个独立切片。完成后可直接验证按键事件流,未来插入 `DictationCoordinator` 时不需要返工。
更新时间:2026-04-26
---
## 1. 目标
按住一个全局热键 → 触发 `pressed` 事件流;松开 → 触发 `released`;录音过程中按 `Esc` → 触发 `cancelled`。
**v1 必须做到:**
- 全局生效(任何 app 聚焦时都能监听到)
- 默认热键不与 macOS 系统功能冲突
- 用户可在设置里换热键
- 权限缺失时返回明确错误码(不静默失效)
- 模块对外只暴露事件流,**不持有 UI / 业务状态**(满足 Codex 审查 §4.5 解耦要求)
**v1 不做:**
- 复杂组合键(仅支持单 modifier 键 push-to-talk + 普通键 Esc 取消)
- toggle 模式(按一下开始、再按一下结束)
- 拦截事件(不阻止系统默认行为,仅监听)
- 多设备适配(外接键盘 fn 键差异)
---
## 2. 关键技术决策
### 2.1 弃用 `KeyboardShortcuts` library
架构文档 §12 原本计划用 [`sindresorhus/KeyboardShortcuts`](https://github.com/sindresorhus/KeyboardShortcuts)。**这里需要修订**。
**原因**:该 library 只支持组合键(modifier + 普通键),**不支持 modifier-only push-to-talk**(按住单个 modifier 触发)。Wispr Flow / Typeless / Superwhisper 默认体验都是按住单一 modifier 说话,这是 OpenLess 必备能力。
**替代**:直接用 `NSEvent.addGlobalMonitorForEvents` + `addLocalMonitorForEvents`,监听 `.flagsChanged`(modifier 变化)和 `.keyDown`(Esc)。零依赖。
### 2.2 默认热键选 `right_option`
候选评估:
| 键 | 优点 | 缺点 | 选/不选 |
|---|---|---|---|
| `fn` / Globe | 用户最熟悉(Wispr 风格) | macOS 13+ 默认绑系统听写 / emoji picker,会同时触发 | ❌ 默认 |
| `right_option` | 单独可检测、零冲突、几乎所有 Mac 键盘有 | 用户习惯感稍弱 | ✅ **默认** |
| `right_command` | 单独可检测 | 部分键盘没有右 cmd | ❌ |
| `right_control` | 单独可检测 | 部分键盘没有右 ctrl | ❌ |
**默认 `right_option`,但设置里允许切到 `fn`、`right_command`、`right_control` 等**(v1 实现 4 个候选,UI 留 dropdown)。
### 2.3 modifier-only 键如何检测
`NSEvent.flagsChanged` 事件的 `modifierFlags.rawValue` 包含设备级别的 modifier 位(NX_DEVICE 系列 mask),可区分左右:
| Trigger | NX_DEVICE_*_MASK |
|---|---|
| `.leftControl` | `0x0001` |
| `.rightCommand` | `0x0010` |
| `.leftOption` | `0x0020` |
| `.rightOption` | `0x0040` |
| `.rightControl` | `0x2000` |
| `.fn` | `NSEvent.ModifierFlags.function` |
判断按下 / 松开:维护一个 `isPressed` 状态,每次 `flagsChanged` 时重新计算 `isTriggerActive(event)`,与上次状态比较 → 触发 `.pressed` / `.released`。
### 2.4 权限处理
现代 macOS(13+)对全局键盘监听有 Input Monitoring 权限要求。`NSEvent.addGlobalMonitorForEvents` 在不少情况下能不要权限工作,但**不能保证**对所有 app 聚焦状态都收到事件。
**v1 方案**:
- 启动时调用 `IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)` 检查权限
- 未授权 → 抛 `HotkeyError.inputMonitoringNotGranted`,由上层引导用户开权限
- 已授权 → 安装 monitor
辅助功能(Accessibility)权限是文本插入需要的,不是键盘监听需要的。两者要分别引导。
### 2.5 线程模型
`NSEvent` monitor callback 默认在主线程。所有事件 yield 到 `AsyncStream` 也在主线程,consumer 默认主线程消费。无锁、无并发问题。
`AsyncStream.Continuation` 本身是 `Sendable` 的,跨线程 yield 安全。
---
## 3. 模块归属
新建 Swift Package:**`OpenLessHotkey`**
依赖:仅 `AppKit`、`IOKit.hidsystem`,**不依赖 OpenLessCore**(保持纯净的"键盘事件平面"模块)。
未来与 Coordinator 集成:在 `OpenLessWorkflow` 模块(架构文档 v2 计划新增)里订阅 `events: AsyncStream<HotkeyEvent>`,转发为 `DictationCommand`。Hotkey 模块本身不知道 Dictation 存在。
---
## 4. 接口契约
```swift
// HotkeyEvent.swift
public enum HotkeyEvent: Sendable, Equatable {
case pressed
case released
case cancelled // Esc 在 pressed 期间按下
}
// HotkeyBinding.swift
public struct HotkeyBinding: Codable, Sendable, Equatable {
public enum Trigger: String, Codable, Sendable, CaseIterable {
case rightOption
case leftOption
case rightCommand
case rightControl
case leftControl
case fn
}
public let pushToTalk: Trigger
public init(pushToTalk: Trigger) {
self.pushToTalk = pushToTalk
}
public static let `default` = HotkeyBinding(pushToTalk: .rightOption)
}
// HotkeyError.swift
public enum HotkeyError: Error, Sendable, Equatable {
case alreadyRunning
case notRunning
case inputMonitoringNotGranted
}
// HotkeyServiceProtocol.swift
@MainActor
public protocol HotkeyServiceProtocol: AnyObject {
var events: AsyncStream<HotkeyEvent> { get }
func start(binding: HotkeyBinding) throws
func stop()
func updateBinding(_ binding: HotkeyBinding)
var isRunning: Bool { get }
}
// HotkeyMonitor.swift(实现)
@MainActor
public final class HotkeyMonitor: HotkeyServiceProtocol { /* NSEvent 实现 */ }
// InputMonitoringPermission.swift
public enum InputMonitoringPermission {
public static func isGranted() -> Bool
public static func request() -> Bool // 触发系统弹窗
}
```
设计要点:
- 协议 + 实现分离,便于 Coordinator 端 mock 测试(未来 ViewModel 测试只需 `HotkeyServiceMock`)
- `AsyncStream` 单向数据流,consumer 不能反向调 service
- `updateBinding` 不需要 stop+start,只改内部状态(避免 monitor 抖动)
- `isRunning` 状态可读(设置页 UI 用得到)
---
## 5. 实现路径
按可独立验证拆分:
| # | 子任务 | 验证方式 |
|---|---|---|
| 5.1 | 创建 `Package.swift` + `OpenLessHotkey` library + `HotkeyDemo` executable | `swift build` 通过 |
| 5.2 | 写 `HotkeyEvent` / `HotkeyBinding` / `HotkeyError` 三个值类型 + 单元测试 | `swift test` 通过 |
| 5.3 | 写 `InputMonitoringPermission` + 一行权限检查测试 | 调 `isGranted()` 不崩溃 |
| 5.4 | 写 `HotkeyMonitor` NSEvent 安装/卸载逻辑(不处理事件) | start/stop 不崩溃,NSEvent monitor 正确装卸 |
| 5.5 | 实现 `flagsChanged` 处理 → 发出 pressed/released | `HotkeyDemo` 跑起来按 right_option 能看到 console 输出 |
| 5.6 | 实现 `keyDown` Esc 处理 → 发出 cancelled | 按 right_option + Esc 看到 cancelled |
| 5.7 | 实现 `updateBinding` 热切换 | demo 里切到 fn,按 fn 能触发 |
| 5.8 | 启动时同步当前 modifier 状态(避免漏 pressed) | 程序启动时已按住 right_option,调 start 后第一次松开能触发 released |
---
## 6. 验收标准(Goal-Driven)
1. ✅ `swift build` 在干净环境编译通过
2. ✅ `swift test` 全部单元测试通过
3. ✅ `swift run HotkeyDemo` 启动 5 秒后按住 `right_option` 1 秒再松开,console 输出严格三行:
```
[hotkey] pressed
[hotkey] released
```
按 `Esc` 在按下 `right_option` 期间,应额外输出 `[hotkey] cancelled`
4. ✅ 切换 binding 到 `fn` 后,按 `right_option` 不再触发;按 fn 触发
5. ✅ 模块只 import `AppKit` / `IOKit.hidsystem` / `Foundation`,**不 import 其它业务模块**
---
## 7. 自我审查(Step 2 内嵌)
> 在编码前回看本规划,确认入口、边界、错误路径无漏。
### 7.1 入口正确性
- `HotkeyMonitor.init()`:仅创建 AsyncStream + continuation;不安装 monitor。✅
- `start(binding:)`:先权限检查 → 装 NSEvent monitor → 同步当前 modifier 状态 → 设 `isRunning = true`。✅
- `stop()`:卸 monitor → 如果当前 isPressed,最后发一个 `.released` → 设 `isRunning = false`。✅
- `updateBinding(_:)`:只改 `self.binding`;如果旧 trigger 当前 active 但新 trigger 不 active,发 `.released`。✅
### 7.2 边界情况
| 情况 | 处理 |
|---|---|
| 程序启动时用户已按住 `right_option` | start 后立即查 `NSEvent.modifierFlags`,若 active → 发 `.pressed` |
| 用户连续快速按下/松开(< 50ms) | 不 debounce,直接传递;上层判断是否有效(v1 由 Coordinator 决定最短录音长度) |
| 用户按住 right_option + 按 cmd | 仍是 right_option 按下,`flagsChanged` 触发但 `isTriggerActive` 仍 true → 不重复发 pressed |
| 用户在我们的 Settings 窗口聚焦时按 right_option | `addLocalMonitorForEvents` 接住,与全局 monitor 行为一致 |
| Esc 按下但 `right_option` 未按下 | 忽略,不发 `cancelled`(避免影响其它 app 的 Esc 用法) |
| `stop()` 时未发 `.released` 的 pressed 状态 | 在 stop 内显式发 `.released` 收尾 |
| 程序退出时 stream 没关闭 | `deinit` 调 `continuation.finish()` |
### 7.3 错误路径
- 权限缺失:`start` 抛 `inputMonitoringNotGranted`,调用方负责弹引导
- 重复 start:抛 `alreadyRunning`
- 在未 start 时 stop:no-op,不抛错(容错友好)
- NSEvent.addGlobalMonitorForEvents 返回 nil(理论极少):抛 `inputMonitoringNotGranted`
### 7.4 跨线程安全
`HotkeyServiceProtocol` 标 `@MainActor`,强制调用方在主线程使用。`AsyncStream` consumer 可以在任意 actor 消费。✅
### 7.5 与未来架构的一致性
- 协议 → 实现分离 ✅(满足 §4.5 MVVM 推断)
- 不依赖业务模块 ✅
- 不持有 UI 引用 ✅
- 事件流单向 ✅
- 测试可注入 mock ✅
**结论**:规划无漏洞,可进入编码阶段。
---
## 8. 后续要更新的架构文档
完成后回头改:
- §7 全局快捷键:替换 `KeyboardShortcuts` 为 `OpenLessHotkey` 自实现
- §12 第三方依赖:移除 `KeyboardShortcuts`
- §10 扩展点矩阵:加一行"用户自定义键盘 binding 映射方案" → `HotkeyBinding.Trigger` 加 case 即可