docs/plans/2026-04-20-logitech-conflict-indicator-design.md
Mos 的 Logi HID++ 按键绑定可能与 Logitech Options+ 同时劫持同一按键。LogitechDivertPlanner 已经保证 Mos 不会无差别扫除第三方 divert,但当用户绑定的按键正好被 Options+ 占用时,按键依然会"双方打架"。需要在 UI 上提前告知用户,让用户做决策。
偏好设置 > 按键 面板每一行的虚线分隔符中部,当该行绑定的 Logi 按键当前已被第三方(如 Options+)divert / remap 时,叠加一个 branch 图标。GetControlReporting 结果。ControlInfo:reportingFlags(bit0=tmpDivert, bit1=persistDivert, bit2=tmpRemap, bit3=persistRemap)+ reportingQueried 标志(查询是否完成)+ targetCID(remap 目标)。divertedCIDs:Mos 本进程当前 set 为 tmpDivert 的 CID 集合(已存在于 LogitechDeviceSession)。conflict(cid) =
reportingQueried == true
&& (
(reportingFlags & 0b0010) != 0 // persistDivert: Mos 从不写
|| (reportingFlags & 0b1100) != 0 // tmpRemap / persistRemap: Mos 从不写
|| ((reportingFlags & 0b0001) != 0 // tmpDivert 且 CID 不在 Mos 的集合
&& !divertedCIDs.contains(cid))
)
Mos 运行时会把自己的绑定 set 为 tmpDivert → reportingFlags.bit0 = 1 但 CID ∈ divertedCIDs → 不算冲突。第三方做的 persist/remap/其它 CID 的 tmpDivert → 命中冲突。
Mos/LogitechHID/LogitechConflictDetector.swift纯函数 / 轻量 struct。
struct LogitechConflictDetector {
enum Status { case unknown, clear, conflict }
static func status(
reportingFlags: UInt8,
reportingQueried: Bool,
cid: UInt16,
mosDivertedCIDs: Set<UInt16>
) -> Status
}
unknown:reportingQueried == false(设备未连接 / init 未完成 / 非 divertable)→ UI 不显示图标。clear / conflict:按上面的规则输出。可单测。
LogitechHIDManager.conflictStatus(forMosCode:) -> Status提供给 UI 层的唯一查询入口。实现:
LogitechCIDRegistry.toCID)。sessions,找到持有该 CID 的 session(同一设备同时只会由一个 session 持有 HID++ 候选接口)。ControlInfo → 调 LogitechConflictDetector.status(...)。.unknown。LogitechHIDManager.reportingQueryDidComplete在 LogitechDeviceSession.handleGetControlReportingResponse 的"查询完成"分支(LogitechDeviceSession.swift:1420)中 post,通知 UI 刷新。现有的 sessionChangedNotification 保留,处理设备连接/断开。
ButtonTableCellView虚线层结构保持 CAShapeLayer 不变,新增:
NSImageView(branch 图标)作为 contentView 的子视图,绝对定位到虚线中点。NSTrackingArea(mouseEnteredAndExited + activeInKeyWindow)附加到该 ImageView。NSPopover(content 为简单 NSViewController + NSTextField,本地化文案)。时机:
configure(with:) 末尾:如按键为 Logi → 调 LogitechHIDManager.shared.conflictStatus(forMosCode:),据此显示/隐藏图标。viewWillAppear 时订阅通知、viewWillDisappear 时取消订阅(可通过弱引用 observer)。sessionChangedNotification 或 reportingQueryDidComplete → 重新 resolve 状态。图标资源:
NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: ...),contentTintColor 设为警告色(可用 NSColor.systemOrange 或已有的 mainBlue)。Mos/Assets.xcassets/Preferences/ConflictBranch.imageset/(与现有 Preferences/ 资产并列)。若资产缺失则整行不显示图标(不影响核心功能)。虚线位置:现有 setupDashedLine 在 startX..endX 中绘制。branch 图标居中于 (startX+endX)/2,图标周围可预留 6~8pt "间隙"(把虚线路径拆成 start→gapStart 和 gapEnd→end 两段),避免图标压在虚线上。
Localizable.xcstrings 新增两 key:
button_conflict_title → 中文:"按键可能被其他应用接管";英文:"Button may be captured by another app"button_conflict_detail → 中文:"该按键当前已被其他应用(例如 Logitech Options+)自定义,两个应用同时劫持同一按键可能无法按预期工作。建议在该应用中释放该按键,或退出该应用。";英文版同义。| 动作 | 文件 | 内容 |
|---|---|---|
| 新增 | Mos/LogitechHID/LogitechConflictDetector.swift | Status enum + status(...) 纯函数 |
| 修改 | Mos/LogitechHID/LogitechHIDManager.swift | 新增 conflictStatus(forMosCode:) + 新 reportingQueryDidComplete notification name |
| 修改 | Mos/LogitechHID/LogitechDeviceSession.swift | 在 reporting 查询完成后 post 新 notification;暴露 control(for cid:) -> ControlInfo? 读取接口(或复用现有 debugDiscoveredControls) |
| 修改 | Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift | 新增 branch icon 子视图、tracking area、popover、通知订阅、在 configure / 通知回调时刷新可见性;修改 setupDashedLine 在图标处留 gap |
| 新增 | Mos/Assets.xcassets/Preferences/ConflictBranch.imageset/ | 10.13~10.15 fallback 图标(template PNG) |
| 修改 | Mos/Localizable.xcstrings | 新增两 key |
| 新增 | MosTests/LogitechConflictDetectorTests.swift | 纯函数单测 |
LogitechConflictDetectorTests:
status_notQueried_returnsUnknownstatus_allZero_returnsClearstatus_persistDivertSet_returnsConflictstatus_tmpRemapSet_returnsConflictstatus_persistRemapSet_returnsConflictstatus_tmpDivert_cidInMosSet_returnsClear — Mos 自己 divert 的不算冲突status_tmpDivert_cidNotInMosSet_returnsConflict — 第三方 tmpDivertstatus_multipleBitsSet_returnsConflictsetControlReporting 内部已经在本地更新 reportingFlags.bit0。所以运行时 Mos 自己的 divert 会体现在 reportingFlags,但判定依赖 divertedCIDs 抵消 → 判定正确。已经在单测覆盖。@available gate,低版本安静失败)—— 与现有 ShortcutManager 风格一致。LogitechConflictDetector,写单测;LogitechHIDManager 暴露 conflictStatus(forMosCode:) + reportingQueryDidComplete。不动 UI,TDD 先绿。ButtonTableCellView 加 branch icon + hover popover;本地化;图标资产。sendRequest/handleInputReport。startReportingQuery + handleGetControlReportingResponse,只新加一行 NotificationCenter.default.post 即可。sessionChangedNotification 风格。NSPopover、NSTrackingArea、CAShapeLayer 均为 AppKit 标准。项目里 Toast 模块也有 NSPopover-like 用法可参考。NSLocalizedString + Localizable.xcstrings。总改动约:新增 ~120 行、修改 ~60 行,单测 ~80 行。无架构变更。