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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 25 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
14 changes: 14 additions & 0 deletions ahakeyconfig-mac/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions ahakeyconfig-mac/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ 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(
Expand All @@ -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 可识别。
Expand Down
12 changes: 9 additions & 3 deletions ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
134 changes: 125 additions & 9 deletions ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 颜色实时还原
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -248,13 +281,32 @@ 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
return
}
isScanning = true
bleConnectionStatus = "扫描中…"
linkDiagnostic = .scanning
appendLog("开始扫描 AhaKey 设备…")
central?.scanForPeripherals(
withServices: [Self.serviceUUID],
Expand All @@ -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("扫描超时,继续后台轮询设备")
}
}
}
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)")
Expand All @@ -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
Expand Down
Loading
Loading