From 279621a36f0b52b49f9e721699dbd04bc2e66fae Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Sun, 21 Jun 2026 16:32:17 +0800 Subject: [PATCH 1/6] feat(ble): diagnose stuck "waiting for device" by link + auto-take-over (Issue #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS Studio showed a flat "等待设备" whenever the 0x7340 config link wasn't up, so users couldn't tell a half-connected device (HID/voice up, config link down) from a device that's simply absent — see AhakeyAI/desktop#34. Root cause: once the keyboard is connected via the system BLE / voice (HID) link it stops advertising, so the advertising-based scanForPeripherals(withServices:[0x7340]) never finds it, and the retrieveConnectedPeripherals(withServices:[0x7340]) fallback is empty when no one has discovered 0x7340 yet — both fallbacks miss and we scan forever. - Root-cause fix (B): connectAutomatically() now retrieves system-connected peripherals using a wider standard-service set (0x7340/180A/180F/1812), matches by "AhaKey" name prefix, and actively connects; didConnect's discoverServices then brings up 0x7340 even when the device isn't advertising. - Diagnostics (A): new LinkDiagnostic enum distinguishes scanning / connecting / connected / bluetoothOff / bluetoothUnauthorized / ownedByAgent / systemConnectedNoConfigLink / noDeviceFound, each with short + actionable text. Scan timeout re-probes via the wide set to pick the right state. - UI: top-bar pill shows the diagnostic instead of "等待设备", with the detailed explanation as a tooltip. Builds clean (swift build). Runtime behavior against real hardware not yet verified — needs a device exhibiting the issue. Co-Authored-By: Claude Opus 4.8 --- .../Sources/BLE/AhaKeyBLEManager.swift | 112 +++++++++++++++++- .../Sources/Views/AhaKeyStudioView.swift | 4 +- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift index 7799f3e7..46642bbc 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,6 +105,10 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { static let firmwareRevisionCharUUID = CBUUID(string: "2A26") static let modelNumberCharUUID = CBUUID(string: "2A24") + // 标准 HID Service —— 设备被系统蓝牙 / 语音链路连上后常只暴露 HID / DeviceInfo, + // 用它来把「已连接但已停止广播 0x7340」的设备从系统侧捞回来(见 connectAutomatically,Issue #34)。 + static let hidServiceUUID = CBUUID(string: "1812") + nonisolated static let deviceNamePrefix = "AhaKey" // MARK: - Private @@ -214,9 +220,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 +244,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 +273,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 { ($0.name ?? "").lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) } + } + + /// 该设备是否已在系统层暴露过 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 +298,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } isScanning = true bleConnectionStatus = "扫描中…" + linkDiagnostic = .scanning appendLog("开始扫描 AhaKey 设备…") central?.scanForPeripherals( withServices: [Self.serviceUUID], @@ -267,7 +311,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("扫描超时,继续后台轮询设备") + } } } } @@ -868,6 +919,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 +1008,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 @@ -949,6 +1048,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/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)%" : "—", From 92415e271557048f2e42cc644c0ffffd224207a7 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Sun, 21 Jun 2026 16:58:58 +0800 Subject: [PATCH 2/6] fix(ble): recognize "vibe code" firmware name, not just "AhaKey" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CH582m vibe-coding firmware advertises as "vibe code XXXX", but both the app and the standalone agent only matched a hard-coded "AhaKey" name prefix, so such devices were filtered out of scan results and connected-peripheral lookups — the app scanned forever / showed "未发现设备". Replace the single deviceNamePrefix with a prefix allow-list ["AhaKey", "vibe code"] plus a matchesDeviceName() helper, applied in both AhaKeyBLEManager (scan callback + wide-service system-connected fallback) and AhaKeyAgent. Verified end to end: app auto-connects to a real "vibe code B1D2" device. Co-Authored-By: Claude Opus 4.8 --- ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift | 12 +++++++++--- .../Sources/BLE/AhaKeyBLEManager.swift | 14 +++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) 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/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift index 46642bbc..95ac7d86 100644 --- a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -109,7 +109,15 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { // 用它来把「已连接但已停止广播 0x7340」的设备从系统侧捞回来(见 connectAutomatically,Issue #34)。 static let hidServiceUUID = CBUUID(string: "1812") - nonisolated static let deviceNamePrefix = "AhaKey" + /// 设备广播名前缀白名单。除官方 "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 @@ -282,7 +290,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { Self.hidServiceUUID, ] let connected = central?.retrieveConnectedPeripherals(withServices: lookupServices) ?? [] - return connected.first { ($0.name ?? "").lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) } + return connected.first { Self.matchesDeviceName($0.name) } } /// 该设备是否已在系统层暴露过 0x7340 配置 service:用于区分「完整可连」与「仅 HID / 语音链路」。 @@ -1028,7 +1036,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)") From 279e0d22853490c09b7c0af0bc451c22e3e22c1b Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Mon, 22 Jun 2026 09:51:07 +0800 Subject: [PATCH 3/6] fix(agent): fall back to App-direct BLE when a stale/broken LaunchAgent can't run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a LaunchAgent plist exists but the agent can't actually run (e.g. it points at a stale build path, or the binary crashes), `launchctl start` does not error and the socket never appears — so the keyboard stays "given to the agent" with nobody actually connecting it, and the app is stuck on "Agent 占用中" until the user manually re-connects. (Hit in practice via a 6/2 plist pointing at a deleted Xcode DerivedData path.) In the agentDaemon branch, after load+start, wait up to ~2.5s for the agent to come up; if it doesn't, release BLE suppression and let the app connect directly (plus a non-launch user alert). Liveness is checked with a real connect() probe (agentSocketAlive) rather than checkRunning()'s file-exists test, so a leftover dead socket no longer masks a non-running agent. Verified: with a broken plist + no agent, app falls back and scans/connects; with a clean state it auto-connects to "vibe code B1D2" on launch. Co-Authored-By: Claude Opus 4.8 --- .../Sources/Utilities/AgentManager.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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() From 4e0d564b097d16d495773e267e90b2fe3695c5df Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Mon, 22 Jun 2026 10:02:49 +0800 Subject: [PATCH 4/6] fix(ble): stop status polling from dying permanently on an unanswered command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI stopped reflecting live keyboard changes (knob/mode/light) until a manual reconnect. Root cause: status polling is gated by `guard protocolResponseWaiters.isEmpty`, and that set could leak forever. In sendCommandAwaitingResponse the timeout child task only `throw`s; it never resumes the pending CheckedContinuation. Since CheckedContinuation ignores task cancellation, withThrowingTaskGroup keeps awaiting the never-finishing waiter task on timeout, so the function never returns, its `defer` cleanup never runs, and the waiter entry stays in protocolResponseWaiters — permanently blocking the 1.5s status poll. Reproduced on connect: reading picture-state for a mode the firmware doesn't answer (mode=3) left waiter[0x83] stuck. Fix: on timeout, atomically remove the waiter and resume its continuation (throwing) on the main actor before throwing, so the group can finish, defer runs, and the waiter set drains. removeValue is atomic vs the response handler, so no double-resume. Verified: in App-direct mode the app now polls every ~1.5s and picks up live mode changes (3->0) without a reconnect. Co-Authored-By: Claude Opus 4.8 --- ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift index 95ac7d86..48cc9817 100644 --- a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -755,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) } From 260a49ab3e51dbd0ebfc8aead24129ccc619c597 Mon Sep 17 00:00:00 2001 From: ZephyrKeXiner <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:00:27 +0800 Subject: [PATCH 5/6] feat(vibebar): integrate dynamic island into main app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls vibebar (notch-style dynamic island, based on DynamicNotchKit) into the repo as a SwiftPM sub-package and wires it through the main macOS app as an in-process module rather than a sibling executable. The island now reflects live app state — keyboard connection + battery, lever auto/ask flag, and voice-agent activity — and offers a tile that calls back into the app to reopen the main window. - vibebar/ — new sub-package: VibeBar library + VibeBarSmoke executable for standalone UI smoke tests; depends on DynamicNotchKit 1.0.0, pinned at 3c40593 - ahakeyconfig-mac/Package.swift — split AhaKeyPluginKit/AhaKeyPlugin/ PluginShowcase into their own targets so the root package actually compiles; bump min macOS to 13 (DynamicNotchKit floor); depend on ../vibebar and link VibeBar into AhaKeyConfig - ahakeyconfig-mac/scripts/build.sh — bump default MACOS_DEPLOYMENT_TARGET to 13 to match the package floor - AhaKeyConfigApp.swift — own a VibeBarBridge, attach on .onAppear, start VibeBarController.shared with the bridged state - VibeBarBridge.swift — Combine mirror of BLE manager + voice services onto VibeBarState; lever maps switchState==0 to auto, preferring the agent's cached value when the app's own BLE link is dormant; fail-safe to leverKnown=false when neither source has answered Root Package.swift also updated for parity (path: "vibebar") so swift build from the repo root keeps working. --- Package.resolved | 15 ++ Package.swift | 28 ++- ahakeyconfig-mac/Package.resolved | 14 ++ ahakeyconfig-mac/Package.swift | 10 +- .../Sources/AhaKeyConfigApp.swift | 8 + .../Sources/Utilities/VibeBarBridge.swift | 73 +++++++ ahakeyconfig-mac/scripts/build.sh | 2 +- vibebar/.gitignore | 14 ++ vibebar/Package.resolved | 15 ++ vibebar/Package.swift | 28 +++ .../Sources/VibeBar/VibeBarController.swift | 132 ++++++++++++ vibebar/Sources/VibeBar/VibeBarState.swift | 22 ++ vibebar/Sources/VibeBar/VibeBarViews.swift | 199 ++++++++++++++++++ .../VibeBarSmoke/VibeBarSmokeApp.swift | 45 ++++ 14 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 Package.resolved create mode 100644 ahakeyconfig-mac/Package.resolved create mode 100644 ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift create mode 100644 vibebar/.gitignore create mode 100644 vibebar/Package.resolved create mode 100644 vibebar/Package.swift create mode 100644 vibebar/Sources/VibeBar/VibeBarController.swift create mode 100644 vibebar/Sources/VibeBar/VibeBarState.swift create mode 100644 vibebar/Sources/VibeBar/VibeBarViews.swift create mode 100644 vibebar/Sources/VibeBarSmoke/VibeBarSmokeApp.swift 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/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/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/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) + } + } + } +} From be433597bcab4d58dd5d4b34079548f9eab54188 Mon Sep 17 00:00:00 2001 From: ZephyrKeXiner <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:00:27 +0800 Subject: [PATCH 6/6] fix(onboarding): stop re-showing onboarding after first completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the main window with the red close button doesn't terminate the app (macOS convention), so reopening the window rebuilds ContentView and resets all @State. That meant dismissedIncompleteOnboardingThisSession went back to false every time, and as long as any permission was still missing the unified onboarding sheet popped up again — even though the user had already been through it. Per product intent: once unifiedOnboardingCompleted is true, never show the onboarding again. Permission gaps surface through the main UI's own indicators instead. Drops the session-scoped dismissal flag entirely. --- ahakeyconfig-mac/Sources/Views/ContentView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 {