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..56334c84 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: [ 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..3425d9ae 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 可识别。 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..48cc9817 100644 --- a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -63,6 +63,8 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { @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 +105,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 @@ -214,9 +228,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 +252,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 +281,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 +306,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } isScanning = true bleConnectionStatus = "扫描中…" + linkDiagnostic = .scanning appendLog("开始扫描 AhaKey 设备…") central?.scanForPeripherals( withServices: [Self.serviceUUID], @@ -267,7 +319,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("扫描超时,继续后台轮询设备") + } } } } @@ -696,8 +755,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 +935,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 @@ -911,10 +1024,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 +1044,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 +1064,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 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..108a0e6a --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift @@ -0,0 +1,73 @@ +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, + voiceRelay: VoiceRelayService = .shared, + nativeSpeech: NativeSpeechTranscriptionService = .shared, + 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..9ef52068 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)%" : "—", 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/scripts/build.sh b/ahakeyconfig-mac/scripts/build.sh index 6c2bbf3f..e296085b 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..1e2eb6c1 --- /dev/null +++ b/vibebar/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.0 +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" + ), + ] +) diff --git a/vibebar/Sources/VibeBar/VibeBarController.swift b/vibebar/Sources/VibeBar/VibeBarController.swift new file mode 100644 index 00000000..36a62a9b --- /dev/null +++ b/vibebar/Sources/VibeBar/VibeBarController.swift @@ -0,0 +1,132 @@ +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 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 + startPointerTracking() + compactNow() + } + + public func stop() { + pointerTimer?.invalidate() + pointerTimer = nil + pendingCompactTask?.cancel() + pendingCompactTask = nil + notch = nil + state = nil + } + + // MARK: - Notch state machine + + private func compactNow() { + pendingCompactTask?.cancel() + isExpanded = false + isHoveringExpanded = false + Task { await notch?.compact(on: targetScreen) } + } + + private func expandNow() { + pendingCompactTask?.cancel() + isExpanded = true + Task { await notch?.expand(on: targetScreen) } + } + + private func compactHoverChanged(_ hovering: Bool) { + if hovering { expandNow() } + } + + private func expandedMenuAppeared() { + pendingCompactTask?.cancel() + isExpanded = true + } + + private func expandedHoverChanged(_ hovering: Bool) { + isHoveringExpanded = hovering + if hovering { + pendingCompactTask?.cancel() + } else { + scheduleCompactIfIdle() + } + } + + private func scheduleCompactIfIdle() { + pendingCompactTask?.cancel() + pendingCompactTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(450)) + self?.compactIfIdle() + } + } + + private func compactIfIdle() { + guard isExpanded, !isHoveringExpanded else { return } + compactNow() + } + + 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() { + guard !isExpanded, topHotZone.contains(NSEvent.mouseLocation) else { return } + expandNow() + } + + private var topHotZone: CGRect { + let screen = targetScreen + let width: CGFloat = 440 + let height: CGFloat = 58 + return CGRect( + x: screen.frame.midX - width / 2, + y: screen.frame.maxY - height, + width: width, + height: height + ) + } + + private var targetScreen: NSScreen { + NSScreen.main ?? NSScreen.screens.first! + } +} 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) + } + } + } +}