diff --git a/ahakeyconfig-mac/Package.swift b/ahakeyconfig-mac/Package.swift index 182b1c80..21a70ae2 100644 --- a/ahakeyconfig-mac/Package.swift +++ b/ahakeyconfig-mac/Package.swift @@ -54,5 +54,10 @@ let package = Package( name: "AhaKeyConfigAgent", path: "Sources/Agent" ), + .testTarget( + name: "AhaKeyConfigTests", + dependencies: ["AhaKeyConfig"], + path: "Tests/AhaKeyConfigTests" + ), ] ) diff --git a/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift index dad2135a..b1208ab0 100644 --- a/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift +++ b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift @@ -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 将交回终端手动确认)") @@ -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": @@ -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) { diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift index 7799f3e7..6ed0a8cd 100644 --- a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -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() { @@ -249,6 +269,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } func startScan() { + guard !suppressAutomaticConnection else { return } guard central?.state == .poweredOn else { pendingConnect = true return @@ -275,7 +296,9 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { func disconnect() { guard let peripheral else { return } central?.cancelPeripheralConnection(peripheral) - appendLog("用户主动断开") + if !suppressAutomaticConnection { + appendLog("用户主动断开") + } } /// 发送原始命令到 0x7343(带队列,防止连发过载) @@ -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 } @@ -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 @@ -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() @@ -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 @@ -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 @@ -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() } } @@ -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() } diff --git a/ahakeyconfig-mac/Sources/Models/AgentBLEConnectionState.swift b/ahakeyconfig-mac/Sources/Models/AgentBLEConnectionState.swift new file mode 100644 index 00000000..01f31de5 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Models/AgentBLEConnectionState.swift @@ -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 + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift index 84a51e4b..7d14a2ed 100644 --- a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift +++ b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift @@ -40,6 +40,7 @@ final class AgentManager: ObservableObject { @Published private(set) var isInstalled = false @Published private(set) var isRunning = false @Published private(set) var isAgentBLEConnected = false // agent 的 BLE 是否真正连上键盘 + @Published private(set) var agentBLEConnectionState = AgentBLEConnectionState.disconnected @Published private(set) var hooksInstalled = false // Claude / Cursor / Codex / Kimi hooks 是否装了任何一个 @Published private(set) var claudeHooksInstalled = false @Published private(set) var cursorHooksInstalled = false @@ -179,11 +180,15 @@ final class AgentManager: ObservableObject { if isRunning { let socketPath = socketPath DispatchQueue.global(qos: .utility).async { [weak self, socketPath] in - let bleConnected = Self.querySocketBLEConnected(socketPath: socketPath) - DispatchQueue.main.async { self?.isAgentBLEConnected = bleConnected } + let connectionState = Self.querySocketBLEConnectionState(socketPath: socketPath) + DispatchQueue.main.async { + self?.isAgentBLEConnected = connectionState.isConnected + self?.agentBLEConnectionState = connectionState + } } } else { isAgentBLEConnected = false + agentBLEConnectionState = .disconnected } } @@ -225,11 +230,12 @@ final class AgentManager: ObservableObject { } } - /// 向 agent socket 发 status 命令,switchState 非 null 即代表 BLE 已连上键盘。 + /// 向 agent socket 发 status 命令,读取 Agent 实际持有的连接与 GATT 通道状态。 + /// 旧版 Agent 只返回 switchState,`AgentBLEConnectionState` 会兼容该回包。 /// 同步执行,需在后台线程调用。 - nonisolated private static func querySocketBLEConnected(socketPath: String) -> Bool { + nonisolated private static func querySocketBLEConnectionState(socketPath: String) -> AgentBLEConnectionState { let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return false } + guard fd >= 0 else { return .disconnected } defer { close(fd) } var tv = timeval(tv_sec: 2, tv_usec: 0) @@ -248,23 +254,23 @@ final class AgentManager: ObservableObject { connect(fd, $0, socklen_t(MemoryLayout.size)) } } - guard ok == 0 else { return false } + guard ok == 0 else { return .disconnected } - guard let payload = "{\"cmd\":\"status\"}\n".data(using: .utf8) else { return false } + guard let payload = "{\"cmd\":\"status\"}\n".data(using: .utf8) else { return .disconnected } let wrote = payload.withUnsafeBytes { ptr -> Int in guard let base = ptr.baseAddress else { return -1 } return write(fd, base, ptr.count) } - guard wrote > 0 else { return false } + guard wrote > 0 else { return .disconnected } var buf = [UInt8](repeating: 0, count: 256) let n = read(fd, &buf, buf.count) - guard n > 0 else { return false } + guard n > 0 else { return .disconnected } guard let json = try? JSONSerialization.jsonObject(with: Data(buf[0.. String { state == 0 ? "自动批准" : "手动批准" } diff --git a/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AgentBLEConnectionStateTests.swift b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AgentBLEConnectionStateTests.swift new file mode 100644 index 00000000..c77326f5 --- /dev/null +++ b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AgentBLEConnectionStateTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import AhaKeyConfig + +final class AgentBLEConnectionStateTests: XCTestCase { + func testUsesExplicitAgentConnectionFields() { + let state = AgentBLEConnectionState(socketReply: [ + "isConnected": true, + "deviceName": "AhaKey 517C", + "deviceUUID": "5AB949F0-B932-537A-BCFB-1B973C74B5A1", + "commandReady": true, + "notifyReady": true, + ]) + + XCTAssertTrue(state.isConnected) + XCTAssertEqual(state.deviceName, "AhaKey 517C") + XCTAssertEqual(state.deviceUUID, "5AB949F0-B932-537A-BCFB-1B973C74B5A1") + XCTAssertTrue(state.commandReady) + XCTAssertTrue(state.notifyReady) + } + + func testFallsBackToLegacySwitchStateReply() { + let state = AgentBLEConnectionState(socketReply: ["switchState": 0]) + + XCTAssertTrue(state.isConnected) + XCTAssertNil(state.deviceName) + XCTAssertFalse(state.commandReady) + XCTAssertFalse(state.notifyReady) + } + + func testTreatsNullLegacySwitchStateAsDisconnected() { + let state = AgentBLEConnectionState(socketReply: ["switchState": NSNull()]) + + XCTAssertFalse(state.isConnected) + } +}