Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ahakeyconfig-mac/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,10 @@ let package = Package(
name: "AhaKeyConfigAgent",
path: "Sources/Agent"
),
.testTarget(
name: "AhaKeyConfigTests",
dependencies: ["AhaKeyConfig"],
path: "Tests/AhaKeyConfigTests"
),
]
)
40 changes: 23 additions & 17 deletions ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat
lastHookStateAt = Date()
sendState(UInt8(clamping: stateValue))
querySwitchState(timeout: 1.5) { status in
let body = Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode)
let body = self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode)
self.emit("← permission 回包 switchState=\(String(describing: body["switchState"]))")
if let s = body["switchState"] as? Int, s != 0 {
self.emit("(拨杆非 0:PermissionRequest 将交回终端手动确认)")
Expand All @@ -349,23 +349,20 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat
}

case "status":
// 判断 BLE 是否真实连上键盘:只有当 cachedSwitchState 不为 nil 时(键盘通过 notify 上报过)才算连上。
// effectiveSwitchState 在用户设置了 userSwitchOverride 时即使未连上 BLE 也有值,不能作为连上键盘的依据
// 回传真实 GATT 通道状态;主 App 由 Agent 持有蓝牙时,必须以这条状态
// 而不是被暂停的 App BLE Manager 的缓存设备名和“连接中”来渲染
if cachedSwitchState != nil {
Self.replyAndClose(clientFd, [
"switchState": effectiveSwitchState.map { Int($0) } ?? NSNull(),
"lightMode": cachedLightMode.map { Int($0) } ?? NSNull(),
])
Self.replyAndClose(clientFd, statusReply(nil, cachedSwitch: effectiveSwitchState, cachedLight: cachedLightMode))
} else {
querySwitchState(timeout: 1.5) { status in
Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode))
Self.replyAndClose(clientFd, self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode))
}
}

case "approval_status":
// 给 Kimi CLI 的实时批准判断用:每次都主动向设备要当前拨杆,避免会话内沿用旧的 yolo/state。
querySwitchState(timeout: 1.5) { status in
Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode))
Self.replyAndClose(clientFd, self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode))
}

case "set_switch_override":
Expand All @@ -388,16 +385,25 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat
}
}

