diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..07ba438a --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "61138d8ebbc66bd6a92db7232ad357860220c50c906c68f82138f4d66021d4d3", + "pins" : [ + { + "identity" : "dynamicnotchkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MrKai77/DynamicNotchKit", + "state" : { + "revision" : "3c405930fd4d9f8498303683f32492c26d3a9b2b", + "version" : "1.0.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index b0f5ad1d..873ffb2e 100644 --- a/Package.swift +++ b/Package.swift @@ -4,18 +4,40 @@ import PackageDescription let package = Package( name: "AhaKeyConfig", platforms: [ - .macOS("12.0") + .macOS(.v13) ], products: [ .executable(name: "AhaKeyConfig", targets: ["AhaKeyConfig"]), .executable(name: "ahakeyconfig-agent", targets: ["AhaKeyConfigAgent"]), + .executable(name: "PluginShowcase", targets: ["PluginShowcase"]), + .library(name: "AhaKeyPluginKit", targets: ["AhaKeyPluginKit"]), + ], + dependencies: [ + .package(path: "vibebar"), ], - targets: [ + .target( + name: "AhaKeyPluginKit", + path: "ahakeyconfig-mac/Sources/AhaKeyPluginKit" + ), + .executableTarget( + name: "Plugin", + dependencies: ["AhaKeyPluginKit"], + path: "ahakeyconfig-mac/Sources/AhaKeyPlugin" + ), + .executableTarget( + name: "PluginShowcase", + dependencies: ["AhaKeyPluginKit"], + path: "ahakeyconfig-mac/Sources/AhaKeyPluginShowcase" + ), .executableTarget( name: "AhaKeyConfig", + dependencies: [ + "AhaKeyPluginKit", + .product(name: "VibeBar", package: "VibeBar"), + ], path: "ahakeyconfig-mac/Sources", - exclude: ["Agent"], + exclude: ["Agent", "AhaKeyPlugin", "AhaKeyPluginKit", "AhaKeyPluginShowcase"], // 与 scripts/build.sh 中 Info.plist 一致。嵌入 __info_plist 段后 TCC 可识别。 // Debug 使用单独 plist:系统在「隐私与安全性」列表中显示为「AhaKey Studio(调试)」,与正式包区分。 linkerSettings: [ @@ -37,5 +59,10 @@ let package = Package( name: "AhaKeyConfigAgent", path: "ahakeyconfig-mac/Sources/Agent" ), + .testTarget( + name: "AhaKeyConfigTests", + dependencies: ["AhaKeyConfig"], + path: "ahakeyconfig-mac/Tests/AhaKeyConfigTests" + ), ] ) diff --git a/ahakeyconfig-mac/Package.resolved b/ahakeyconfig-mac/Package.resolved new file mode 100644 index 00000000..20637a88 --- /dev/null +++ b/ahakeyconfig-mac/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "dynamicnotchkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MrKai77/DynamicNotchKit", + "state" : { + "revision" : "3c405930fd4d9f8498303683f32492c26d3a9b2b", + "version" : "1.0.0" + } + } + ], + "version" : 2 +} diff --git a/ahakeyconfig-mac/Package.swift b/ahakeyconfig-mac/Package.swift index 182b1c80..42741913 100644 --- a/ahakeyconfig-mac/Package.swift +++ b/ahakeyconfig-mac/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "AhaKeyConfig", platforms: [ - .macOS("12.0") + .macOS(.v13) ], products: [ .executable(name: "AhaKeyConfig", targets: ["AhaKeyConfig"]), @@ -12,6 +12,9 @@ let package = Package( .executable(name: "PluginShowcase", targets: ["PluginShowcase"]), .library(name: "AhaKeyPluginKit", targets: ["AhaKeyPluginKit"]), ], + dependencies: [ + .package(path: "../vibebar"), + ], targets: [ .target( @@ -30,7 +33,10 @@ let package = Package( ), .executableTarget( name: "AhaKeyConfig", - dependencies: ["AhaKeyPluginKit"], + dependencies: [ + "AhaKeyPluginKit", + .product(name: "VibeBar", package: "VibeBar"), + ], path: "Sources", exclude: ["Agent", "AhaKeyPlugin", "AhaKeyPluginKit", "AhaKeyPluginShowcase"], // 与 scripts/build.sh 中 Info.plist 一致。嵌入 __info_plist 段后 TCC 可识别。 @@ -54,5 +60,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..fb56ab10 100644 --- a/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift +++ b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift @@ -28,9 +28,15 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat private let serviceUUID = CBUUID(string: "7340") private let commandCharUUID = CBUUID(string: "7343") private let notifyCharUUID = CBUUID(string: "7344") - private let deviceNamePrefix = "AhaKey" + /// 设备名前缀白名单:官方 "AhaKey" 及 vibe coding 固件的 "vibe code"(与主 App 保持一致)。 + private let deviceNamePrefixes = ["AhaKey", "vibe code"] private let socketPath: String + private func matchesDeviceName(_ name: String?) -> Bool { + guard let lower = name?.lowercased() else { return false } + return deviceNamePrefixes.contains { lower.hasPrefix($0.lowercased()) } + } + private let header: [UInt8] = [0xAA, 0xBB] private let trailer: [UInt8] = [0xCC, 0xDD] @@ -442,7 +448,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat // 2. 系统已连接 let connected = central.retrieveConnectedPeripherals(withServices: [serviceUUID]) - if let p = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(deviceNamePrefix.lowercased()) }) { + if let p = connected.first(where: { matchesDeviceName($0.name) }) { emit("系统已连接: \(p.name ?? "?")") peripheral = p p.delegate = self @@ -472,7 +478,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" - guard name.lowercased().hasPrefix(deviceNamePrefix.lowercased()) else { return } + guard matchesDeviceName(name) else { return } central.stopScan() emit("发现: \(name)") self.peripheral = peripheral diff --git a/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift index 78e7c350..24497b3c 100644 --- a/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift +++ b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift @@ -3,16 +3,24 @@ import SwiftUI import AVFoundation import Speech import UserNotifications +import VibeBar @main struct AhaKeyConfigApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var bleManager = AhaKeyBLEManager() + @State private var vibeBarBridge = VibeBarBridge() var body: some Scene { WindowGroup("AhaKey Studio") { ContentView(bleManager: bleManager) .frame(minWidth: 1180, minHeight: 680) + .onAppear { + vibeBarBridge.attach(bleManager: bleManager) { [weak appDelegate] in + appDelegate?.reopenMainWindow() + } + VibeBarController.shared.start(state: vibeBarBridge.state) + } } .windowStyle(.titleBar) diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift index 7799f3e7..59bce2b3 100644 --- a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -59,10 +59,15 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { @Published private(set) var lightMode: Int = 0 @Published private(set) var switchState: Int = 0 @Published private(set) var brightness: Int = 35 + /// `nil` 表示尚未收到状态;`false` 表示旧协议固件(状态帧的亮度保留位为 0)。 + /// 新固件把该字节定义为 1...100 的 WS2812 亮度,并实现 0x84/0x85/0x91。 + @Published private(set) var supportsConfigurableLighting: Bool? @Published private(set) var bleConnectionStatus: String = "未连接" @Published private(set) var bleDeviceUUID: String = "—" @Published private(set) var bluetoothPermissionGranted = true @Published private(set) var bluetoothPoweredOn = false + /// 细分的「卡在哪条链路」诊断,把笼统的「等待设备」拆成可操作提示(Issue #34)。 + @Published private(set) var linkDiagnostic: LinkDiagnostic = .idle @Published private(set) var oledUploadProgress: OLEDUploadProgress? @Published private(set) var isUploadingOLED = false /// 由 ahakeyconfig-agent 写入的当前 IDE hook 状态值(IDEState.rawValue),用于画布 LED 颜色实时还原 @@ -103,7 +108,19 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { static let firmwareRevisionCharUUID = CBUUID(string: "2A26") static let modelNumberCharUUID = CBUUID(string: "2A24") - nonisolated static let deviceNamePrefix = "AhaKey" + // 标准 HID Service —— 设备被系统蓝牙 / 语音链路连上后常只暴露 HID / DeviceInfo, + // 用它来把「已连接但已停止广播 0x7340」的设备从系统侧捞回来(见 connectAutomatically,Issue #34)。 + static let hidServiceUUID = CBUUID(string: "1812") + + /// 设备广播名前缀白名单。除官方 "AhaKey" 外,也认 vibe coding 固件的 "vibe code" 名 + /// (CH582m_vibe_coding_BLE_keyboard 固件默认广播名形如 "vibe code XXXX")。 + nonisolated static let deviceNamePrefixes = ["AhaKey", "vibe code"] + + /// 设备名是否匹配任一已知前缀(大小写无关)。 + nonisolated static func matchesDeviceName(_ name: String?) -> Bool { + guard let lower = name?.lowercased() else { return false } + return deviceNamePrefixes.contains { lower.hasPrefix($0.lowercased()) } + } // MARK: - Private @@ -188,19 +205,23 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } } - private static func currentBluetoothAuthorizationGranted() -> Bool { - switch CBCentralManager.authorization { + nonisolated static func isBluetoothAuthorizationGranted(_ authorization: CBManagerAuthorization) -> Bool { + switch authorization { case .allowedAlways: return true case .notDetermined: - return true + return false case .restricted, .denied: return false @unknown default: - return true + return false } } + private static func currentBluetoothAuthorizationGranted() -> Bool { + isBluetoothAuthorizationGranted(CBCentralManager.authorization) + } + /// 由「设备信息 / 顶栏」等**用户显式**发起连接时调用:取消「交给 Agent」时的抑制并尝试连接。 func userInitiatedConnect() { ensureCentralManager() @@ -214,9 +235,18 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } func connectAutomatically() { - guard !suppressAutomaticConnection else { return } + guard !suppressAutomaticConnection else { + // 蓝牙交由 ahakeyconfig-agent 占用,本 App 不直连——这是预期行为,明确标记以免被当成故障。 + linkDiagnostic = .ownedByAgent + return + } guard central?.state == .poweredOn else { pendingConnect = true + switch central?.state { + case .poweredOff: linkDiagnostic = .bluetoothOff + case .unauthorized: linkDiagnostic = .bluetoothUnauthorized + default: break + } return } @@ -229,14 +259,24 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { p.delegate = self central?.connect(p, options: nil) bleConnectionStatus = "连接中…" + linkDiagnostic = .connecting return } } - // 2. 查找系统已连接设备 - let connected = central?.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) ?? [] - if let existing = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) }) { - appendLog("发现系统已连接设备: \(existing.name ?? "?")") + // 2. 查找系统已连接设备。 + // 根因兜底(Issue #34):设备一旦被系统蓝牙 / 语音(HID) 链路连上,通常会停止广播, + // 基于广播的 scanForPeripherals(withServices:[0x7340]) 永远扫不到它;且若此前无人发现过 + // 0x7340,retrieveConnectedPeripherals(withServices:[0x7340]) 也为空。所以这里用更宽的标准 + // service 集合把设备捞回来,再主动 connect → didConnect 里 discoverServices 补发现 0x7340。 + if let existing = systemConnectedAhaKeyPeripheral() { + if isConfigServiceVisible(existing) { + appendLog("发现系统已连接设备: \(existing.name ?? "?")") + linkDiagnostic = .connecting + } else { + appendLog("设备已被系统/语音链路连接但未广播配置服务,主动接管 0x7340: \(existing.name ?? "?")") + linkDiagnostic = .systemConnectedNoConfigLink + } self.peripheral = existing existing.delegate = self central?.connect(existing, options: nil) @@ -248,6 +288,24 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { startScan() } + /// 用更宽的标准 service 集合在「系统已连接」外设里查找 AhaKey 设备——即使它已停止广播 0x7340。 + private func systemConnectedAhaKeyPeripheral() -> CBPeripheral? { + let lookupServices = [ + Self.serviceUUID, + Self.deviceInfoServiceUUID, + Self.batteryServiceUUID, + Self.hidServiceUUID, + ] + let connected = central?.retrieveConnectedPeripherals(withServices: lookupServices) ?? [] + return connected.first { Self.matchesDeviceName($0.name) } + } + + /// 该设备是否已在系统层暴露过 0x7340 配置 service:用于区分「完整可连」与「仅 HID / 语音链路」。 + private func isConfigServiceVisible(_ peripheral: CBPeripheral) -> Bool { + (central?.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) ?? []) + .contains { $0.identifier == peripheral.identifier } + } + func startScan() { guard central?.state == .poweredOn else { pendingConnect = true @@ -255,6 +313,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } isScanning = true bleConnectionStatus = "扫描中…" + linkDiagnostic = .scanning appendLog("开始扫描 AhaKey 设备…") central?.scanForPeripherals( withServices: [Self.serviceUUID], @@ -267,7 +326,14 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { self.central?.stopScan() self.isScanning = false self.bleConnectionStatus = "等待设备" - self.appendLog("扫描超时,继续后台轮询设备") + // 扫描超时后再用宽 service 探一次,区分「设备已连但未广播配置链路」与「彻底没发现设备」(Issue #34)。 + if self.systemConnectedAhaKeyPeripheral() != nil { + self.linkDiagnostic = .systemConnectedNoConfigLink + self.appendLog("扫描超时:检测到设备已被系统/语音链路连接,但配置链路 0x7340 未建立") + } else { + self.linkDiagnostic = .noDeviceFound + self.appendLog("扫描超时,继续后台轮询设备") + } } } } @@ -480,6 +546,69 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { appendLog("→ 预览灯效 \(effect)") } + /// 写入前主动验证新灯效协议。旧 v1.0 对未知命令也返回 status=0,不能只看 0x85 ACK; + /// 必须再查询状态并确认亮度字段真的变成期望值。 + func verifyConfigurableLightingSupport(brightness value: UInt8) async throws { + guard commandChar != nil else { throw OLEDUploadError.channelNotReady } + + let expected = max(1, min(100, value)) + appendLog("验证灯效协议:写入亮度 \(expected)% 并回读…") + _ = try await sendCommandAwaitingResponse( + AhaKeyCommand.setBrightness(expected), + expectedCommand: AhaKeyCommand.cmdSetBrightness + ) + + let response = try await sendCommandAwaitingResponse( + AhaKeyCommand.queryDeviceStatus(), + expectedCommand: 0x00 + ) + guard let status = AhaKeyResponseParser.parseDeviceStatus(response.payload) else { + throw OLEDUploadError.invalidDeviceStatusPayload + } + + let supported = status.brightness == Int(expected) + supportsConfigurableLighting = supported + guard supported else { + throw OLEDUploadError.unsupportedLightingFirmware( + main: status.firmwareMain, + sub: status.firmwareSub, + reportedBrightness: status.brightness + ) + } + appendLog("灯效协议验证通过:0x84/0x85/0x91 可用") + } + + /// 严格串行发送并等待每一条设备 ACK。只有所有命令(包括最后的 0x04 保存) + /// 都收到 status=0,调用方才能向用户报告“写入成功”。 + func writeCommandsConfirmingResponses( + _ commands: [(data: Data, label: String)], + interCommandDelayMilliseconds: UInt64 = 50 + ) async throws { + guard commandChar != nil else { throw OLEDUploadError.channelNotReady } + + for (index, command) in commands.enumerated() { + guard command.data.count >= 5, + command.data[0] == 0xAA, + command.data[1] == 0xBB else { + throw OLEDUploadError.invalidCommandFrame + } + let commandID = command.data[2] + appendLog(command.label) + _ = try await sendCommandAwaitingResponse( + command.data, + expectedCommand: commandID + ) + + if index < commands.count - 1, interCommandDelayMilliseconds > 0 { + try await Task.sleep(nanoseconds: interCommandDelayMilliseconds * 1_000_000) + } + } + } + + nonisolated static func statusAdvertisesConfigurableLighting(brightness: Int) -> Bool { + (1...100).contains(brightness) + } + func setWorkMode(_ mode: UInt8) { guard commandChar != nil else { return } writeCommand(AhaKeyCommand.setWorkMode(mode)) @@ -696,8 +825,16 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } } } - group.addTask { + group.addTask { [weak self] in try await Task.sleep(nanoseconds: UInt64(Double(timeoutSeconds) * 1_000_000_000)) + // 超时必须主动 resume 仍挂着的 continuation 并移除它:CheckedContinuation 不响应任务取消, + // 否则 withThrowingTaskGroup 会一直等这个永不结束的子任务而 hang,导致本函数永不返回 → + // defer 不清理 → protocolResponseWaiters 残留 → 状态轮询被 guard 永久挡死(设备某档/某模式 + // 不回应即复现:界面不再随键盘变化刷新,必须重连)。removeValue 原子取出,与响应处理互斥,不会重复 resume。 + await MainActor.run { [weak self] in + self?.protocolResponseWaiters.removeValue(forKey: expectedCommand)? + .resume(throwing: OLEDUploadError.timeout(command: expectedCommand)) + } throw OLEDUploadError.timeout(command: expectedCommand) } @@ -868,6 +1005,52 @@ final class SwitchStateNotifier: ObservableObject { } } +/// 设备「卡在哪条链路」的细分诊断(Issue #34):把笼统的「等待设备」拆成可操作的提示, +/// 让用户能区分「蓝牙已连但配置链路未连」与「设备完全没连接」。 +enum LinkDiagnostic: Equatable { + case idle // 初始 / 空闲 + case scanning // 扫描中 + case connecting // 连接中 + case connected // 配置链路 (0x7340) 已连 + case bluetoothOff // 系统蓝牙未开启 + case bluetoothUnauthorized // 未授权蓝牙权限 + case ownedByAgent // BLE 交由 ahakeyconfig-agent 占用(本 App 不直连,属预期) + case systemConnectedNoConfigLink // 设备已被系统 / 语音(HID) 链路连接,但 0x7340 配置链路未建立 + case noDeviceFound // 未发现设备(未开机 / 不在范围 / 未配对) + + /// 顶栏 pill 用的极简副标题。 + var shortMessage: String { + switch self { + case .idle, .scanning: return "扫描中…" + case .connecting: return "连接中…" + case .connected: return "已连接" + case .bluetoothOff: return "蓝牙未开启" + case .bluetoothUnauthorized: return "无蓝牙权限" + case .ownedByAgent: return "Agent 占用中" + case .systemConnectedNoConfigLink: return "配置链路未连" + case .noDeviceFound: return "未发现设备" + } + } + + /// 详细可操作说明,供设备信息 / tooltip 展示。 + var detail: String { + switch self { + case .idle: return "正在初始化蓝牙…" + case .scanning: return "正在扫描 AhaKey 设备…" + case .connecting: return "正在连接设备…" + case .connected: return "AhaKey 配置链路 (0x7340) 已连接。" + case .bluetoothOff: return "系统蓝牙未开启。请在「控制中心 / 系统设置 > 蓝牙」打开蓝牙。" + case .bluetoothUnauthorized: return "未授权蓝牙权限。请在「系统设置 > 隐私与安全性 > 蓝牙」中允许 AhaKey Studio。" + case .ownedByAgent: return "蓝牙当前交由 ahakeyconfig-agent 占用,本 App 不直接连接(这是预期行为)。配置链路状态请参考 Agent;如需本 App 直连,请在设备信息里把「蓝牙连接」切回本 App。" + case .systemConnectedNoConfigLink: return "设备已通过系统蓝牙(HID / 语音链路)连接,但 AhaKey 配置服务 0x7340 尚未建立。本 App 正在尝试主动接管该链路;若长时间无效,请在「系统设置 > 蓝牙」忽略此设备后重新配对。" + case .noDeviceFound: return "未发现 AhaKey 设备。请确认设备已开机、处于蓝牙范围内并已与本机配对。" + } + } + + /// 是否为「配置链路完整可用」。 + var isHealthy: Bool { self == .connected } +} + enum OLEDUploadError: LocalizedError { case channelNotReady case noFrames @@ -876,6 +1059,9 @@ enum OLEDUploadError: LocalizedError { case timeout(command: UInt8) case deviceRejected(command: UInt8, status: UInt8) case invalidPictureStatePayload + case invalidDeviceStatusPayload + case invalidCommandFrame + case unsupportedLightingFirmware(main: Int, sub: Int, reportedBrightness: Int) var errorDescription: String? { switch self { @@ -893,6 +1079,12 @@ enum OLEDUploadError: LocalizedError { return String(format: "设备拒绝了命令 0x%02X,状态码 0x%02X", command, status) case .invalidPictureStatePayload: return "设备返回的动画槽位信息无法解析。" + case .invalidDeviceStatusPayload: + return "设备返回的状态信息无法解析。" + case .invalidCommandFrame: + return "待写入的命令帧格式不正确。" + case .unsupportedLightingFirmware(let main, let sub, let reportedBrightness): + return "当前键盘是旧灯效协议固件(设备回报 v\(main).\(sub),亮度字段 \(reportedBrightness)),不会执行 0x84/0x85/0x91。请先刷入 2026-06-22 后的新固件,本次未上报写入成功。" } } } @@ -911,10 +1103,12 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { self.refreshBluetoothAuthorization() self.appendLog("蓝牙已关闭", isError: true) self.bleConnectionStatus = "蓝牙关闭" + self.linkDiagnostic = .bluetoothOff case .unauthorized: self.refreshBluetoothAuthorization() self.appendLog("蓝牙权限未开启", isError: true) self.bleConnectionStatus = "蓝牙权限未开启" + self.linkDiagnostic = .bluetoothUnauthorized default: self.refreshBluetoothAuthorization() break @@ -929,7 +1123,7 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { rssi RSSI: NSNumber ) { let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" - guard name.lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) else { return } + guard Self.matchesDeviceName(name) else { return } Task { @MainActor in self.appendLog("发现设备: \(name) RSSI=\(RSSI)") @@ -949,6 +1143,7 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { self.bleDeviceUUID = peripheral.identifier.uuidString self.lastPeripheralUUID = peripheral.identifier self.bleConnectionStatus = "已连接" + self.linkDiagnostic = .connected self.appendLog("已连接: \(peripheral.name ?? "?") UUID=\(peripheral.identifier.uuidString)") self.autoReconnectTimer?.invalidate() self.autoReconnectTimer = nil @@ -997,6 +1192,7 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { self.dataCharReady = false self.commandCharReady = false self.notifyCharReady = false + self.supportsConfigurableLighting = nil // 不清 peripheral 和 lastPeripheralUUID——用于直连重试 self.peripheral = nil self.writeQueue.removeAll() @@ -1162,6 +1358,9 @@ extension AhaKeyBLEManager: CBPeripheralDelegate { lightMode = status.lightMode switchState = status.switchState brightness = status.brightness + supportsConfigurableLighting = Self.statusAdvertisesConfigurableLighting(brightness: status.brightness) + // 状态帧不是普通的 [cmd,status] ACK,需要在这里单独唤醒 0x00 查询等待者。 + protocolResponseWaiters.removeValue(forKey: 0x00)?.resume(returning: (status: 0, payload: data)) appendLog(" 状态: 电量=\(status.battery) 固件=\(status.firmwareMain).\(status.firmwareSub) 模式=\(status.workMode) 灯=\(status.lightMode) 开关=\(status.switchState) 亮度=\(status.brightness)") } else if AhaKeyResponseParser.isProtocolFrame(data) { if let response = AhaKeyResponseParser.parseCommandResponse(data) { diff --git a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift index 84a51e4b..972fcd48 100644 --- a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift +++ b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift @@ -316,6 +316,27 @@ final class AgentManager: ObservableObject { _ = runLaunchctlQuiet(["load", plistPath]) _ = runLaunchctlQuiet(["start", label]) self.refresh() + + // plist 存在 ≠ agent 真能跑:路径失效 / 崩溃时 launchctl start 不会报错,但 socket + // 永远不出现,键盘会卡在「Agent 占用中」无人连接。等 socket 出现,超时仍无则回退 + // App 直连,保证键盘可用(修:残留/失效 LaunchAgent plist 导致开机卡住)。 + var agentUp = false + let socketPath = self.socketPath + for _ in 0..<10 { // 最多约 2.5s + try? await Task.sleep(nanoseconds: 250 * 1_000_000) + if Self.agentSocketAlive(socketPath: socketPath) { agentUp = true; break } + } + if !agentUp { + log.info("Agent 已安装但未能启动(socket 未出现),临时回退由 App 直连键盘") + bleManager.setSuppressedForAgentOwningKeyboard(false) + if !isLaunch { + self.agentUserAlert = "Agent 已安装但未能启动,已临时由 App 直接连接键盘。请在「更多 → 设备信息 · Agent」里检查或重新安装 Agent。" + } + if !bleManager.isConnected, !bleManager.isScanning { + bleManager.connectAutomatically() + } + self.refresh() + } } } if !isLaunch { @@ -397,6 +418,30 @@ final class AgentManager: ObservableObject { command.contains("ahakeyconfig-agent") || command.contains("ahakey-state") } + /// 真去 connect 一下 agent socket,区分「活着的 agent」和「残留死 socket」。 + /// `checkRunning()` 只看 socket 文件是否存在,会被进程已死但 socket 残留的情况误判。 + nonisolated static func agentSocketAlive(socketPath: String) -> Bool { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var tv = timeval(tv_sec: 1, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + socketPath.withCString { src in + withUnsafeMutablePointer(to: &addr.sun_path) { dst in + _ = strcpy(UnsafeMutableRawPointer(dst).assumingMemoryBound(to: CChar.self), src) + } + } + let ok = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + return ok == 0 + } + private func checkRunning() -> Bool { // 检查 socket 是否存在(agent 运行时会创建) var statBuf = stat() diff --git a/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift b/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift new file mode 100644 index 00000000..588dd555 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift @@ -0,0 +1,85 @@ +import Combine +import Foundation +import VibeBar + +/// 把主 app 的 BLE / 语音服务状态镜像到 VibeBarState,让灵动岛 UI 自动跟随。 +@MainActor +final class VibeBarBridge { + let state = VibeBarState() + private var cancellables = Set() + + func attach( + bleManager: AhaKeyBLEManager, + onOpenMainWindow: @escaping () -> Void + ) { + attach( + bleManager: bleManager, + voiceRelay: .shared, + nativeSpeech: .shared, + onOpenMainWindow: onOpenMainWindow + ) + } + + func attach( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService, + onOpenMainWindow: @escaping () -> Void + ) { + state.onOpenMainWindow = onOpenMainWindow + + // BLE + bleManager.$isConnected + .receive(on: RunLoop.main) + .sink { [weak self] in self?.state.keyboardConnected = $0 } + .store(in: &cancellables) + + bleManager.$batteryLevel + .receive(on: RunLoop.main) + .sink { [weak self] in self?.state.batteryLevel = $0 } + .store(in: &cancellables) + + bleManager.$deviceName + .receive(on: RunLoop.main) + .sink { [weak self] in self?.state.deviceName = $0 } + .store(in: &cancellables) + + // Lever: switchState == 0 即 auto;优先采用 agent 缓存(agent 占用 BLE 时主 app 自己未连接) + Publishers.CombineLatest3( + bleManager.$isConnected, + bleManager.$switchState, + bleManager.$agentSwitchState + ) + .receive(on: RunLoop.main) + .sink { [weak self] connected, switchState, agentSwitchState in + guard let self else { return } + if let agent = agentSwitchState { + self.state.leverKnown = true + self.state.leverIsAuto = (agent == 0) + } else if connected { + self.state.leverKnown = true + self.state.leverIsAuto = (switchState == 0) + } else { + self.state.leverKnown = false + self.state.leverIsAuto = false + } + } + .store(in: &cancellables) + + // 语音 + voiceRelay.$isListening + .receive(on: RunLoop.main) + .sink { [weak self] in self?.state.voiceListening = $0 } + .store(in: &cancellables) + + Publishers.CombineLatest( + nativeSpeech.$isRecording, + nativeSpeech.$isLongPressRecording + ) + .receive(on: RunLoop.main) + .sink { [weak self] recording, longPress in + self?.state.voiceRecording = recording || longPress + } + .store(in: &cancellables) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift index e2c7eb80..29bd8e83 100644 --- a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift @@ -185,10 +185,12 @@ struct AhaKeyStudioView: View { HStack(spacing: 8) { infoPill( title: isEffectivelyConnected ? "已连接" : (bleManager.isScanning ? "扫描中" : "未连接"), - subtitle: bleManager.deviceName ?? "等待设备", + // 未连接时不再笼统显示「等待设备」,而是给出细分链路诊断(Issue #34)。 + subtitle: isEffectivelyConnected ? (bleManager.deviceName ?? "已连接") : bleManager.linkDiagnostic.shortMessage, accent: isEffectivelyConnected ? .green : .orange, width: 118 ) + .help(isEffectivelyConnected ? "" : bleManager.linkDiagnostic.detail) infoPill( title: "电量", subtitle: isEffectivelyConnected ? "\(bleManager.batteryLevel)%" : "—", @@ -214,7 +216,7 @@ struct AhaKeyStudioView: View { ahaTypeModeStatus - configurationModeStatus + configurationModeControl if shouldShowTopBarInstallStartButton { Button("安装启动") { @@ -287,6 +289,10 @@ struct AhaKeyStudioView: View { .lineLimit(1) .truncationMode(.tail) } + Spacer(minLength: 0) + Image(systemName: isEditingConfiguration ? "checkmark.circle" : "pencil.circle") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isEditingConfiguration ? Color.blue : Color.green) } .frame(width: 138, alignment: .leading) .padding(.horizontal, 10) @@ -295,7 +301,18 @@ struct AhaKeyStudioView: View { RoundedRectangle(cornerRadius: 10) .fill(Color(nsColor: .controlBackgroundColor)) ) - .help("日常使用由 Agent 控制键盘;需要改键、LCD 或同步时,进入编辑配置后由 AhaKey Studio 临时接管蓝牙。") + } + + private var configurationModeControl: some View { + Button(action: handleConfigurationModeButton) { + configurationModeStatus + } + .buttonStyle(.plain) + .disabled(isSyncing) + .contentShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityLabel(configurationModeButtonTitle) + .accessibilityHint(configurationModeButtonHelp) + .help(configurationModeButtonHelp) } private var ahaTypeModeStatus: some View { @@ -1389,6 +1406,24 @@ struct AhaKeyStudioView: View { private var lightBarInspector: some View { VStack(alignment: .leading, spacing: 16) { + if bleManager.supportsConfigurableLighting == false { + GroupBox { + VStack(alignment: .leading, spacing: 6) { + Label("当前键盘固件不支持可写灯效", systemImage: "exclamationmark.triangle.fill") + .font(.callout.weight(.semibold)) + .foregroundStyle(.orange) + Text("旧 v1.0 会对未实现的 0x84/0x85/0x91 返回“成功”,但不会改灯。请先刷入 2026-06-22 后的新固件;客户端已禁止把这种假 ACK 当作写入成功。") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if bleManager.supportsConfigurableLighting == true { + Label("新灯效协议已就绪(0x84 / 0x85 / 0x91)", systemImage: "checkmark.seal.fill") + .font(.caption) + .foregroundStyle(.green) + } + GroupBox("状态灯效映射") { VStack(alignment: .leading, spacing: 10) { ForEach(IDEState.workflowOrder) { state in @@ -2158,6 +2193,10 @@ struct AhaKeyStudioView: View { Task { @MainActor in do { + let desiredBrightness = UInt8(max(1, min(100, studioDraft.draft(for: .mode0).lightBar.brightness))) + syncStatusMessage = "正在验证键盘灯效固件…" + try await bleManager.verifyConfigurableLightingSupport(brightness: desiredBrightness) + let uploadedOLEDCount = try await uploadChangedOLEDsToDevice() var commands = commandsForModes(AhaKeyModeSlot.allCases) commands.append((data: AhaKeyCommand.saveConfig(), label: "保存全部配置到设备")) @@ -2168,22 +2207,19 @@ struct AhaKeyStudioView: View { } else { self.syncStatusMessage = "正在写入灯效与键位配置(约 \(total) 条)…" } - self.bleManager.writeCommandsSequentially(commands) { - Task { @MainActor in - // 队列与 50ms 间隔已保证顺序;略等再交还蓝牙,避免固件尚未处理完最后帧。 - try? await Task.sleep(nanoseconds: UInt64(250) * 1_000_000) - self.lastSyncedDraft = self.studioDraft - self.lastSyncDate = Date() - self.isSyncing = false - self.syncStatusMessage = "已全部写入设备并保存。" - if showResultAlert { - self.writeResultAlertMessage = "配置已成功写入键盘。" - self.showsWriteResultAlert = true - } - if returnAgent { - self.returnToKeyboardControl() - } - } + try await self.bleManager.writeCommandsConfirmingResponses(commands) + // 最后的 0x04 已收到固件 ACK,略等再交还蓝牙。 + try? await Task.sleep(nanoseconds: UInt64(150) * 1_000_000) + self.lastSyncedDraft = self.studioDraft + self.lastSyncDate = Date() + self.isSyncing = false + self.syncStatusMessage = "已全部写入设备并保存(所有命令已确认)。" + if showResultAlert { + self.writeResultAlertMessage = "配置已成功写入键盘,并收到固件确认。" + self.showsWriteResultAlert = true + } + if returnAgent { + self.returnToKeyboardControl() } } catch { let message = "写入键盘失败:\(error.localizedDescription)" @@ -2246,12 +2282,17 @@ struct AhaKeyStudioView: View { isSyncing = true syncStatusMessage = "正在写入 \(selectedMode.title)…" - bleManager.writeCommandsSequentially(commands) { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(150) * 1_000_000) + Task { @MainActor in + do { + let desiredBrightness = UInt8(max(1, min(100, currentModeDraft.lightBar.brightness))) + try await bleManager.verifyConfigurableLightingSupport(brightness: desiredBrightness) + try await bleManager.writeCommandsConfirmingResponses(commands) self.lastSyncDate = Date() self.isSyncing = false - self.syncStatusMessage = "已重新发送 \(self.selectedMode.title) 当前模式。" + self.syncStatusMessage = "已重新写入 \(self.selectedMode.title)(固件已确认)。" + } catch { + self.isSyncing = false + self.syncStatusMessage = "写入键盘失败:\(error.localizedDescription)" } } } @@ -2486,6 +2527,10 @@ struct AhaKeyStudioView: View { syncStatusMessage = "已更新虚拟灯效预览;连接键盘后可预览到设备。" return } + guard bleManager.supportsConfigurableLighting != false else { + syncStatusMessage = "当前是旧协议固件,0x91 会假返回成功但不改灯;请先刷新固件。" + return + } bleManager.previewLightEffect(effect.firmwareIndex) syncStatusMessage = "正在预览灯效:\(effect.title)。" } @@ -2495,6 +2540,10 @@ struct AhaKeyStudioView: View { syncStatusMessage = "已更新亮度为 \(value)%;连接键盘后可预览到设备。" return } + guard bleManager.supportsConfigurableLighting != false else { + syncStatusMessage = "当前是旧协议固件,亮度不会写入;请先刷新固件。" + return + } bleManager.setBrightness(UInt8(max(1, min(100, value)))) syncStatusMessage = "正在预览灯光强度:\(value)% 。" } diff --git a/ahakeyconfig-mac/Sources/Views/ContentView.swift b/ahakeyconfig-mac/Sources/Views/ContentView.swift index 6278dc03..f8c0c3c6 100644 --- a/ahakeyconfig-mac/Sources/Views/ContentView.swift +++ b/ahakeyconfig-mac/Sources/Views/ContentView.swift @@ -6,7 +6,6 @@ struct ContentView: View { @StateObject private var voiceRelay = VoiceRelayService.shared @StateObject private var nativeSpeech = NativeSpeechTranscriptionService.shared @AppStorage(UnifiedOnboardingStorage.completedKey) private var unifiedOnboardingCompleted = false - @State private var dismissedIncompleteOnboardingThisSession = false var body: some View { ZStack { @@ -20,7 +19,6 @@ struct ContentView: View { actions: onboardingActions ) { _, _ in unifiedOnboardingCompleted = true - dismissedIncompleteOnboardingThisSession = !onboardingPermissionState.allPermissionsGranted voiceRelay.suppressPermissionOnboarding(for: 60) } .transition(.opacity) @@ -39,7 +37,6 @@ struct ContentView: View { } .onChange(of: onboardingPermissionState.allPermissionsGranted) { allGranted in if allGranted { - dismissedIncompleteOnboardingThisSession = false unifiedOnboardingCompleted = true voiceRelay.showsPermissionOnboarding = false } else if shouldShowUnifiedOnboarding { @@ -78,8 +75,7 @@ struct ContentView: View { } private var shouldShowUnifiedOnboarding: Bool { - !unifiedOnboardingCompleted || - (!onboardingPermissionState.allPermissionsGranted && !dismissedIncompleteOnboardingThisSession) + !unifiedOnboardingCompleted } private var onboardingActions: AhaKeyOnboardingActions { diff --git a/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeyBLEManagerTests.swift b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeyBLEManagerTests.swift new file mode 100644 index 00000000..5dc762fc --- /dev/null +++ b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeyBLEManagerTests.swift @@ -0,0 +1,47 @@ +import CoreBluetooth +import XCTest +@testable import AhaKeyConfig + +final class AhaKeyBLEManagerTests: XCTestCase { + func testBluetoothAuthorizationRequiresExplicitGrant() { + XCTAssertFalse(AhaKeyBLEManager.isBluetoothAuthorizationGranted(.notDetermined)) + XCTAssertFalse(AhaKeyBLEManager.isBluetoothAuthorizationGranted(.restricted)) + XCTAssertFalse(AhaKeyBLEManager.isBluetoothAuthorizationGranted(.denied)) + XCTAssertTrue(AhaKeyBLEManager.isBluetoothAuthorizationGranted(.allowedAlways)) + } + + func testLegacyStatusDoesNotAdvertiseConfigurableLighting() throws { + let legacy = Data([ + 0xAA, 0xBB, 0x00, + 74, 50, 1, 0, 0, 2, 1, 0, + 0xCC, 0xDD, + ]) + let status = try XCTUnwrap(AhaKeyResponseParser.parseDeviceStatus(legacy)) + + XCTAssertEqual(status.brightness, 0) + XCTAssertFalse(AhaKeyBLEManager.statusAdvertisesConfigurableLighting(brightness: status.brightness)) + } + + func testModernStatusAdvertisesConfigurableLighting() throws { + let modern = Data([ + 0xAA, 0xBB, 0x00, + 74, 50, 1, 0, 0, 12, 1, 35, + 0xCC, 0xDD, + ]) + let status = try XCTUnwrap(AhaKeyResponseParser.parseDeviceStatus(modern)) + + XCTAssertEqual(status.lightMode, 12) + XCTAssertEqual(status.brightness, 35) + XCTAssertTrue(AhaKeyBLEManager.statusAdvertisesConfigurableLighting(brightness: status.brightness)) + } + + func testLightingCommandsMatchFirmwareProtocol() { + XCTAssertEqual( + AhaKeyCommand.setLightMapping(mode: 2, stateEffects: Array(7...15)), + Data([0xAA, 0xBB, 0x84, 2, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0xCC, 0xDD]) + ) + XCTAssertEqual(AhaKeyCommand.setBrightness(0), Data([0xAA, 0xBB, 0x85, 1, 0xCC, 0xDD])) + XCTAssertEqual(AhaKeyCommand.previewLightEffect(16), Data([0xAA, 0xBB, 0x91, 16, 0xCC, 0xDD])) + XCTAssertEqual(AhaKeyCommand.saveConfig(), Data([0xAA, 0xBB, 0x04, 0xCC, 0xDD])) + } +} diff --git a/ahakeyconfig-mac/scripts/build.sh b/ahakeyconfig-mac/scripts/build.sh index c560ff15..7df133ef 100755 --- a/ahakeyconfig-mac/scripts/build.sh +++ b/ahakeyconfig-mac/scripts/build.sh @@ -9,7 +9,7 @@ EXECUTABLE_NAME="AhaKeyConfig" APP_BUNDLE_NAME="${APP_BUNDLE_NAME:-AhaKey Studio}" APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AhaKey Studio}" APP_IDENTIFIER="lab.jawa.ahakeyconfig" -MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-12.0}" +MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-13.0}" BUILD_ARCHS="${BUILD_ARCHS:-arm64 x86_64}" OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" APP_BUNDLE="$OUTPUT_DIR/$APP_BUNDLE_NAME.app" diff --git a/vibebar/.gitignore b/vibebar/.gitignore new file mode 100644 index 00000000..353f8680 --- /dev/null +++ b/vibebar/.gitignore @@ -0,0 +1,14 @@ +# SwiftPM +.build/ +.swiftpm/configuration/registries.json +.swiftpm/configuration/checksum-store/ +.swiftpm/xcode/package.xcworkspace/xcuserdata/ +.swiftpm/xcode/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ + +# macOS +.DS_Store + +# Xcode (if opened as xcodeproj from SPM) +DerivedData/ diff --git a/vibebar/Package.resolved b/vibebar/Package.resolved new file mode 100644 index 00000000..7abdb1f3 --- /dev/null +++ b/vibebar/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "290bd38eebfc423c0a0effe5233d16e7ea5183a1222b49f8bf1868e5be647b04", + "pins" : [ + { + "identity" : "dynamicnotchkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MrKai77/DynamicNotchKit", + "state" : { + "revision" : "3c405930fd4d9f8498303683f32492c26d3a9b2b", + "version" : "1.0.0" + } + } + ], + "version" : 3 +} diff --git a/vibebar/Package.swift b/vibebar/Package.swift new file mode 100644 index 00000000..96171639 --- /dev/null +++ b/vibebar/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "VibeBar", + platforms: [.macOS(.v13)], + products: [ + .library(name: "VibeBar", targets: ["VibeBar"]), + .executable(name: "VibeBarSmoke", targets: ["VibeBarSmoke"]), + ], + dependencies: [ + .package(url: "https://github.com/MrKai77/DynamicNotchKit", exact: "1.0.0"), + ], + targets: [ + .target( + name: "VibeBar", + dependencies: [ + .product(name: "DynamicNotchKit", package: "DynamicNotchKit"), + ], + path: "Sources/VibeBar" + ), + .executableTarget( + name: "VibeBarSmoke", + dependencies: ["VibeBar"], + path: "Sources/VibeBarSmoke" + ), + .testTarget( + name: "VibeBarTests", + dependencies: ["VibeBar"], + path: "Tests/VibeBarTests" + ), + ] +) diff --git a/vibebar/Sources/VibeBar/VibeBarController.swift b/vibebar/Sources/VibeBar/VibeBarController.swift new file mode 100644 index 00000000..c5db20cd --- /dev/null +++ b/vibebar/Sources/VibeBar/VibeBarController.swift @@ -0,0 +1,201 @@ +import AppKit +import DynamicNotchKit +import SwiftUI + +@MainActor +public final class VibeBarController { + public static let shared = VibeBarController() + + private var notch: (any DynamicNotchControllable)? + private var pendingCompactTask: Task? + private var pointerTimer: Timer? + private var isHoveringExpanded = false + private var isExpanded = false + private weak var state: VibeBarState? + private var presentationScreen: NSScreen? + private var desiredPresentation: Presentation = .compact + private var presentationTask: Task? + + private enum Presentation: Equatable { + case compact + case expanded + } + + private init() {} + + /// 在主 app 启动后调用一次。多次调用是空操作。 + public func start(state: VibeBarState) { + guard notch == nil else { return } + self.state = state + + let notch = DynamicNotch( + hoverBehavior: [.increaseShadow], + style: .notch + ) { [weak self] in + VibeBarExpandedMenu( + state: state, + onAppear: { self?.expandedMenuAppeared() }, + onHoverChanged: { self?.expandedHoverChanged($0) }, + onCompact: { self?.compactNow() }, + onOpenMain: { state.onOpenMainWindow?() } + ) + } compactLeading: { [weak self] in + VibeBarCompactKeyboardItem(state: state) { hovering in + self?.compactHoverChanged(hovering) + } + } compactTrailing: { [weak self] in + VibeBarCompactLeverItem(state: state) { hovering in + self?.compactHoverChanged(hovering) + } + } + + self.notch = notch + presentationScreen = screenContainingPointer() ?? NSScreen.main ?? NSScreen.screens.first + compactNow() + startPointerTracking() + } + + public func stop() { + pointerTimer?.invalidate() + pointerTimer = nil + pendingCompactTask?.cancel() + pendingCompactTask = nil + presentationTask?.cancel() + presentationTask = nil + notch = nil + state = nil + presentationScreen = nil + } + + // MARK: - Notch state machine + + private func compactNow() { + cancelPendingCompact() + isExpanded = false + isHoveringExpanded = false + requestPresentation(.compact, on: presentationScreen ?? preferredScreen) + } + + private func expandNow(on screen: NSScreen? = nil) { + cancelPendingCompact() + isExpanded = true + requestPresentation(.expanded, on: screen ?? screenContainingPointer() ?? preferredScreen) + } + + private func compactHoverChanged(_ hovering: Bool) { + if hovering { expandNow(on: screenContainingPointer()) } + } + + private func expandedMenuAppeared() { + cancelPendingCompact() + isExpanded = true + } + + private func expandedHoverChanged(_ hovering: Bool) { + isHoveringExpanded = hovering + if hovering { + cancelPendingCompact() + } else { + scheduleCompactIfIdle() + } + } + + private func scheduleCompactIfIdle() { + guard pendingCompactTask == nil else { return } + pendingCompactTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(450)) + guard !Task.isCancelled else { return } + self?.pendingCompactTask = nil + self?.compactIfIdle() + } + } + + private func compactIfIdle() { + guard isExpanded, !isHoveringExpanded, !pointerIsInExpandedInteractionZone else { return } + compactNow() + } + + private func cancelPendingCompact() { + pendingCompactTask?.cancel() + pendingCompactTask = nil + } + + private func startPointerTracking() { + pointerTimer?.invalidate() + pointerTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + Task { @MainActor in self?.expandIfPointerIsInTopHotZone() } + } + RunLoop.main.add(pointerTimer!, forMode: .common) + } + + private func expandIfPointerIsInTopHotZone() { + if !isExpanded { + guard let screen = screenWhoseTopHotZoneContainsPointer() else { return } + expandNow(on: screen) + } else if pointerIsInExpandedInteractionZone { + cancelPendingCompact() + } else if !isHoveringExpanded { + scheduleCompactIfIdle() + } + } + + private func requestPresentation(_ presentation: Presentation, on screen: NSScreen) { + desiredPresentation = presentation + presentationScreen = screen + guard presentationTask == nil else { return } + + presentationTask = Task { @MainActor [weak self] in + await self?.drainPresentationRequests() + } + } + + private func drainPresentationRequests() async { + defer { presentationTask = nil } + + while !Task.isCancelled { + guard let notch else { return } + let requestedPresentation = desiredPresentation + let requestedScreen = presentationScreen ?? preferredScreen + + switch requestedPresentation { + case .compact: + await notch.compact(on: requestedScreen) + case .expanded: + await notch.expand(on: requestedScreen) + } + + let requestIsCurrent = desiredPresentation == requestedPresentation + && presentationScreen?.frame == requestedScreen.frame + if requestIsCurrent { return } + } + } + + private func screenContainingPointer() -> NSScreen? { + let screens = NSScreen.screens + guard let index = VibeBarHoverGeometry.screenIndex( + containing: NSEvent.mouseLocation, + screenFrames: screens.map(\.frame) + ) else { return nil } + return screens[index] + } + + private func screenWhoseTopHotZoneContainsPointer() -> NSScreen? { + let screens = NSScreen.screens + guard let index = VibeBarHoverGeometry.screenIndex( + withTopHotZoneContaining: NSEvent.mouseLocation, + screenFrames: screens.map(\.frame) + ) else { return nil } + return screens[index] + } + + private var pointerIsInExpandedInteractionZone: Bool { + VibeBarHoverGeometry.screenIndex( + withExpandedInteractionZoneContaining: NSEvent.mouseLocation, + screenFrames: NSScreen.screens.map(\.frame) + ) != nil + } + + private var preferredScreen: NSScreen { + presentationScreen ?? screenContainingPointer() ?? NSScreen.main ?? NSScreen.screens[0] + } +} diff --git a/vibebar/Sources/VibeBar/VibeBarHoverGeometry.swift b/vibebar/Sources/VibeBar/VibeBarHoverGeometry.swift new file mode 100644 index 00000000..249fa857 --- /dev/null +++ b/vibebar/Sources/VibeBar/VibeBarHoverGeometry.swift @@ -0,0 +1,37 @@ +import CoreGraphics + +enum VibeBarHoverGeometry { + static let hotZoneWidth: CGFloat = 440 + static let hotZoneHeight: CGFloat = 58 + static let expandedInteractionHeight: CGFloat = 210 + + static func topHotZone(in screenFrame: CGRect) -> CGRect { + CGRect( + x: screenFrame.midX - hotZoneWidth / 2, + y: screenFrame.maxY - hotZoneHeight, + width: hotZoneWidth, + height: hotZoneHeight + ) + } + + static func screenIndex(containing point: CGPoint, screenFrames: [CGRect]) -> Int? { + screenFrames.firstIndex { $0.contains(point) } + } + + static func screenIndex(withTopHotZoneContaining point: CGPoint, screenFrames: [CGRect]) -> Int? { + screenFrames.firstIndex { topHotZone(in: $0).contains(point) } + } + + static func expandedInteractionZone(in screenFrame: CGRect) -> CGRect { + CGRect( + x: screenFrame.midX - hotZoneWidth / 2, + y: screenFrame.maxY - expandedInteractionHeight, + width: hotZoneWidth, + height: expandedInteractionHeight + ) + } + + static func screenIndex(withExpandedInteractionZoneContaining point: CGPoint, screenFrames: [CGRect]) -> Int? { + screenFrames.firstIndex { expandedInteractionZone(in: $0).contains(point) } + } +} diff --git a/vibebar/Sources/VibeBar/VibeBarState.swift b/vibebar/Sources/VibeBar/VibeBarState.swift new file mode 100644 index 00000000..7c32592b --- /dev/null +++ b/vibebar/Sources/VibeBar/VibeBarState.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftUI + +@MainActor +public final class VibeBarState: ObservableObject { + @Published public var keyboardConnected: Bool = false + @Published public var batteryLevel: Int = 0 + @Published public var deviceName: String? = nil + + /// `true` 表示拨杆在 auto 位(switchState == 0),AI 工具调用自动放行 + @Published public var leverIsAuto: Bool = false + /// 是否已经读取到拨杆状态。读不到时默认 ask(fail-safe) + @Published public var leverKnown: Bool = false + + @Published public var voiceListening: Bool = false + @Published public var voiceRecording: Bool = false + + /// 主 app 注入的回调:用户在灵动岛展开菜单里点 "打开主窗口" 时调用 + public var onOpenMainWindow: (() -> Void)? + + public init() {} +} diff --git a/vibebar/Sources/VibeBar/VibeBarViews.swift b/vibebar/Sources/VibeBar/VibeBarViews.swift new file mode 100644 index 00000000..f4cae777 --- /dev/null +++ b/vibebar/Sources/VibeBar/VibeBarViews.swift @@ -0,0 +1,199 @@ +import SwiftUI + +struct VibeBarCompactKeyboardItem: View { + @ObservedObject var state: VibeBarState + let onHoverChanged: (Bool) -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: state.keyboardConnected ? "keyboard.fill" : "keyboard") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(state.keyboardConnected ? .cyan : .secondary) + Text(label) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .contentShape(Rectangle()) + .onHover(perform: onHoverChanged) + } + + private var label: String { + if !state.keyboardConnected { return "—" } + return "\(state.batteryLevel)%" + } +} + +struct VibeBarCompactLeverItem: View { + @ObservedObject var state: VibeBarState + let onHoverChanged: (Bool) -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(color) + Text(label) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .contentShape(Rectangle()) + .onHover(perform: onHoverChanged) + } + + private var icon: String { + guard state.leverKnown else { return "questionmark.circle" } + return state.leverIsAuto ? "lock.open.fill" : "lock.fill" + } + + private var color: Color { + guard state.leverKnown else { return .secondary } + return state.leverIsAuto ? .green : .orange + } + + private var label: String { + guard state.leverKnown else { return "Lever?" } + return state.leverIsAuto ? "Auto" : "Ask" + } +} + +struct VibeBarExpandedMenu: View { + @ObservedObject var state: VibeBarState + let onAppear: () -> Void + let onHoverChanged: (Bool) -> Void + let onCompact: () -> Void + let onOpenMain: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "keyboard") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.cyan) + VStack(alignment: .leading, spacing: 2) { + Text("AhaKey Island") + .font(.system(size: 16, weight: .semibold)) + Text(subtitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + Spacer() + Button(action: onCompact) { + Image(systemName: "chevron.up") + } + .buttonStyle(.plain) + } + + HStack(spacing: 8) { + statusTile( + title: "Device", + systemName: state.keyboardConnected ? "keyboard.fill" : "keyboard", + value: state.keyboardConnected ? "\(state.batteryLevel)%" : "Off", + tint: state.keyboardConnected ? .cyan : .secondary + ) + statusTile( + title: "Lever", + systemName: leverIcon, + value: leverValue, + tint: leverTint + ) + statusTile( + title: "Voice", + systemName: voiceIcon, + value: voiceValue, + tint: voiceTint + ) + statusTile( + title: "Window", + systemName: "macwindow", + value: "Open", + tint: .indigo, + action: onOpenMain + ) + } + + HStack(spacing: 8) { + Spacer() + Text("Move cursor away to collapse") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .frame(width: 420) + .foregroundStyle(.white) + .contentShape(Rectangle()) + .onAppear(perform: onAppear) + .onHover(perform: onHoverChanged) + } + + private var subtitle: String { + if let name = state.deviceName, state.keyboardConnected { + return name + } + return state.keyboardConnected ? "Connected" : "Disconnected" + } + + private var leverIcon: String { + guard state.leverKnown else { return "questionmark.circle" } + return state.leverIsAuto ? "lock.open.fill" : "lock.fill" + } + + private var leverValue: String { + guard state.leverKnown else { return "Unknown" } + return state.leverIsAuto ? "Auto" : "Ask" + } + + private var leverTint: Color { + guard state.leverKnown else { return .secondary } + return state.leverIsAuto ? .green : .orange + } + + private var voiceIcon: String { + if state.voiceRecording { return "mic.fill" } + if state.voiceListening { return "waveform" } + return "mic.slash" + } + + private var voiceValue: String { + if state.voiceRecording { return "Rec" } + if state.voiceListening { return "On" } + return "Off" + } + + private var voiceTint: Color { + if state.voiceRecording { return .red } + if state.voiceListening { return .green } + return .secondary + } + + private func statusTile( + title: String, + systemName: String, + value: String, + tint: Color, + action: (() -> Void)? = nil + ) -> some View { + Button { + action?() + } label: { + VStack(spacing: 4) { + Image(systemName: systemName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(tint) + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + Text(value) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(width: 90, height: 64) + } + .buttonStyle(.borderless) + .disabled(action == nil) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} diff --git a/vibebar/Sources/VibeBarSmoke/VibeBarSmokeApp.swift b/vibebar/Sources/VibeBarSmoke/VibeBarSmokeApp.swift new file mode 100644 index 00000000..832e1f85 --- /dev/null +++ b/vibebar/Sources/VibeBarSmoke/VibeBarSmokeApp.swift @@ -0,0 +1,45 @@ +import AppKit +import SwiftUI +import VibeBar + +@main +struct VibeBarSmokeApp: App { + @NSApplicationDelegateAdaptor(SmokeDelegate.self) private var appDelegate + + var body: some Scene { + Settings { EmptyView() } + } +} + +@MainActor +final class SmokeDelegate: NSObject, NSApplicationDelegate { + private let state = VibeBarState() + private var demoTimer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + + state.keyboardConnected = true + state.batteryLevel = 75 + state.deviceName = "AhaKey-X1 (smoke)" + state.leverIsAuto = true + state.leverKnown = true + state.voiceListening = true + state.voiceRecording = false + state.onOpenMainWindow = { + NSLog("VibeBarSmoke: onOpenMainWindow tapped") + } + + VibeBarController.shared.start(state: state) + + // 周期性翻转一些状态,方便肉眼验证 UI 真的在跟着 state 变 + demoTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.state.leverIsAuto.toggle() + self.state.voiceRecording.toggle() + self.state.batteryLevel = max(5, (self.state.batteryLevel + 95) % 100) + } + } + } +} diff --git a/vibebar/Tests/VibeBarTests/VibeBarHoverGeometryTests.swift b/vibebar/Tests/VibeBarTests/VibeBarHoverGeometryTests.swift new file mode 100644 index 00000000..deef1d46 --- /dev/null +++ b/vibebar/Tests/VibeBarTests/VibeBarHoverGeometryTests.swift @@ -0,0 +1,55 @@ +import CoreGraphics +import XCTest +@testable import VibeBar + +final class VibeBarHoverGeometryTests: XCTestCase { + func testTopHotZoneUsesEachScreensGlobalCoordinates() { + let primary = CGRect(x: 0, y: 0, width: 1_512, height: 982) + let secondary = CGRect(x: -1_920, y: 0, width: 1_920, height: 1_080) + + XCTAssertEqual( + VibeBarHoverGeometry.topHotZone(in: primary), + CGRect(x: 536, y: 924, width: 440, height: 58) + ) + XCTAssertEqual( + VibeBarHoverGeometry.screenIndex( + withTopHotZoneContaining: CGPoint(x: -960, y: 1_070), + screenFrames: [primary, secondary] + ), + 1 + ) + XCTAssertEqual( + VibeBarHoverGeometry.screenIndex( + withExpandedInteractionZoneContaining: CGPoint(x: -960, y: 900), + screenFrames: [primary, secondary] + ), + 1 + ) + } + + func testScreenContainingPointerSupportsNegativeOrigins() { + let primary = CGRect(x: 0, y: 0, width: 1_512, height: 982) + let secondary = CGRect(x: -1_920, y: -200, width: 1_920, height: 1_080) + let frames = [primary, secondary] + + XCTAssertEqual( + VibeBarHoverGeometry.screenIndex( + containing: CGPoint(x: -100, y: 400), + screenFrames: frames + ), + 1 + ) + XCTAssertNil( + VibeBarHoverGeometry.screenIndex( + withTopHotZoneContaining: CGPoint(x: 200, y: 200), + screenFrames: frames + ) + ) + XCTAssertNil( + VibeBarHoverGeometry.screenIndex( + withExpandedInteractionZoneContaining: CGPoint(x: 200, y: 200), + screenFrames: frames + ) + ) + } +}