private static func statusReply(_ status: AgentDeviceStatus?,
cachedSwitch: UInt8?,
cachedLight: UInt8?) -> [String: Any] {
private func statusReply(_ status: AgentDeviceStatus?,
cachedSwitch: UInt8?,
cachedLight: UInt8?) -> [String: Any] {
let gattReady = peripheral != nil && commandChar != nil && notifyChar != nil
var reply: [String: Any] = [
"isConnected": gattReady,
"deviceName": peripheral?.name ?? NSNull(),
"deviceUUID": peripheral?.identifier.uuidString ?? NSNull(),
"commandReady": commandChar != nil,
"notifyReady": notifyChar != nil,
]
if let s = status {
return ["switchState": s.switchState, "lightMode": s.lightMode]
reply["switchState"] = s.switchState
reply["lightMode"] = s.lightMode
return reply
}
return [
"switchState": cachedSwitch.map { Int($0) } ?? NSNull(),
"lightMode": cachedLight.map { Int($0) } ?? NSNull(),
]
reply["switchState"] = cachedSwitch.map { Int($0) } ?? NSNull()
reply["lightMode"] = cachedLight.map { Int($0) } ?? NSNull()
return reply
}

private func scheduleStateReset(to state: UInt8, afterMs: Int, reason: String) {
Expand Down
102 changes: 80 additions & 22 deletions ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,36 @@ final class AhaKeyBLEManager: NSObject, ObservableObject {
}
}

/// 由「设备信息 / 顶栏」等**用户显式**发起连接时调用:取消「交给 Agent」时的抑制并尝试连接。
/// 由「设备信息 / 顶栏」等**用户显式**发起连接时调用。
/// Agent 持有键盘时仍保持抑制;用户必须先切换所有权,不能借权限申请路径抢占 GATT 连接。
func userInitiatedConnect() {
ensureCentralManager()
suppressAutomaticConnection = false
guard !suppressAutomaticConnection else {
bleConnectionStatus = "由 Agent 管理"
return
}
connectAutomatically()
}

/// 与 `AgentManager` 的蓝牙占用方一致:交给 Agent 时为 true,交回本 App 时为 false。
func setSuppressedForAgentOwningKeyboard(_ suppress: Bool) {
suppressAutomaticConnection = suppress
guard suppress else { return }

pendingConnect = false
central?.stopScan()
isScanning = false
autoReconnectTimer?.invalidate()
autoReconnectTimer = nil
stopRSSIPolling()
stopStatusPolling()

let activePeripheral = peripheral
resetLocalBLEConnectionState()
if let activePeripheral {
central?.cancelPeripheralConnection(activePeripheral)
}
bleConnectionStatus = "由 Agent 管理"
}

func connectAutomatically() {
Expand Down Expand Up @@ -249,6 +269,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject {
}

func startScan() {
guard !suppressAutomaticConnection else { return }
guard central?.state == .poweredOn else {
pendingConnect = true
return
Expand All @@ -275,7 +296,9 @@ final class AhaKeyBLEManager: NSObject, ObservableObject {
func disconnect() {
guard let peripheral else { return }
central?.cancelPeripheralConnection(peripheral)
appendLog("用户主动断开")
if !suppressAutomaticConnection {
appendLog("用户主动断开")
}
}

/// 发送原始命令到 0x7343(带队列,防止连发过载)
Expand Down Expand Up @@ -551,10 +574,16 @@ final class AhaKeyBLEManager: NSObject, ObservableObject {
}

private func startAutoReconnectPolling() {
guard !suppressAutomaticConnection else {
autoReconnectTimer?.invalidate()
autoReconnectTimer = nil
return
}
autoReconnectTimer?.invalidate()
autoReconnectTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
guard !self.suppressAutomaticConnection else { return }
guard self.central?.state == .poweredOn else { return }
guard !self.isConnected, !self.isScanning else { return }
guard self.bleConnectionStatus != "连接中…" else { return }
Expand Down Expand Up @@ -591,6 +620,26 @@ final class AhaKeyBLEManager: NSObject, ObservableObject {
statusPollTimer = nil
}

/// 仅清理本 App 的瞬态 BLE 引用,不清 lastPeripheralUUID,以便用户将所有权切回 App 时直连。
private func resetLocalBLEConnectionState() {
isConnected = false
dataChar = nil
commandChar = nil
notifyChar = nil
batteryLevelChar = nil
dataCharReady = false
commandCharReady = false
notifyCharReady = false
peripheral = nil
writeQueue.removeAll()
isWriting = false
writeBatches.removeAll()
didQueryAfterConnect = false
keyboardPictureStates.removeAll()
stopRSSIPolling()
stopStatusPolling()
}

private func startIDEStatePolling() {
ideStatePollTimer?.invalidate()
ideStatePollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Expand Down Expand Up @@ -902,6 +951,11 @@ enum OLEDUploadError: LocalizedError {
extension AhaKeyBLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
if self.suppressAutomaticConnection {
self.refreshBluetoothAuthorization()
self.bleConnectionStatus = "由 Agent 管理"
return
}
switch central.state {
case .poweredOn:
self.refreshBluetoothAuthorization()
Expand Down Expand Up @@ -932,6 +986,11 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate {
guard name.lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) else { return }

Task { @MainActor in
guard !self.suppressAutomaticConnection else {
self.central?.stopScan()
self.isScanning = false
return
}
self.appendLog("发现设备: \(name) RSSI=\(RSSI)")
self.central?.stopScan()
self.isScanning = false
Expand All @@ -944,6 +1003,11 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate {

nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
Task { @MainActor in
guard !self.suppressAutomaticConnection else {
central.cancelPeripheralConnection(peripheral)
self.bleConnectionStatus = "由 Agent 管理"
return
}
self.isConnected = true
self.deviceName = peripheral.name
self.bleDeviceUUID = peripheral.identifier.uuidString
Expand All @@ -965,13 +1029,17 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate {

nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
guard !self.suppressAutomaticConnection else {
self.bleConnectionStatus = "由 Agent 管理"
return
}
self.bleConnectionStatus = "连接失败"
self.appendLog("连接失败: \(error?.localizedDescription ?? "未知")", isError: true)
self.startAutoReconnectPolling()
// 3 秒后重试
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Double(3) * 1_000_000_000))
if !self.isConnected {
if !self.suppressAutomaticConnection, !self.isConnected {
self.connectAutomatically()
}
}
Expand All @@ -982,37 +1050,27 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate {
Task { @MainActor in
let dropped = self.writeQueue.count
let openBatches = self.writeBatches.count
self.resetLocalBLEConnectionState()
if self.suppressAutomaticConnection {
self.autoReconnectTimer?.invalidate()
self.autoReconnectTimer = nil
self.bleConnectionStatus = "由 Agent 管理"
return
}
if dropped > 0 || openBatches > 0 {
self.appendLog(
"BLE 已断开,丢弃未发出命令 \(dropped) 条(未闭合批 \(openBatches) 个)。\(error.map { "原因:\($0.localizedDescription)" } ?? "")",
isError: true
)
}
self.isConnected = false
self.bleConnectionStatus = "已断开"
self.dataChar = nil
self.commandChar = nil
self.notifyChar = nil
self.batteryLevelChar = nil
self.dataCharReady = false
self.commandCharReady = false
self.notifyCharReady = false
// 不清 peripheral 和 lastPeripheralUUID——用于直连重试
self.peripheral = nil
self.writeQueue.removeAll()
self.isWriting = false
self.writeBatches.removeAll()
self.didQueryAfterConnect = false
self.keyboardPictureStates.removeAll()
self.stopRSSIPolling()
self.stopStatusPolling()
self.startAutoReconnectPolling()
self.appendLog("已断开: \(error?.localizedDescription ?? "正常")")

// 2 秒后自动重连
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Double(2) * 1_000_000_000))
if !self.isConnected {
if !self.suppressAutomaticConnection, !self.isConnected {
self.appendLog("尝试自动重连…")
self.connectAutomatically()
}
Expand Down
47 changes: 47 additions & 0 deletions ahakeyconfig-mac/Sources/Models/AgentBLEConnectionState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// 主 App 从 Agent socket 收到的 BLE 连接快照。
///
/// Agent 拥有键盘时,主 App 的 `AhaKeyBLEManager` 会被刻意暂停,不能再用它的
/// "连接中" 或缓存设备名代表实际连接状态。
struct AgentBLEConnectionState: Equatable {
let isConnected: Bool
let deviceName: String?
let deviceUUID: String?
let commandReady: Bool
let notifyReady: Bool

init(
isConnected: Bool,
deviceName: String?,
deviceUUID: String?,
commandReady: Bool,
notifyReady: Bool
) {
self.isConnected = isConnected
self.deviceName = deviceName
self.deviceUUID = deviceUUID
self.commandReady = commandReady
self.notifyReady = notifyReady
}

static let disconnected = AgentBLEConnectionState(
isConnected: false,
deviceName: nil,
deviceUUID: nil,
commandReady: false,
notifyReady: false
)

/// 兼容旧 Agent:旧回包没有 `isConnected` 时,沿用已有的 switchState 语义。
init(socketReply: [String: Any]) {
let legacyConnected = !(socketReply["switchState"] is NSNull)
&& socketReply["switchState"] != nil

isConnected = socketReply["isConnected"] as? Bool ?? legacyConnected
deviceName = socketReply["deviceName"] as? String
deviceUUID = socketReply["deviceUUID"] as? String
commandReady = socketReply["commandReady"] as? Bool ?? false
notifyReady = socketReply["notifyReady"] as? Bool ?? false
}
}
Loading