diff --git a/.gitignore b/.gitignore index d6677d41..017084cf 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ logs/ **/config_client.json **/settings.json.bak.* **/hooks.json.bak.* +scripts/build.local.env # Local backups / temp *.bak @@ -72,3 +73,19 @@ logs/ # Local model / runtime payloads internal/ .gstack/ + +# Java / Maven build output +target/ +*.class + +# Swift build artifacts +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate + +# Packaged outputs +dist/ + +# Local env (signing creds etc.) +scripts/build.local.env diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/App.config b/BLE_tcp_bridge/App.config similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/App.config rename to BLE_tcp_bridge/App.config diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/AppConfig.cs b/BLE_tcp_bridge/AppConfig.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/AppConfig.cs rename to BLE_tcp_bridge/AppConfig.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/BLE_tcp_driver.csproj b/BLE_tcp_bridge/BLE_tcp_driver.csproj similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/BLE_tcp_driver.csproj rename to BLE_tcp_bridge/BLE_tcp_driver.csproj diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/BLE_tcp_driver.sln b/BLE_tcp_bridge/BLE_tcp_driver.sln similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/BLE_tcp_driver.sln rename to BLE_tcp_bridge/BLE_tcp_driver.sln diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Form1.Designer.cs b/BLE_tcp_bridge/Form1.Designer.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Form1.Designer.cs rename to BLE_tcp_bridge/Form1.Designer.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Form1.cs b/BLE_tcp_bridge/Form1.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Form1.cs rename to BLE_tcp_bridge/Form1.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Form1.resx b/BLE_tcp_bridge/Form1.resx similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Form1.resx rename to BLE_tcp_bridge/Form1.resx diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Program.cs b/BLE_tcp_bridge/Program.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Program.cs rename to BLE_tcp_bridge/Program.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/AssemblyInfo.cs b/BLE_tcp_bridge/Properties/AssemblyInfo.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/AssemblyInfo.cs rename to BLE_tcp_bridge/Properties/AssemblyInfo.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Resources.Designer.cs b/BLE_tcp_bridge/Properties/Resources.Designer.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Resources.Designer.cs rename to BLE_tcp_bridge/Properties/Resources.Designer.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Resources.resx b/BLE_tcp_bridge/Properties/Resources.resx similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Resources.resx rename to BLE_tcp_bridge/Properties/Resources.resx diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Settings.Designer.cs b/BLE_tcp_bridge/Properties/Settings.Designer.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Settings.Designer.cs rename to BLE_tcp_bridge/Properties/Settings.Designer.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Settings.settings b/BLE_tcp_bridge/Properties/Settings.settings similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Properties/Settings.settings rename to BLE_tcp_bridge/Properties/Settings.settings diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Protocol.cs b/BLE_tcp_bridge/Protocol.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/Protocol.cs rename to BLE_tcp_bridge/Protocol.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/TcpServer.cs b/BLE_tcp_bridge/TcpServer.cs similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/TcpServer.cs rename to BLE_tcp_bridge/TcpServer.cs diff --git a/platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/readme.md b/BLE_tcp_bridge/readme.md similarity index 100% rename from platforms/windows/BLE_tcp_bridge_for_vibe_code-master (1)/BLE_tcp_bridge_for_vibe_code-master/readme.md rename to BLE_tcp_bridge/readme.md diff --git a/platforms/macos/Makefile b/Makefile similarity index 100% rename from platforms/macos/Makefile rename to Makefile diff --git a/platforms/macos/Package.swift b/Package.swift similarity index 100% rename from platforms/macos/Package.swift rename to Package.swift diff --git a/platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist b/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist similarity index 100% rename from platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist rename to Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist diff --git a/platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo.plist b/Packaging/AhaKeyConfig-EmbeddedInfo.plist similarity index 100% rename from platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo.plist rename to Packaging/AhaKeyConfig-EmbeddedInfo.plist diff --git a/README-sharing.md b/README-sharing.md new file mode 100644 index 00000000..bbcce8d1 --- /dev/null +++ b/README-sharing.md @@ -0,0 +1,169 @@ +# AhaKeyConfig — AhaKey-X1 原生 macOS 配置工具 + +## 项目简介 + +这是一个用 Swift + SwiftUI 写的 macOS 原生应用,直接通过 CoreBluetooth 连接 AhaKey-X1(Vibecoding Keyboard),**不需要 TCP 桥接层**。 + +原厂工具的架构是 Python (PySide6) → .NET TCP Bridge → Swift BLE Helper,三层转接。这个项目把整个链路压缩成一个 Swift 应用,直连 BLE。 + +### 功能 + +- BLE 自动扫描/连接/重连 AhaKey-X1 设备 +- 设备状态查询(电量、固件版本、工作模式、灯光、拨杆) +- 4 键 × 3 模式的键位映射(快捷键 + LCD 描述文字) +- IDE 状态同步 → LED 变色(Claude Code hooks 集成) +- LCD 图片/GIF 管理界面(协议已实现,上传功能 TODO) +- 后台守护进程维持 BLE 连接(LaunchAgent) + +### 协议实现完整度 + +BLE 协议已从原厂工具反编译完整还原,包括: +- 帧格式(AA BB ... CC DD) +- 全部 DeviceCmd(0x00-0x90) +- 按键配置(快捷键/宏/描述 三种子类型) +- 大数据写入流程( LCD 图片) +- IDE 状态同步(0x90) + +详见 `docs/ble-protocol.md`。 + +--- + +## 构建说明 + +### 环境要求 + +- macOS 15.0+ +- Xcode 15+(或等效 Swift toolchain) +- Swift 5.9+ +- Apple Silicon(arm64) + +### 编译 + +```bash +# 仅编译 +swift build -c release --arch arm64 --product AhaKeyConfig + +# 编译 + 打包 .app bundle(含图标生成、代码签名) +bash scripts/build.sh + +# 编译 + 安装到 /Applications +INSTALL_TO_APPLICATIONS=1 bash scripts/build.sh + +# 编译 + 安装 + 启动 +INSTALL_TO_APPLICATIONS=1 LAUNCH_AFTER_INSTALL=1 bash scripts/build.sh +``` + +也可以用 Makefile: + +```bash +make build # 等同 bash scripts/build.sh +make install # 等同 INSTALL_TO_APPLICATIONS=1 LAUNCH_AFTER_INSTALL=1 bash scripts/build.sh +``` + +### 代码签名 + +`scripts/build.sh` 会自动查找本机的 Developer ID Application 或 Apple Development 证书。如需指定: + +```bash +SIGNING_IDENTITY="你的签名身份" bash scripts/build.sh +``` + +**重要**:当前 bundle ID 是 `lab.jawa.ahakeyconfig`,你们需要改成自己的(搜索替换即可,出现在 `Package.swift` 的 product name 不需要改,主要改 `scripts/build.sh` 里的 `APP_IDENTIFIER` 和源码里的 Logger subsystem)。 + +### 蓝牙权限 + +App 需要 Bluetooth entitlement 才能使用 CoreBluetooth: + +```xml +com.apple.security.device.bluetooth + +``` + +`scripts/build.sh` 会自动生成 entitlements 文件并签名。如果用 Xcode 打开项目,需要在 Signing & Capabilities 里手动添加 Bluetooth。 + +--- + +## 项目结构 + +``` +ahakeyconfig/ +├── Package.swift # SPM 包定义,两个 target +├── Makefile # 快捷构建命令 +├── scripts/ +│ ├── build.sh # 完整构建脚本(编译+打包+签名+安装) +│ ├── ahakey-state.sh # Claude hooks 用的 LED 状态同步脚本 +│ └── generate_icons.swift # 程序化生成 app icon +├── Sources/ +│ ├── AhaKeyConfigApp.swift # App 入口,单实例控制 +│ ├── BLE/ +│ │ ├── AhaKeyProtocol.swift # ★ 协议编解码(帧格式、命令、HID 键码表) +│ │ └── AhaKeyBLEManager.swift # ★ CoreBluetooth 通信管理器 +│ ├── Views/ +│ │ ├── ContentView.swift # Tab 容器 +│ │ ├── DeviceInfoView.swift # 设备信息 + LED 测试 + BLE 日志 +│ │ ├── KeyMappingView.swift # 键位映射配置 + 预设方案 +│ │ └── LCDManagerView.swift # LCD 图片/GIF 管理 +│ ├── Utilities/ +│ │ └── AgentManager.swift # LaunchAgent 守护进程管理 + Claude hooks 安装 +│ └── Agent/ +│ ├── AhaKeyAgent.swift # 轻量 BLE 守护进程(Unix socket 接收状态命令) +│ └── main.swift # Agent 入口 +├── docs/ +│ └── ble-protocol.md # ★ 完整 BLE 协议文档 +└── .gitignore +``` + +### 重点文件 + +| 文件 | 说明 | +|------|------| +| `Sources/BLE/AhaKeyProtocol.swift` | 你们的协议完整实现——帧编解码、所有 DeviceCmd、HID 键码表、响应解析 | +| `Sources/BLE/AhaKeyBLEManager.swift` | 直接 CoreBluetooth 通信,自动扫描/连接/重连,写入队列防过载 | +| `docs/ble-protocol.md` | 协议完整文档(从原厂工具反编译 + 抓包验证) | +| `Sources/Agent/AhaKeyAgent.swift` | 后台守护进程,通过 Unix socket 接收 LED 状态命令 | +| `Sources/Utilities/AgentManager.swift` | Claude Code hooks 自动安装/卸载(追加模式,不覆盖已有 hooks) | + +--- + +## 架构说明 + +### 与原厂工具的对比 + +``` +原厂: Python (PySide6) ←TCP→ .NET (BleTcpBridge) ←stdin/stdout→ Swift (ble_helper) +本项目: Swift (SwiftUI + CoreBluetooth) ──BLE──> 键盘 +``` + +省掉了 TCP 桥接层,连接更稳定,延迟更低,部署更简单(单个 .app)。 + +### 双进程架构 + +1. **AhaKeyConfig**(主进程):SwiftUI GUI,键位配置、设备管理、LCD 管理 +2. **ahakeyconfig-agent**(守护进程):无 UI,LaunchAgent 管理,维持 BLE 连接 + 接收 Unix socket 命令 + +守护进程的存在是为了在 GUI 关闭后仍能接收 Claude Code hooks 的 LED 状态推送。 + +### LED 状态同步流程 + +``` +Claude Code hook → ahakey-state.sh → Unix socket → ahakeyconfig-agent → BLE 0x90 → 键盘 LED +``` + +--- + +## 已知限制 / TODO + +1. ** LCD 图片上传**:协议已文档化(见 `ble-protocol.md` 第 8 节),UI 已有,但分包上传逻辑未实现 +2. **宏录入 UI**:协议支持宏(sub_type 0x74),但 UI 只做了快捷键映射 +3. **多模式切换**:代码支持 3 种工作模式(mode 0/1/2),但 UI 目前只操作 mode 0 +4. **拨杆联动**:原厂的拨杆→自动授权功能未移植(需要在 PermissionRequest hook 中查询 SwitchState) + +--- + +## 许可说明 + +本项目无偿共享,欢迎自由使用、修改、集成到你们的官方工具中。无需署名,无附加条件。 + +协议文档 (`docs/ble-protocol.md`) 基于原厂工具反编译整理,版权归原始作者所有。 + +如有问题欢迎随时沟通。 diff --git a/Resources/AppIcon/AhaKeyStudioIcon.png b/Resources/AppIcon/AhaKeyStudioIcon.png new file mode 100644 index 00000000..43f3bd26 Binary files /dev/null and b/Resources/AppIcon/AhaKeyStudioIcon.png differ diff --git a/platforms/macos/Resources/DefaultOLED/claude_0.gif b/Resources/DefaultOLED/claude_0.gif similarity index 100% rename from platforms/macos/Resources/DefaultOLED/claude_0.gif rename to Resources/DefaultOLED/claude_0.gif diff --git a/Resources/DefaultOLED/codex.gif b/Resources/DefaultOLED/codex.gif new file mode 100644 index 00000000..7558413c Binary files /dev/null and b/Resources/DefaultOLED/codex.gif differ diff --git a/Resources/DefaultOLED/cursor.gif b/Resources/DefaultOLED/cursor.gif new file mode 100644 index 00000000..53bb3758 Binary files /dev/null and b/Resources/DefaultOLED/cursor.gif differ diff --git a/Resources/Help/AhaKeyStudioHelp.html b/Resources/Help/AhaKeyStudioHelp.html new file mode 100644 index 00000000..41337bab --- /dev/null +++ b/Resources/Help/AhaKeyStudioHelp.html @@ -0,0 +1,162 @@ + + + + + + AhaKey Studio 帮助中心 + + + +
+

AhaKey Studio 帮助中心

+

这份本地说明对应当前客户端界面和代码逻辑,适用于首次设置、权限排查、AhaType 云端整理和键盘配置。

+ +

首次启动流程

+

首次打开 AhaKey Studio 会进入新手引导。引导顶部按步骤显示,底部使用“上一步”和“下一步”推进。

+
+
+ 欢迎 + 介绍连接键盘、后台语音键接管、macOS 原生语音和体验输入。 +
+
+ 系统授权 + 检查蓝牙、输入监控和辅助功能。蓝牙用于发现和连接键盘,输入监控与辅助功能用于后台接管实体语音键。 +
+
+ 语音授权 + 检查麦克风、语音转写、Siri 与听写。默认 macOS 原生语音需要这些能力都可用。 +
+
+ 开始体验 + 使用客户端内同一套语音链路试说一句话,确认识别和写入链路正常。 +
+
+ +

需要开启的系统权限

+
    +
  1. 蓝牙:在“隐私与安全性 > 蓝牙”允许 AhaKey Studio,并确保系统蓝牙已打开。
  2. +
  3. 输入监控:在“隐私与安全性 > 输入监控”允许 AhaKey Studio,用于后台监听实体语音键。
  4. +
  5. 辅助功能:在“隐私与安全性 > 辅助功能”允许 AhaKey Studio,用于把语音键转换成 macOS 原生转写或 Fn/Globe 行为。
  6. +
  7. 麦克风:在“隐私与安全性 > 麦克风”允许 AhaKey Studio,用于苹果原生语音采集。
  8. +
  9. 语音转写:在“隐私与安全性 > 语音识别”允许 AhaKey Studio,用于系统语音识别。
  10. +
  11. Siri:在“系统设置 > Siri 与聚焦”开启 Siri。
  12. +
  13. 听写:在“系统设置 > 键盘 > 听写”开启听写。
  14. +
+
如果系统设置里已经打开权限,但客户端仍显示未开启,请完全退出 AhaKey Studio 后重新打开。输入监控和辅助功能常按进程生效,重启后状态会与系统设置一致。
+ +

主界面区域

+

顶部状态栏

+

顶部显示设备连接状态、电量、拨杆状态、AhaType 开关和键盘控制状态。“更多”菜单提供重新连接设备、清空 LCD 预览、设备信息、云端账号和退出等入口。

+ +

Keyboard Mode

+

Mode 标签对应键盘不同工作模式。Claude Code、Cursor、Codex 三个预设会影响实体按键写入内容和语音键路由。

+ +

配置区

+

点击灯条、屏幕、四个按键或拨杆可以进入对应配置。修改会先保存到本地,连接设备并进入编辑配置后再同步到键盘。

+ +

右侧检查面板

+

“后台监听中”展示输入监控和辅助功能状态;“苹果原生转写”展示麦克风、语音转写、Siri 与听写状态;“AhaType 云端整理”展示云端整理开关、账号状态和额度信息。

+ +

AhaType 云端整理

+

开启 AhaType 后,语音识别结果会先发送到云端整理,再写入当前光标。若未登录、登录过期或额度不足,客户端会退回写入原始转写文本。

+

可在“更多 > 云端账号 · AhaType”登录、注册、刷新额度、兑换或购买额度。

+ +

语音键体验

+
    +
  1. 保持 AhaKey Studio 在后台运行。
  2. +
  3. 确认权限检查里输入监控、辅助功能、麦克风、语音转写、Siri 与听写均已开启。
  4. +
  5. 确认设备当前在 Mode 0,或在目标 Mode 中已配置语音键。
  6. +
  7. 按一次实体语音键开始录音,再按一次结束并写入当前光标。
  8. +
+ +

底部状态栏

+

底部会显示当前选中的部件和模式、未同步改动数量、同步提示和最近同步时间。右下角的“新手引导”可重新进入分步引导,“帮助中心”会打开本文件。

+ +

常见问题

+

为什么麦克风已打开仍提示未开启?

+

请完全退出并重新打开 AhaKey Studio。若应用路径或签名发生变化,macOS 可能把新版本视为另一个应用,需要对当前正式包重新授权。

+ +

为什么系统设置没有自动跳到准确页面?

+

不同 macOS 版本的系统设置 URL 支持不完全一致。客户端会优先打开最接近的权限页;如果没有定位到目标项,请在“隐私与安全性”中手动选择对应权限。

+ +

为什么连接状态显示由 Agent 控制?

+

日常使用由后台 Agent 持有蓝牙连接;需要改键、LCD、同步或本机灯效测试时,点击“编辑配置”由 AhaKey Studio 临时接管蓝牙。

+
+ + diff --git a/Sources/Agent/AhaKeyAgent.swift b/Sources/Agent/AhaKeyAgent.swift new file mode 100644 index 00000000..0cf82a3d --- /dev/null +++ b/Sources/Agent/AhaKeyAgent.swift @@ -0,0 +1,586 @@ +import CoreBluetooth +import Foundation +import os.log + +private let log = Logger(subsystem: "lab.jawa.ahakeyconfig.agent", category: "BLE") + +/// 设备 8 字节状态解析结果。 +/// +/// 与 Sources/BLE/AhaKeyProtocol.swift 的 `AhaKeyDeviceStatus` 保持同构; +/// Agent 是独立 target,不共享源码,所以这里内联一份极简解析器。 +struct AgentDeviceStatus { + let battery: Int + let signal: Int + let firmwareMain: Int + let firmwareSub: Int + let workMode: Int + let lightMode: Int + let switchState: Int +} + +/// 轻量 BLE 守护进程:维持连接 + 接收 Unix socket 命令 → 发送 LED 状态 / 回传拨杆状态 +final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { + private var central: CBCentralManager! + private var peripheral: CBPeripheral? + private var commandChar: CBCharacteristic? + private var notifyChar: CBCharacteristic? + private var lastUUID: UUID? + private let serviceUUID = CBUUID(string: "7340") + private let commandCharUUID = CBUUID(string: "7343") + private let notifyCharUUID = CBUUID(string: "7344") + private let deviceNamePrefix = "vibe code" + private let socketPath: String + + private let header: [UInt8] = [0xAA, 0xBB] + private let trailer: [UInt8] = [0xCC, 0xDD] + + // MARK: 缓存(供 hook 查询使用) + /// 最新 switchState(0=auto, 1=manual),未知时 nil + private(set) var cachedSwitchState: UInt8? + /// 最新 lightMode + private(set) var cachedLightMode: UInt8? + /// 用户刚点击画布虚拟拨杆时的短暂模拟值。它只用于等待设备下一次真实状态回包, + /// 真实硬件状态一到就立即清除,不能覆盖物理拨杆。 + private(set) var userSwitchOverride: UInt8? + + private static let switchOverrideDefaultsKey = "lab.jawa.ahakeyconfig.agent.userSwitchOverride" + + /// 等待下一次 status 回包的回调队列(用于 querySwitchState) + private var statusWaiters: [(AgentDeviceStatus?) -> Void] = [] + /// 工具完成 / 用户提交等短暂态的自动回落。 + private var pendingStateReset: DispatchWorkItem? + /// 固件不会在拨杆变动时主动通知,因此 Agent 占用蓝牙时也必须轮询真实状态。 + private var statusPollTimer: DispatchSourceTimer? + + // MARK: 看门狗(Claude Code 手动停止任务时 Stop hook 不触发,超时后自动归位) + /// 最近一次 hook 发来状态命令的时间(nil = 尚未收到) + private var lastHookStateAt: Date? + /// 最近一次我们主动发给键盘的 LED 状态 + private var lastSentState: UInt8 = 0 + private var watchdogTimer: DispatchSourceTimer? + + /// 各活跃态超时时长(秒): + /// 1=PermissionRequest / 7=UserPromptSubmit → 30s(等待阶段,手动停止后无 hook 跟进) + /// 其余工具执行态 → 60s(工具可能运行较久,避免误触发) + private func watchdogTimeout(for state: UInt8) -> Double { + switch state { + case 1, 7: return 30 // PermissionRequest / UserPromptSubmit:短超时 + default: return 60 // PreToolUse / PostToolUse / SessionStart / TaskCompleted + } + } + + var onLog: ((String) -> Void)? + + init(socketPath: String = "/tmp/ahakey.sock") { + self.socketPath = socketPath + // 旧版会持久化虚拟拨杆覆盖,导致真实硬件档位永远无法回写。迁移时清除它。 + UserDefaults.standard.removeObject(forKey: Self.switchOverrideDefaultsKey) + super.init() + central = CBCentralManager(delegate: self, queue: nil) + Self.clearLiveSwitchState() + } + + /// 虚拟拨杆只在等待真实回包的短暂窗口内生效;随后一律使用键盘 GPIO 状态。 + var effectiveSwitchState: UInt8? { + userSwitchOverride ?? cachedSwitchState + } + + func setSwitchOverride(_ value: UInt8?) { + userSwitchOverride = value + // 如果固件支持 0x91,同步给键盘真正改 sw_state;否则仅短暂模拟,直到下一次真实回包。 + if let v = value { + sendSwitchState(v) + } + // 把模拟值写进共享文件,主 App 点击后立刻看到反馈。 + Self.writeLiveState(switchState: effectiveSwitchState) + emit("拨杆覆盖 = \(value.map { String($0) } ?? "清除")(effective=\(effectiveSwitchState.map { String($0) } ?? "未知"))") + } + + private func sendSwitchState(_ value: UInt8) { + guard let commandChar, let peripheral else { + emit("设置拨杆 \(value): 未连接,仅记录到覆盖值") + return + } + let data = Data(header + [0x91, value] + trailer) + let wt: CBCharacteristicWriteType = + commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(data, for: commandChar, type: wt) + emit("→ 拨杆 0x91 \(value): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))") + } + + // MARK: - Public + + func sendState(_ state: UInt8) { + pendingStateReset?.cancel() + pendingStateReset = nil + guard let commandChar, let peripheral else { + emit("LED 状态 \(state): 未连接") + return + } + let data = Data(header + [0x90, state] + trailer) + let wt: CBCharacteristicWriteType = + commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(data, for: commandChar, type: wt) + lastSentState = state + emit("→ LED 状态 \(state): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))") + Self.writeLiveState(stateValue: state) + } + + /// 把 agent 当前对键盘的认知(最近一次 hook 发送的 stateValue + BLE 上报的 lightMode/switchState/workMode) + /// merge-write 到共享文件,供主 App 在 agent 拥有 BLE 时读取实时状态。 + /// 任意调用方只传自己负责更新的字段;未传的字段保留文件中的旧值。 + static func writeLiveState(stateValue: UInt8? = nil, + lightMode: UInt8? = nil, + switchState: UInt8? = nil, + workMode: UInt8? = nil) { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("current-ide-state.json") + var obj: [String: Any] = [:] + if let existing = try? Data(contentsOf: url), + let parsed = (try? JSONSerialization.jsonObject(with: existing)) as? [String: Any] { + obj = parsed + } + let now = Date().timeIntervalSince1970 + if let s = stateValue { + obj["stateValue"] = Int(s) + obj["stateTs"] = now + } + if let lm = lightMode { + obj["lightMode"] = Int(lm) + obj["lightModeTs"] = now + } + if let sw = switchState { + obj["switchState"] = Int(sw) + } + if let wm = workMode { + obj["workMode"] = Int(wm) + } + obj["ts"] = now + if let data = try? JSONSerialization.data(withJSONObject: obj) { + try? data.write(to: url, options: .atomic) + } + } + + private static func clearLiveSwitchState() { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/current-ide-state.json") + guard let data = try? Data(contentsOf: url), + var obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return } + obj.removeValue(forKey: "switchState") + obj["ts"] = Date().timeIntervalSince1970 + guard let encoded = try? JSONSerialization.data(withJSONObject: obj) else { return } + try? encoded.write(to: url, options: .atomic) + } + + private func requestDeviceStatus() { + guard let commandChar, let peripheral else { return } + let query = Data(header + [0x00] + trailer) + let wt: CBCharacteristicWriteType = + commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(query, for: commandChar, type: wt) + } + + private func startStatusPolling() { + statusPollTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 1.0, repeating: 1.5) + timer.setEventHandler { [weak self] in + guard let self, self.statusWaiters.isEmpty else { return } + self.requestDeviceStatus() + } + statusPollTimer = timer + timer.resume() + } + + private func stopStatusPolling() { + statusPollTimer?.cancel() + statusPollTimer = nil + } + + /// 主动查询一次设备状态,等待下一个 notify 回包 (timeout 秒内)。 + /// 超时时用缓存兜底;仍然没有则返回 nil。完成回调在 main 队列。 + func querySwitchState(timeout: TimeInterval = 1.5, + completion: @escaping (AgentDeviceStatus?) -> Void) { + guard let commandChar, let peripheral else { + completion(nil) + return + } + // 发设备状态查询命令 AA BB 00 CC DD + requestDeviceStatus() + + statusWaiters.append(completion) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in + guard let self else { return } + // 把目前仍在队列里的 waiter 全部用缓存兜底 fire 掉 + guard !self.statusWaiters.isEmpty else { return } + let waiters = self.statusWaiters + self.statusWaiters.removeAll() + let fallback = self.cachedStatus() + for w in waiters { w(fallback) } + } + } + + private func cachedStatus() -> AgentDeviceStatus? { + guard let sw = cachedSwitchState else { return nil } + return AgentDeviceStatus( + battery: -1, signal: -1, firmwareMain: -1, firmwareSub: -1, + workMode: -1, lightMode: Int(cachedLightMode ?? 0), switchState: Int(sw) + ) + } + + func startSocketListener() { + startWatchdog() + // 清理旧 socket + unlink(socketPath) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { emit("socket() 失败"); return } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + socketPath.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + let buf = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + strcpy(buf, ptr) + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return } + + listen(fd, 5) + emit("监听 Unix socket: \(socketPath)") + + DispatchQueue.global(qos: .utility).async { [weak self] in + while true { + let clientFd = accept(fd, nil, nil) + guard clientFd >= 0 else { continue } + self?.handleClient(clientFd) + } + } + } + + // MARK: - 看门狗 + + private func startWatchdog() { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 30, repeating: 10) + timer.setEventHandler { [weak self] in + self?.checkWatchdog() + } + timer.resume() + watchdogTimer = timer + } + + private func checkWatchdog() { + guard let lastAt = lastHookStateAt else { return } + let activeStates: [UInt8] = [1, 2, 3, 4, 6, 7] + guard activeStates.contains(lastSentState) else { return } + let elapsed = Date().timeIntervalSince(lastAt) + let threshold = watchdogTimeout(for: lastSentState) + guard elapsed >= threshold else { return } + emit("⏰ 看门狗:\(Int(elapsed))s 无 hook 活动(上次 LED=\(lastSentState),阈值 \(Int(threshold))s),自动发 Stop(5)") + sendState(5) + lastHookStateAt = nil // 重置,避免重复触发 + } + + // MARK: - Socket handling + + /// 单个客户端的处理:读一包请求,按 JSON 或旧版纯数字分发。 + /// + /// 协议: + /// - JSON 一行:`{"cmd":"state","value":3}` / `{"cmd":"permission","value":1}` / `{"cmd":"status"}` + /// - 纯数字(兼容旧 `ahakey-state.sh`):`3` → sendState(3),不回包 + private func handleClient(_ clientFd: Int32) { + var buf = [UInt8](repeating: 0, count: 1024) + let n = read(clientFd, &buf, buf.count) + guard n > 0 else { close(clientFd); return } + + let line = String(bytes: buf[0 ..< Int(n)], encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // JSON 请求 + if let lineData = line.data(using: .utf8), + let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any], + let cmd = obj["cmd"] as? String { + DispatchQueue.main.async { [weak self] in + self?.handleJsonCommand(cmd: cmd, obj: obj, clientFd: clientFd) + } + return // fd 在命令 handler 里最终关闭 + } + + // 旧协议:纯数字当作 state,fire-and-forget + if let state = UInt8(line) { + DispatchQueue.main.async { [weak self] in self?.sendState(state) } + } + close(clientFd) + } + + /// 在主队列执行的 JSON 命令分发。回包由 `replyAndClose` 负责异步写入 + 关 fd。 + private func handleJsonCommand(cmd: String, obj: [String: Any], clientFd: Int32) { + switch cmd { + case "state": + if let v = obj["value"] as? Int { + lastHookStateAt = Date() + sendState(UInt8(clamping: v)) + } + Self.replyAndClose(clientFd, ["ok": true]) + + case "state_with_reset": + let stateValue = obj["value"] as? Int ?? 0 + let resetValue = obj["resetValue"] as? Int ?? 4 + let delayMs = max(0, obj["delayMs"] as? Int ?? 1200) + sendState(UInt8(clamping: stateValue)) + scheduleStateReset( + to: UInt8(clamping: resetValue), + afterMs: delayMs, + reason: "temporary state \(stateValue) -> reset \(resetValue)" + ) + Self.replyAndClose(clientFd, ["ok": true]) + + case "permission": + // 发 PermissionRequest 对应的 state(默认 1),同时主动查询拨杆 + let stateValue = obj["value"] as? Int ?? 1 + lastHookStateAt = Date() + sendState(UInt8(clamping: stateValue)) + querySwitchState(timeout: 1.5) { status in + let body = Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode) + self.emit("← permission 回包 switchState=\(String(describing: body["switchState"]))") + if let s = body["switchState"] as? Int, s != 0 { + self.emit("(拨杆非 0:PermissionRequest 将交回终端手动确认)") + } else if body["switchState"] is NSNull { + self.emit("(switchState 缺省:批准链可能仍交回手动;请把「蓝牙」交给 Agent 并连上键盘。)") + } + Self.replyAndClose(clientFd, body) + } + + case "status": + // 每次都请求真实 GPIO 状态,不能因旧缓存或虚拟模拟而返回过期档位。 + querySwitchState(timeout: 1.5) { status in + Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode)) + } + + case "approval_status": + // 给 Kimi CLI 的实时批准判断用:每次都主动向设备要当前拨杆,避免会话内沿用旧的 yolo/state。 + querySwitchState(timeout: 1.5) { status in + Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode)) + } + + case "set_switch_override": + // 主 App 画布虚拟拨杆点击 → 设置 / 清除覆盖 + // value=null / 缺省 → 清除(恢复用真实 BLE 上报) + // value=0/1/2 → 设置覆盖值;如果固件支持 0x91 还会同步给键盘 + if obj["value"] is NSNull || obj["value"] == nil { + setSwitchOverride(nil) + } else if let v = obj["value"] as? Int { + setSwitchOverride(UInt8(clamping: v)) + } + Self.replyAndClose(clientFd, [ + "ok": true, + "switchState": effectiveSwitchState.map { Int($0) } ?? NSNull(), + "override": userSwitchOverride.map { Int($0) } ?? NSNull(), + ]) + + default: + Self.replyAndClose(clientFd, ["error": "unknown cmd: \(cmd)"]) + } + } + + private static func statusReply(_ status: AgentDeviceStatus?, + cachedSwitch: UInt8?, + cachedLight: UInt8?) -> [String: Any] { + if let s = status { + return ["switchState": s.switchState, "lightMode": s.lightMode] + } + return [ + "switchState": cachedSwitch.map { Int($0) } ?? NSNull(), + "lightMode": cachedLight.map { Int($0) } ?? NSNull(), + ] + } + + private func scheduleStateReset(to state: UInt8, afterMs: Int, reason: String) { + pendingStateReset?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.sendState(state) + self.emit("自动回落灯态:\(reason)") + } + pendingStateReset = work + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(afterMs), execute: work) + } + + private static func replyAndClose(_ fd: Int32, _ dict: [String: Any]) { + DispatchQueue.global(qos: .utility).async { + if let data = try? JSONSerialization.data(withJSONObject: dict, options: []) { + var out = data + out.append(0x0A) // \n 作为消息边界 + _ = out.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return -1 } + return write(fd, base, ptr.count) + } + } + close(fd) + } + } + + // MARK: - Connection + + private func connectAutomatically() { + // 1. 用已知 UUID + if let uuid = lastUUID { + let known = central.retrievePeripherals(withIdentifiers: [uuid]) + if let p = known.first { + emit("直连已知设备: \(uuid.uuidString.prefix(8))…") + peripheral = p + p.delegate = self + central.connect(p, options: nil) + return + } + } + + // 2. 系统已连接 + let connected = central.retrieveConnectedPeripherals(withServices: [serviceUUID]) + if let p = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(deviceNamePrefix) }) { + emit("系统已连接: \(p.name ?? "?")") + peripheral = p + p.delegate = self + central.connect(p, options: nil) + return + } + + // 3. 扫描 + emit("开始扫描…") + central.scanForPeripherals(withServices: [serviceUUID], options: nil) + } + + private func emit(_ msg: String) { + log.info("\(msg)") + onLog?(msg) + } + + // MARK: - CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + if central.state == .poweredOn { + emit("蓝牙就绪") + connectAutomatically() + } + } + + 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) else { return } + central.stopScan() + emit("发现: \(name)") + self.peripheral = peripheral + peripheral.delegate = self + central.connect(peripheral, options: nil) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + lastUUID = peripheral.identifier + emit("已连接: \(peripheral.name ?? "?")") + peripheral.discoverServices([serviceUUID]) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + stopStatusPolling() + commandChar = nil + notifyChar = nil + self.peripheral = nil + cachedSwitchState = nil + cachedLightMode = nil + // 把 pending 的 waiter 全部通知为 nil(避免 hook 客户端一直等) + if !statusWaiters.isEmpty { + let waiters = statusWaiters + statusWaiters.removeAll() + for w in waiters { w(nil) } + } + emit("已断开,2s 后重连") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.connectAutomatically() + } + } + + // MARK: - CBPeripheralDelegate + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }) else { return } + peripheral.discoverCharacteristics([commandCharUUID, notifyCharUUID], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + for char in service.characteristics ?? [] { + if char.uuid == commandCharUUID { + commandChar = char + emit("命令通道就绪") + } else if char.uuid == notifyCharUUID { + notifyChar = char + peripheral.setNotifyValue(true, for: char) + emit("通知通道已订阅") + } + } + // 两个特征都就绪后发一次初始状态查询 + if commandChar != nil, notifyChar != nil { + requestDeviceStatus() + startStatusPolling() + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == commandCharUUID || characteristic.uuid == notifyCharUUID, + let data = characteristic.value else { return } + guard let status = Self.parseDeviceStatus(data) else { return } + + let hardwareSwitchState = UInt8(clamping: status.switchState) + cachedSwitchState = hardwareSwitchState + cachedLightMode = UInt8(clamping: status.lightMode) + if userSwitchOverride != nil { + userSwitchOverride = nil + emit("← 收到真实拨杆状态 \(hardwareSwitchState),已清除虚拟拨杆覆盖") + } + emit("← status battery=\(status.battery) light=\(status.lightMode) switch=\(status.switchState)") + // 一律写入键盘真实 GPIO 状态,避免旧虚拟模拟把 UI / hook 锁在错误档位。 + Self.writeLiveState( + lightMode: UInt8(clamping: status.lightMode), + switchState: hardwareSwitchState, + workMode: UInt8(clamping: max(0, status.workMode)) + ) + + guard !statusWaiters.isEmpty else { return } + let waiters = statusWaiters + statusWaiters.removeAll() + for w in waiters { w(status) } + } + + // MARK: - 协议内联解析 + + /// 解析 AA BB 00 [battery][signal][fw_main][fw_sub][work][light][switch][reserve] CC DD + /// 与 Sources/BLE/AhaKeyProtocol.swift:parseDeviceStatus 等价 + private static func parseDeviceStatus(_ data: Data) -> AgentDeviceStatus? { + guard data.count >= 12, + data[0] == 0xAA, data[1] == 0xBB, + data[data.count - 2] == 0xCC, data[data.count - 1] == 0xDD else { + return nil + } + let payload = data[2 ..< data.count - 2] + guard payload.count >= 8, payload[payload.startIndex] == 0x00 else { return nil } + let base = payload.startIndex + 1 // 跳过 cmd echo + return AgentDeviceStatus( + battery: Int(payload[base]), + signal: Int(Int8(bitPattern: payload[base + 1])), + firmwareMain: Int(payload[base + 2]), + firmwareSub: Int(payload[base + 3]), + workMode: Int(payload[base + 4]), + lightMode: Int(payload[base + 5]), + switchState: Int(payload[base + 6]) + ) + } +} diff --git a/Sources/Agent/ClaudeHookHandler.swift b/Sources/Agent/ClaudeHookHandler.swift new file mode 100644 index 00000000..8a4485b3 --- /dev/null +++ b/Sources/Agent/ClaudeHookHandler.swift @@ -0,0 +1,49 @@ +import Foundation + +enum ClaudeHookHandler { + static func handlePermissionRequest() { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Claude") + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + let behavior: String + + if isAuto { + behavior = "allow" + } else { + HookSupport.emitPermissionStderr( + ide: "Claude", + hookName: "PermissionRequest", + reply: reply, + switchState: switchState + ) + behavior = "ask" + } + + let out: [String: Any] = [ + "hookSpecificOutput": [ + "hookEventName": "PermissionRequest", + "decision": ["behavior": behavior], + ], + ] + if let data = try? JSONSerialization.data(withJSONObject: out, options: []), + let str = String(data: data, encoding: .utf8) { + print(str) + } + + HookSupport.appendDiagnostic( + ide: "claude", + hookEvent: "PermissionRequest", + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: behavior, + cursorPermission: nil, + cursorDebug: nil, + kimiPreToolDecision: nil + ) + } +} diff --git a/Sources/Agent/CodexHookHandler.swift b/Sources/Agent/CodexHookHandler.swift new file mode 100644 index 00000000..32d90f50 --- /dev/null +++ b/Sources/Agent/CodexHookHandler.swift @@ -0,0 +1,80 @@ +import Foundation + +enum CodexHookHandler { + static func handleState(stateValue: UInt8) { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Codex") + let reply = HookSupport.sendUnifiedLightState(stateValue: stateValue) + HookSupport.appendCodexHookLog( + hookEvent: ctx["hook_event_name"] as? String, + agentEvent: codexAgentEventName(forStateValue: stateValue), + stateValue: stateValue, + toolContext: ctx, + reply: reply, + switchState: HookSupport.intValue(reply?["switchState"]), + decision: nil + ) + print("{}") + } + + static func handlePermissionRequest() { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Codex") + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + + if !isAuto { + HookSupport.emitPermissionStderr( + ide: "Codex", + hookName: "PermissionRequest", + reply: reply, + switchState: switchState + ) + } + + var hookOut: [String: Any] = ["hookEventName": "PermissionRequest"] + if isAuto { + hookOut["decision"] = ["behavior": "allow"] + } + HookSupport.appendCodexHookLog( + hookEvent: "PermissionRequest", + agentEvent: "CodexPermissionRequest", + stateValue: HookSupport.permissionLedValue, + toolContext: ctx, + reply: reply, + switchState: switchState, + decision: isAuto ? "allow" : "pass_through" + ) + let out: [String: Any] = ["hookSpecificOutput": hookOut] + if let data = try? JSONSerialization.data(withJSONObject: out, options: []), + let str = String(data: data, encoding: .utf8) { + print(str) + } + + HookSupport.appendDiagnostic( + ide: "codex", + hookEvent: "PermissionRequest", + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: nil, + cursorPermission: nil, + cursorDebug: nil, + kimiPreToolDecision: nil + ) + } + + private static func codexAgentEventName(forStateValue stateValue: UInt8) -> String { + switch stateValue { + case 2: return "CodexPostToolUse" + case 3: return "CodexPreToolUse" + case 4: return "CodexSessionStart" + case 5: return "CodexStop" + case 7: return "CodexUserPromptSubmit" + default: return "CodexState\(stateValue)" + } + } +} diff --git a/platforms/macos/Sources/Agent/CursorCliLeverSync.swift b/Sources/Agent/CursorCliLeverSync.swift similarity index 100% rename from platforms/macos/Sources/Agent/CursorCliLeverSync.swift rename to Sources/Agent/CursorCliLeverSync.swift diff --git a/Sources/Agent/CursorHookHandler.swift b/Sources/Agent/CursorHookHandler.swift new file mode 100644 index 00000000..beca57fc --- /dev/null +++ b/Sources/Agent/CursorHookHandler.swift @@ -0,0 +1,54 @@ +import Foundation + +enum CursorHookHandler { + static func handleToolPermission(hookEvent: String) { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Cursor") + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + let permission: String + + if isAuto { + permission = "allow" + } else { + HookSupport.emitPermissionStderr( + ide: "Cursor", + hookName: hookEvent, + reply: reply, + switchState: switchState + ) + permission = "ask" + } + + if let s = switchState { + let auto = s == 0 + CursorCliLeverSync.apply(switchStateAuto: auto) + CursorPermissionsJsonLeverSync.apply(switchStateAuto: auto) + } + + let out: [String: Any] = ["permission": permission] + if let data = try? JSONSerialization.data(withJSONObject: out, options: []), + let str = String(data: data, encoding: .utf8) { + print(str) + } + + let cursorDebug = HookSupport.buildCursorHookDebug( + stdinData: stdinData, + commandPreview: ctx["commandPreview"] as? String + ) + HookSupport.appendDiagnostic( + ide: "cursor", + hookEvent: hookEvent, + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: nil, + cursorPermission: permission, + cursorDebug: cursorDebug, + kimiPreToolDecision: nil + ) + } +} diff --git a/platforms/macos/Sources/Agent/CursorPermissionsJsonLeverSync.swift b/Sources/Agent/CursorPermissionsJsonLeverSync.swift similarity index 100% rename from platforms/macos/Sources/Agent/CursorPermissionsJsonLeverSync.swift rename to Sources/Agent/CursorPermissionsJsonLeverSync.swift diff --git a/Sources/Agent/HookClient.swift b/Sources/Agent/HookClient.swift new file mode 100644 index 00000000..ba76de97 --- /dev/null +++ b/Sources/Agent/HookClient.swift @@ -0,0 +1,75 @@ +import Foundation + +/// 仅负责事件路由;各 IDE 的具体逻辑已拆到独立 handler,避免互相影响。 +enum HookClient { + private enum EventRoute { + case sharedState(UInt8) + case claudePermissionRequest + case cursorToolPermission + case codexState(UInt8) + case codexPermissionRequest + case kimiPreToolPermission + } + + private static let eventMap: [String: EventRoute] = [ + "Notification": .sharedState(0), + "PermissionRequest": .claudePermissionRequest, + "PostToolUse": .sharedState(2), + "PreToolUse": .sharedState(3), + "SessionStart": .sharedState(4), + "Stop": .sharedState(5), + // SubagentStop:Claude Code 将原 Stop 拆分后,手动终止任务时触发此事件而非 Stop + "SubagentStop": .sharedState(5), + "TaskCompleted": .sharedState(6), + "UserPromptSubmit": .sharedState(7), + "SessionEnd": .sharedState(8), + "PreCompact": .sharedState(0), + + "sessionStart": .sharedState(4), + "sessionEnd": .sharedState(8), + "postToolUse": .sharedState(2), + "stop": .sharedState(5), + "preToolUse": .cursorToolPermission, + "beforeShellExecution": .cursorToolPermission, + "beforeMCPExecution": .cursorToolPermission, + + "CodexSessionStart": .codexState(4), + "CodexPostToolUse": .codexState(2), + "CodexPreToolUse": .codexState(3), + "CodexUserPromptSubmit": .codexState(7), + "CodexStop": .codexState(5), + "CodexPermissionRequest": .codexPermissionRequest, + + "KimiNotification": .sharedState(0), + "KimiSessionStart": .sharedState(4), + "KimiSessionEnd": .sharedState(8), + "KimiPreToolUse": .kimiPreToolPermission, + "KimiPostToolUse": .sharedState(2), + "KimiUserPromptSubmit": .sharedState(7), + "KimiStop": .sharedState(5), + ] + + static func run(event: String) -> Int32 { + signal(SIGPIPE, SIG_IGN) + guard let route = eventMap[event] else { + FileHandle.standardError.write(Data("[ahakey-hook] unknown event: \(event)\n".utf8)) + return 0 + } + + switch route { + case .sharedState(let value): + HookSupport.handleFireAndForgetState(stateValue: value) + case .claudePermissionRequest: + ClaudeHookHandler.handlePermissionRequest() + case .cursorToolPermission: + CursorHookHandler.handleToolPermission(hookEvent: event) + case .codexState(let value): + CodexHookHandler.handleState(stateValue: value) + case .codexPermissionRequest: + CodexHookHandler.handlePermissionRequest() + case .kimiPreToolPermission: + KimiHookHandler.handlePreToolPermission() + } + return 0 + } +} diff --git a/Sources/Agent/HookSupport.swift b/Sources/Agent/HookSupport.swift new file mode 100644 index 00000000..fdbeadc5 --- /dev/null +++ b/Sources/Agent/HookSupport.swift @@ -0,0 +1,312 @@ +import Foundation + +enum HookSupport { + static let permissionLedValue: UInt8 = 1 + static let socketPath = "/tmp/ahakey.sock" + static let stateRequestTimeout: Double = 2.0 + static let permissionRequestTimeout: Double = 5.0 + + static let diagnosticTimestampFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f + }() + + static func handleFireAndForgetState(stateValue: UInt8) { + _ = sendUnifiedLightState(stateValue: stateValue) + } + + @discardableResult + static func sendUnifiedLightState(stateValue: UInt8) -> [String: Any]? { + let request: [String: Any] = ["cmd": "state", "value": Int(stateValue)] + return sendJsonRequest(request, timeout: stateRequestTimeout) + } + + @discardableResult + static func readAllStdinSilently() -> Data { + let handle = FileHandle.standardInput + return (try? handle.readToEnd()) ?? Data() + } + + static func sendJsonRequest(_ dict: [String: Any], timeout: Double) -> [String: Any]? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var tv = timeval( + tv_sec: __darwin_time_t(timeout), + tv_usec: suseconds_t((timeout - Double(Int(timeout))) * 1_000_000) + ) + 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) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + _ = strcpy(dst, src) + } + } + let addrLen = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, addrLen) + } + } + guard connected == 0 else { return nil } + + guard var payload = try? JSONSerialization.data(withJSONObject: dict, options: []) else { + return nil + } + payload.append(0x0A) + let wrote = payload.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return -1 } + return write(fd, base, ptr.count) + } + guard wrote >= 0 else { return nil } + + var buf = [UInt8](repeating: 0, count: 1024 * 4) + let n = read(fd, &buf, buf.count) + guard n > 0 else { return nil } + let slice = Data(buf[0 ..< Int(n)]) + return (try? JSONSerialization.jsonObject(with: slice)) as? [String: Any] + } + + static func intValue(_ v: Any?) -> Int? { + switch v { + case let i as Int: + return i + case let n as NSNumber: + return n.intValue + case let d as Double: + return Int(d) + case let s as String: + return Int(s) + default: + return nil + } + } + + static func emitPermissionStderr( + ide: String, + hookName: String, + reply: [String: Any]?, + switchState: Int? + ) { + if switchState == nil, reply == nil { + let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(/tmp/ahakey.sock 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。" + + "请确认 LaunchAgent 里 ahakeyconfig-agent 在跑、且蓝牙已选「由 Agent 占用」并连上键盘。\n" + FileHandle.standardError.write(Data(msg.utf8)) + } else if switchState == nil, reply != nil { + let msg = "[ahakey-hook] \(ide) \(hookName): 回包无有效 switchState(需 BLE 已连且能读到拨杆 0=自动批准),将按交回用户/终端处理。\n" + FileHandle.standardError.write(Data(msg.utf8)) + } else if let s = switchState, s != 0 { + let msg = "[ahakey-hook] \(ide) \(hookName): 拨杆 switchState=\(s)(非 0),不自动批准。\n" + FileHandle.standardError.write(Data(msg.utf8)) + } + } + + static func parseStdinContext(_ data: Data, label: String) -> [String: Any] { + var out: [String: Any] = [ + "stdinBytes": data.count, + "parser": label, + ] + guard !data.isEmpty, + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + return out + } + if let t = obj["tool_name"] as? String { + out["tool_name"] = t + } + if let c = obj["command"] as? String { + out["commandPreview"] = String(c.prefix(120)) + } + if out["tool_name"] == nil, let t = obj["name"] as? String { + out["name"] = t + } + if let he = obj["hook_event_name"] as? String { + out["hook_event_name"] = he + } + if let sid = obj["session_id"] as? String { + out["session_id_short"] = String(sid.prefix(16)) + } + if let cw = obj["cwd"] as? String { + out["cwdPreview"] = String(cw.prefix(120)) + } + return out + } + + static func appendDiagnostic( + ide: String, + hookEvent: String, + toolContext: [String: Any], + reply: [String: Any]?, + switchState: Int?, + isAuto: Bool, + claudeBehavior: String?, + cursorPermission: String?, + cursorDebug: [String: Any]? = nil, + kimiPreToolDecision: String? = nil, + kimiLeverDebug: [String: Any]? = nil + ) { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + let fileURL = dir.appendingPathComponent("permission-request.log") + + let diagnostic: String + if reply == nil { + diagnostic = "no_agent_reply" + } else if switchState == nil { + diagnostic = "no_switch_in_reply" + } else if isAuto { + diagnostic = "allow" + } else { + diagnostic = "ask" + } + + var lineObj: [String: Any] = [ + "ts": diagnosticTimestampFormatter.string(from: Date()), + "ide": ide, + "hookEvent": hookEvent, + "switchState": switchState.map { $0 } ?? NSNull(), + "isAuto": isAuto, + "agentReply": reply == nil ? false : true, + "diagnostic": diagnostic, + "tool": toolContext, + ] + if let b = claudeBehavior { lineObj["claudeBehavior"] = b } + if let p = cursorPermission { lineObj["cursorPermission"] = p } + if let c = cursorDebug { lineObj["cursorDebug"] = c } + if let k = kimiPreToolDecision { lineObj["kimiPreToolDecision"] = k } + if let kd = kimiLeverDebug { lineObj["kimiLeverDebug"] = kd } + + guard let data = try? JSONSerialization.data(withJSONObject: lineObj, options: []), + var line = String(data: data, encoding: .utf8) else { return } + line += "\n" + appendLine(line, to: fileURL) + } + + static func appendCodexHookLog( + hookEvent: String?, + agentEvent: String, + stateValue: UInt8, + toolContext: [String: Any], + reply: [String: Any]?, + switchState: Int?, + decision: String? + ) { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + let fileURL = dir.appendingPathComponent("codex-hook.log") + + var lineObj: [String: Any] = [ + "ts": diagnosticTimestampFormatter.string(from: Date()), + "agentEvent": agentEvent, + "hookEvent": hookEvent ?? NSNull(), + "stateValue": Int(stateValue), + "agentReply": reply == nil ? false : true, + "switchState": switchState.map { $0 } ?? NSNull(), + "tool": toolContext, + ] + if let decision { lineObj["decision"] = decision } + + guard let data = try? JSONSerialization.data(withJSONObject: lineObj, options: []), + var line = String(data: data, encoding: .utf8) else { return } + line += "\n" + appendLine(line, to: fileURL) + } + + static func appendLine(_ line: String, to fileURL: URL) { + guard let out = line.data(using: .utf8) else { return } + if !FileManager.default.fileExists(atPath: fileURL.path) { + try? out.write(to: fileURL, options: .atomic) + return + } + if let h = try? FileHandle(forWritingTo: fileURL) { + defer { try? h.close() } + h.seekToEndOfFile() + h.write(out) + } + } + + static let cursorStdinLogKeys: [String] = [ + "cursorVersion", "cursor_version", "appVersion", "app_version", "version", + "workspacePath", "workspace_path", "workspaceFolders", "workspace_folders", "workspaceRoot", + "cwd", "root", "shell", "shell_type", "sessionId", "conversation_id", + ] + + static func buildCursorHookDebug(stdinData: Data, commandPreview: String?) -> [String: Any] { + var out: [String: Any] = [ + "userCliConfig": CursorCliLeverSync.diagnosticSnapshotForLog(), + "userPermissionsJson": CursorPermissionsJsonLeverSync.diagnosticSnapshotForLog(), + "note": "IDE「Not in allowlist」用 ~/.cursor/permissions.json 的 terminalAllowlist,与 userCliConfig(cli-config=CLI)分离;见 cursor.com/docs/reference/permissions", + "stdinFields": cursorStdinDebugFields(stdinData), + "processEnvCursorVscode": cursorRelatedEnvForLog(), + ] + if let cd = inferredCdPathFromShellCommand(commandPreview) { + out["inferredCdPath"] = cd + let proj = (cd as NSString).appendingPathComponent(".cursor/cli.json") + out["projectCliJsonPath"] = proj + out["projectCliJsonExists"] = FileManager.default.fileExists(atPath: proj) + } + return out + } + + static func cursorStdinDebugFields(_ data: Data) -> [String: Any] { + guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + return ["parse": "empty_or_invalid_json"] + } + var o: [String: Any] = [:] + for k in cursorStdinLogKeys { + guard let v = obj[k] else { continue } + o[k] = stringifyDebugValue(v, maxLen: 220) + } + if o.isEmpty { o["note"] = "no_whitelisted_keys_in_stdin" } + return o + } + + static func stringifyDebugValue(_ v: Any, maxLen: Int) -> String { + if let s = v as? String { return String(s.prefix(maxLen)) } + if let n = v as? NSNumber { return n.stringValue } + if let a = v as? [String] { + return String(a.prefix(12).joined(separator: ", ").prefix(maxLen)) + } + return String(String(describing: v).prefix(maxLen)) + } + + static func cursorRelatedEnvForLog() -> [String: Any] { + var o: [String: Any] = [:] + for (k, v) in ProcessInfo.processInfo.environment.sorted(by: { $0.key < $1.key }) { + let ku = k.uppercased() + guard ku.contains("CURSOR") || ku.hasPrefix("VSCODE_") || ku == "TERM" else { continue } + o[k] = String(v.prefix(200)) + if o.count >= 32 { break } + } + return o + } + + static func inferredCdPathFromShellCommand(_ command: String?) -> String? { + guard let raw = command?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.hasPrefix("cd ") else { return nil } + var rest = String(raw.dropFirst(3)).trimmingCharacters(in: .whitespaces) + if let r = rest.range(of: " && ") { rest = String(rest[.. [String: Any] { + let fm = FileManager.default + let path = configURL.path + var d: [String: Any] = [ + "kimiConfigPath": path, + "kimiConfigExists": fm.fileExists(atPath: path), + "hookApprovalPolicy": "dial_writes_default_yolo_on_PreToolUse_kimi_reload_to_apply_upstream_deny_only", + ] + guard let data = fm.contents(atPath: path), + let raw = String(data: data, encoding: .utf8) else { return d } + if let regex = try? NSRegularExpression(pattern: #"(?m)^\s*default_yolo\s*=\s*(\S+)"#, options: []), + let m = regex.firstMatch(in: raw, options: [], range: NSRange(location: 0, length: (raw as NSString).length)), + m.numberOfRanges > 1 { + let ns = raw as NSString + d["default_yolo_lineno_hint"] = "matched" + d["default_yolo_valueSnippet"] = String(ns.substring(with: m.range(at: 1)).prefix(32)) + } else { + d["default_yolo_valueSnippet"] = NSNull() + } + return d + } +} diff --git a/Sources/Agent/KimiConfigLeverSync.swift b/Sources/Agent/KimiConfigLeverSync.swift new file mode 100644 index 00000000..fc8f9869 --- /dev/null +++ b/Sources/Agent/KimiConfigLeverSync.swift @@ -0,0 +1,113 @@ +import Foundation + +/// 将键盘拨杆与 Kimi **`~/.kimi/config.toml`** 的根级 **`default_yolo`** 对齐:**自动档 `true`,手动档 `false`**。 +/// +/// 不使用「整文件恢复快照」来代表手动档:快照往往在用户原本就开启过 YOLO 时记下 `default_yolo=true`,一还原无论怎样 `/reload` 都会回到 YOLO。自动档仍可在首次写入前备份一份 **`.ahakey.lever0.bak`** 供你以后自行比对;切到手动档时会删掉该快照以免再次误还原。 +enum KimiConfigLeverSync { + private static var configURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".kimi/config.toml", isDirectory: false) + } + + private static var snapshotURL: URL { configURL.appendingPathExtension("ahakey.lever0.bak") } + + static func apply(switchStateAuto: Bool) { + if switchStateAuto { + enableYoloInConfig() + } else { + disableDefaultYoloForManualDial() + } + } + + private static func enableYoloInConfig() { + let fm = FileManager.default + let path = configURL.path + guard fm.fileExists(atPath: path) else { + fprintStderr("KimiConfigLeverSync: 未找到 \(path),跳过。\n") + KimiHookDebugLog.append(event: "kimi_lever_sync_missing_config", details: ["path": path]) + return + } + + if !fm.fileExists(atPath: snapshotURL.path) { + do { + try fm.copyItem(at: configURL, to: snapshotURL) + KimiHookDebugLog.append(event: "kimi_lever_sync_backup_created", details: ["to": snapshotURL.path]) + } catch { + fprintStderr("KimiConfigLeverSync: 备份失败 \(error.localizedDescription)\n") + KimiHookDebugLog.append(event: "kimi_lever_sync_backup_failed", details: ["error": error.localizedDescription]) + } + } + + guard let data = fm.contents(atPath: path), + var raw = String(data: data, encoding: .utf8) else { + fprintStderr("KimiConfigLeverSync: 无法读取 \(path)\n") + return + } + raw = replaceOrInsertDefaultYolo(raw, template: #"default_yolo = true # AhaKey: dial auto; run /reload in Kimi after changing dial"#) + writeRaw(raw, label: "enable_auto") + } + + private static func disableDefaultYoloForManualDial() { + let fm = FileManager.default + let path = configURL.path + // 删掉旧逻辑用的快照:手动档若用它「整文件还原」会把原先的 default_yolo=true 整块带回来。 + if fm.fileExists(atPath: snapshotURL.path) { + do { + try fm.removeItem(at: snapshotURL) + KimiHookDebugLog.append(event: "kimi_lever_snapshot_removed_for_manual_clear", details: [:]) + KimiHookDebugLog.stderrLine("removed config.toml.ahakey.lever0.bak (avoid restoring old default_yolo=true)") + } catch { + fprintStderr("KimiConfigLeverSync: 无法删除快照 \(error.localizedDescription)\n") + } + } + guard fm.fileExists(atPath: path) else { return } + guard let data = fm.contents(atPath: path), + var raw = String(data: data, encoding: .utf8) else { return } + raw = replaceOrInsertDefaultYolo(raw, template: #"default_yolo = false # AhaKey: dial manual; run /reload in Kimi after changing dial"#) + writeRaw(raw, label: "disable_manual") + KimiHookDebugLog.stderrLine("default_yolo=false on dial manual; run /reload in Kimi.") + // kimi-cli: effective_yolo = load_config.default_yolo OR session persist state.json approval.yolo + KimiHookDebugLog.stderrLine( + "note: kimi ORs persisted session YOLO (e.g. after /yolo) with config — if reload still shows yolo, toggle /yolo off once or use a fresh session." + ) + } + + private static func replaceOrInsertDefaultYolo(_ text: String, template: String) -> String { + let pattern = #"(?m)^\s*default_yolo\s*=\s*[^\r\n]*\s*$"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return template + "\n\n" + text + } + let nsFull = text as NSString + let fullRange = NSRange(location: 0, length: nsFull.length) + if regex.firstMatch(in: text, options: [], range: fullRange) != nil { + return regex.stringByReplacingMatches(in: text, options: [], range: fullRange, withTemplate: template) + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return template + "\n" } + return template + "\n\n" + text + } + + private static func writeRaw(_ raw: String, label: String) { + guard let out = raw.data(using: .utf8) else { return } + do { + try out.write(to: configURL, options: .atomic) + KimiHookDebugLog.append(event: "kimi_lever_sync_\(label)", details: [ + "bytes": out.count, + "reloadHint": "kimi_reload_slash_same_session_ok", + ]) + } catch { + fprintStderr("KimiConfigLeverSync: 写回失败 \(error.localizedDescription)\n") + KimiHookDebugLog.append(event: "kimi_lever_sync_write_failed", details: ["error": error.localizedDescription]) + } + } + + private static func fprintStderr(_ s: String) { + FileHandle.standardError.write(Data(s.utf8)) + } + + /// 供 `permission-request.log`:`kimiLeverDebug`。 + static func diagnosticSnapshotForLog() -> [String: Any] { + var d = KimiConfigDiagnostic.snapshotForLog() + d["leverSnapshotBakExists"] = FileManager.default.fileExists(atPath: snapshotURL.path) + return d + } +} diff --git a/Sources/Agent/KimiHookDebugLog.swift b/Sources/Agent/KimiHookDebugLog.swift new file mode 100644 index 00000000..40d3a79c --- /dev/null +++ b/Sources/Agent/KimiHookDebugLog.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Kimi PreToolUse 等设备侧调试日志:stderr(终端里 kimi 会看到)+ 单行 JSON 写入 **当前工作目录** 下的 `kimi-hook-debug.log`。 +enum KimiHookDebugLog { + private static let logFileName = "kimi-hook-debug.log" + + /// 供 Hook stderr 告知用户到哪看完整 JSON 行日志(绝对路径)。 + static var logPath: String { debugFileURL.path } + + private static var debugFileURL: URL { + let cwd = FileManager.default.currentDirectoryPath + let base = cwd.isEmpty + ? FileManager.default.homeDirectoryForCurrentUser.path + : cwd + return URL(fileURLWithPath: base, isDirectory: true).appendingPathComponent(logFileName, isDirectory: false) + } + + private static let isoTs: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// stderr 单行,前缀统一便于 `grep`,kimi-cli 将把 hook 工具的 stderr 打出来(视版本而定)。 + static func stderrLine(_ msg: String) { + let line = "[aha-kimi] \(msg)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + + /// 结构化追加到磁盘(与 `permission-request.log` 等 App diagnostics 分离,避免路径含空格时难找)。 + static func append(event: String, details: [String: Any]) { + let cwd = FileManager.default.currentDirectoryPath + var payload: [String: Any] = [ + "ts": isoTs.string(from: Date()), + "event": event, + "pid": ProcessInfo.processInfo.processIdentifier, + "cwd": cwd.isEmpty ? NSNull() : cwd, + ] + for (k, v) in details { payload[k] = v } + + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload, options: []), + var line = String(data: data, encoding: .utf8) else { + stderrLine("debug log JSON encode failed event=\(event)") + return + } + line += "\n" + + let url = debugFileURL + let fm = FileManager.default + do { + // 日志在 cwd 下,目录应已存在;若为空回退到 home,则保证父目录存在 + if FileManager.default.currentDirectoryPath.isEmpty { + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + if !fm.fileExists(atPath: url.path) { + try line.data(using: .utf8)?.write(to: url, options: .atomic) + } else if let handle = try? FileHandle(forWritingTo: url) { + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(line.utf8)) + } + } catch { + let fallback = "[aha-kimi] append log failed \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(fallback.utf8)) + } + } +} diff --git a/Sources/Agent/KimiHookHandler.swift b/Sources/Agent/KimiHookHandler.swift new file mode 100644 index 00000000..b39f6423 --- /dev/null +++ b/Sources/Agent/KimiHookHandler.swift @@ -0,0 +1,109 @@ +import Foundation + +enum KimiHookHandler { + static func handlePreToolPermission() { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Kimi") + KimiHookDebugLog.append(event: "kimi_PreToolUse_start", details: [ + "stdinBytes": stdinData.count, + "toolPreview": ctx, + "hintLogFile": KimiHookDebugLog.logPath, + ]) + KimiHookDebugLog.stderrLine("PreToolUse hook start bytes=\(stdinData.count); full log=\(KimiHookDebugLog.logPath)") + + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + + KimiHookDebugLog.append(event: "kimi_PreToolUse_agent_reply", details: [ + "agentReplyNil": reply == nil, + "switchState": switchState.map { $0 } ?? NSNull(), + "computedIsAuto": isAuto, + "replyPreview": kimiReplyPreview(reply), + ]) + KimiHookDebugLog.stderrLine("agent socket reply: switchState=\(switchState.map { String($0) } ?? "nil") isAuto=\(isAuto)") + + let stdoutPayload: String + let kimiPreToolDecision: String + if isAuto { + stdoutPayload = "" + kimiPreToolDecision = "auto_no_stdout_no_veto" + } else { + stdoutPayload = "" + kimiPreToolDecision = "manual_lever_no_stdout" + HookSupport.emitPermissionStderr( + ide: "Kimi", + hookName: "PreToolUse", + reply: reply, + switchState: switchState + ) + } + + let kimiProbeInitial = KimiConfigDiagnostic.snapshotForLog() + KimiHookDebugLog.append(event: "kimi_PreToolUse_probe_before_lever_sync", details: [ + "kimiLeverDebug_preview": kimiProbeInitial, + "switchStateNil": switchState == nil, + "kimiRunnerNote": "upstream_kimi_hooks_runner_only_handles_permissionDecision_deny", + ]) + var leverSnapshotForLog = kimiProbeInitial + if switchState == nil { + KimiHookDebugLog.stderrLine("no switchState from agent → skipped default_yolo sync; kimiPreTool=\(kimiPreToolDecision); check BLE/agent") + } else { + KimiConfigLeverSync.apply(switchStateAuto: isAuto) + leverSnapshotForLog = KimiConfigLeverSync.diagnosticSnapshotForLog() + KimiHookDebugLog.append(event: "kimi_PreToolUse_after_default_yolo_disk_sync", details: leverSnapshotForLog) + KimiHookDebugLog.stderrLine( + "default_yolo synced on disk (\(isAuto ? "true" : "false")); if kimi is already running: enter /reload in that session — no quit needed." + ) + let yoloSnippet: String + if let v = leverSnapshotForLog["default_yolo_valueSnippet"], !(v is NSNull) { + yoloSnippet = String(describing: v) + } else { + yoloSnippet = "(unset/absent)" + } + KimiHookDebugLog.stderrLine("on-disk default_yolo now reads \(yoloSnippet); /reload picks it up.") + } + + if !stdoutPayload.isEmpty { + FileHandle.standardOutput.write(Data(stdoutPayload.utf8)) + } + KimiHookDebugLog.append(event: "kimi_PreToolUse_stdout", details: [ + "kimiDecision": kimiPreToolDecision, + "stdoutChars": stdoutPayload.count, + "hint": "kimi-cli 读 default_yolo 在 KimiCLI.create/load_config;已开着的会话请输入 /reload 即可(不必退出 kimi)。PreToolUse 仍仅 deny 会拦工具;default_yolo 管交互批准层。", + ]) + KimiHookDebugLog.stderrLine("stdout chars=\(stdoutPayload.count) decision=\(kimiPreToolDecision)") + + HookSupport.appendDiagnostic( + ide: "kimi", + hookEvent: "PreToolUse", + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: nil, + cursorPermission: nil, + cursorDebug: nil, + kimiPreToolDecision: kimiPreToolDecision, + kimiLeverDebug: KimiConfigLeverSync.diagnosticSnapshotForLog() + ) + } + + private static func kimiReplyPreview(_ reply: [String: Any]?) -> [String: Any] { + guard let reply else { + return ["nil": true] + } + var o: [String: Any] = [:] + if let sv = reply["switchState"] { + o["switchState"] = String(String(describing: sv).prefix(48)) + } + if let ok = reply["ok"] { + o["ok"] = ok + } + if let err = reply["error"] as? String { + o["error"] = String(err.prefix(160)) + } + return o + } +} diff --git a/platforms/macos/Sources/Agent/main.swift b/Sources/Agent/main.swift similarity index 89% rename from platforms/macos/Sources/Agent/main.swift rename to Sources/Agent/main.swift index 9bb17dc3..97e36bbe 100644 --- a/platforms/macos/Sources/Agent/main.swift +++ b/Sources/Agent/main.swift @@ -5,7 +5,7 @@ import Foundation // 两种运行模式(由首个参数决定): // 1. Daemon(无参数 / 只传 --socket):常驻 LaunchAgent,维持 BLE 连接 + 监听 Unix socket // ahakeyconfig-agent [--socket /tmp/ahakey.sock] -// 2. Hook 子命令(首个参数为 hook):Claude Code 会直接 exec 本进程 +// 2. Hook 子命令(首个参数为 hook):Claude Code / Cursor / Codex / Kimi Code CLI 会 exec 本进程 // ahakeyconfig-agent hook // 内部通过 Unix socket 联系常驻 daemon,并按需向 stdout 输出 Claude 决策 JSON。 diff --git a/platforms/macos/Sources/AhaKeyConfigApp.swift b/Sources/AhaKeyConfigApp.swift similarity index 67% rename from platforms/macos/Sources/AhaKeyConfigApp.swift rename to Sources/AhaKeyConfigApp.swift index 76686c6b..9ebb414f 100644 --- a/platforms/macos/Sources/AhaKeyConfigApp.swift +++ b/Sources/AhaKeyConfigApp.swift @@ -9,7 +9,7 @@ struct AhaKeyConfigApp: App { var body: some Scene { WindowGroup("AhaKey Studio") { ContentView(bleManager: bleManager) - .frame(minWidth: 1280, minHeight: 820) + .frame(minWidth: 1180, minHeight: 680) } .windowStyle(.titleBar) @@ -37,12 +37,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // 单实例:检查是否已有实例在运行 let bundleID = Bundle.main.bundleIdentifier ?? "lab.jawa.ahakeyconfig" + let currentBundlePath = Bundle.main.bundlePath let running = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) if running.count > 1 { - if let existing = running.first(where: { $0 != NSRunningApplication.current }) { - existing.activate() + let otherInstances = running.filter { $0 != NSRunningApplication.current } + let sameBundleInstance = otherInstances.first { app in + app.bundleURL?.path == currentBundlePath + } + + if let existing = sameBundleInstance { + existing.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSApp.terminate(nil) + return + } + + for stale in otherInstances { + stale.terminate() } - NSApp.terminate(nil) } VoiceRelayService.shared.start() @@ -63,7 +74,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func reopenMainWindow() { NSApp.activate(ignoringOtherApps: true) - if let mainWindow = NSApp.windows.first { + if let mainWindow = NSApp.windows.first(where: { $0.canBecomeMain && !$0.isMiniaturized }) { + mainWindow.makeKeyAndOrderFront(nil) + } else if let mainWindow = NSApp.windows.first(where: { $0.canBecomeMain }) { + mainWindow.deminiaturize(nil) mainWindow.makeKeyAndOrderFront(nil) } } diff --git a/Sources/BLE/AhaKeyBLEManager.swift b/Sources/BLE/AhaKeyBLEManager.swift new file mode 100644 index 00000000..2abd6f27 --- /dev/null +++ b/Sources/BLE/AhaKeyBLEManager.swift @@ -0,0 +1,1373 @@ +import AppKit +import Combine +import CoreBluetooth +import Foundation +import os.log +import UserNotifications + +private let log = Logger(subsystem: "lab.jawa.ahakeyconfig", category: "BLE") + +/// 0x83 查询回来的某个 mode 的图片元信息(用 Equatable struct 方便 SwiftUI .onChange 监听) +struct KeyboardPictureState: Equatable { + let frameCount: Int + let frameIntervalMs: Int +} + +struct KeyboardTaskPictureSlot: Hashable { + let mode: Int + let set: Int + let state: Int +} + +/// 通信日志条目 +struct BLELogEntry: Identifiable { + let id = UUID() + let timestamp: Date + let message: String + let isError: Bool + + var formattedTime: String { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f.string(from: timestamp) + } +} + +/// AhaKey-X1 BLE 通信管理器 +@MainActor +final class AhaKeyBLEManager: NSObject, ObservableObject { + typealias CommandResponse = (status: UInt8, payload: Data) + + struct OLEDUploadProgress: Equatable { + let completedChunks: Int + let totalChunks: Int + let completedFrames: Int + let totalFrames: Int + + var fractionCompleted: Double { + guard totalChunks > 0 else { return 0 } + return Double(completedChunks) / Double(totalChunks) + } + } + + // MARK: - Published State + + @Published private(set) var isScanning = false + @Published private(set) var isConnected = false + @Published private(set) var deviceName: String? + @Published private(set) var batteryLevel: Int = 0 + @Published private(set) var signalStrength: Int = 0 + @Published private(set) var firmwareMainVersion: Int = 0 + @Published private(set) var firmwareSubVersion: Int = 0 + @Published private(set) var firmwareRevision: String = "—" + @Published private(set) var modelNumber: String = "—" + @Published private(set) var workMode: Int = 0 + @Published private(set) var lightMode: Int = 0 + @Published private(set) var switchState: Int = 0 + @Published private(set) var bleConnectionStatus: String = "未连接" + @Published private(set) var bleDeviceUUID: String = "—" + @Published private(set) var bluetoothPermissionGranted = true + @Published private(set) var bluetoothPoweredOn = false + @Published private(set) var oledUploadProgress: OLEDUploadProgress? + @Published private(set) var isUploadingOLED = false + /// 由 ahakeyconfig-agent 写入的当前 IDE hook 状态值(IDEState.rawValue),用于画布 LED 颜色实时还原 + @Published private(set) var liveIDEStateValue: Int? = nil + /// Agent 端 BLE 通知缓存的 lightMode/switchState/workMode(agent 占用蓝牙时主 App 自己 BLE 未连,靠这些读到键盘实时状态) + @Published private(set) var agentLightMode: Int? = nil + @Published private(set) var agentSwitchState: Int? = nil + @Published private(set) var agentWorkMode: Int? = nil + /// 各 mode flash 里的真实图片元信息。 + /// 主 App 自占 BLE 后通过 0x83 查询填充;frameCount == 0 表示用户没自定义上传, + /// 键盘显示固件出厂动图(与 bundle/DefaultOLED 同源)。 + @Published private(set) var keyboardPictureStates: [Int: KeyboardPictureState] = [:] + /// 新固件的 [Mode, 套图, IDE 状态] 图片槽元数据。 + @Published private(set) var keyboardTaskPictureStates: [KeyboardTaskPictureSlot: AhaKeyTaskPictureState] = [:] + /// 每个 Mode 当前由固件选择的 GIF 套图。 + @Published private(set) var activeTaskPictureSets: [Int: Int] = [:] + + /// 通信日志(最近 200 条) + @Published private(set) var commLog: [BLELogEntry] = [] + private let maxLogEntries = 200 + + // 特征就绪状态 + @Published private(set) var dataCharReady = false + @Published private(set) var commandCharReady = false + @Published private(set) var notifyCharReady = false + + // MARK: - BLE Constants + + // AhaKey 主服务 + static let serviceUUID = CBUUID(string: "7340") + static let dataCharUUID = CBUUID(string: "7341") + static let infoCharUUID = CBUUID(string: "7342") + static let commandCharUUID = CBUUID(string: "7343") + static let notifyCharUUID = CBUUID(string: "7344") + + // 标准 Battery Service + static let batteryServiceUUID = CBUUID(string: "180F") + static let batteryLevelCharUUID = CBUUID(string: "2A19") + + // 标准 Device Information Service + static let deviceInfoServiceUUID = CBUUID(string: "180A") + static let firmwareRevisionCharUUID = CBUUID(string: "2A26") + static let modelNumberCharUUID = CBUUID(string: "2A24") + + nonisolated static let deviceNamePrefix = "vibe code" + + // MARK: - Private + + private var central: CBCentralManager! + private var peripheral: CBPeripheral? + private var dataChar: CBCharacteristic? + private var commandChar: CBCharacteristic? + private var notifyChar: CBCharacteristic? + private var batteryLevelChar: CBCharacteristic? + private var pendingConnect = false + private var rssiTimer: Timer? + private var autoReconnectTimer: Timer? + private var statusPollTimer: Timer? + private var ideStatePollTimer: Timer? + /// 记住上次连接的 UUID,用于快速重连 + private var lastPeripheralUUID: UUID? + /// 为 true 时,本 App 不扫描、不连接、不响应掉线/轮询重连(物理键盘由 `ahakeyconfig-agent` 占用时由 AgentManager 置位) + private var suppressAutomaticConnection = false + /// 防止 onAllCharacteristicsReady 重复触发 + private var didQueryAfterConnect = false + /// 写入队列:避免连发导致设备过载 + private var writeQueue: [(Data, String)] = [] + private var isWriting = false + /// 与 `writeQueue` 前缀顺序对应的各批 `writeCommandsSequentially` 剩余条数与完成回调。 + private struct WriteCommandBatch { + var commandsRemaining: Int + var completion: (() -> Void)? + } + + private var writeBatches: [WriteCommandBatch] = [] + private var protocolResponseWaiters: [UInt8: CheckedContinuation] = [:] + private var dataWriteResultContinuation: CheckedContinuation? + + // MARK: - Init + + override init() { + super.init() + // 与 AgentManager 启动顺序无关:若上次退出前选择「Agent 占蓝牙」, + // 在蓝牙栈 poweredOn 回调里会立刻重连,必须在 init 就挡住。 + let storedOwner = UserDefaults.standard.string(forKey: "lab.jawa.ahakeyconfig.bluetoothConnectionOwner") + if storedOwner == nil || storedOwner == BluetoothConnectionOwner.agentDaemon.rawValue { + suppressAutomaticConnection = true + } + central = CBCentralManager(delegate: nil, queue: nil) + central.delegate = self + refreshBluetoothAuthorization() + startAutoReconnectPolling() + startIDEStatePolling() + } + + // MARK: - Public API + + func refreshBluetoothAuthorization() { + bluetoothPermissionGranted = Self.currentBluetoothAuthorizationGranted() + bluetoothPoweredOn = central?.state == .poweredOn + if !bluetoothPermissionGranted { + bleConnectionStatus = "蓝牙权限未开启" + } else if central?.state == .poweredOff { + bleConnectionStatus = "蓝牙关闭" + } + } + + var bluetoothAuthorizationCanPrompt: Bool { + CBCentralManager.authorization == .notDetermined + } + + var bluetoothAuthorizationDeniedOrRestricted: Bool { + switch CBCentralManager.authorization { + case .restricted, .denied: + return true + case .allowedAlways, .notDetermined: + return false + @unknown default: + return false + } + } + + private static func currentBluetoothAuthorizationGranted() -> Bool { + switch CBCentralManager.authorization { + case .allowedAlways: + return true + case .notDetermined: + return true + case .restricted, .denied: + return false + @unknown default: + return true + } + } + + /// 由「设备信息 / 顶栏」等**用户显式**发起连接时调用:取消「交给 Agent」时的抑制并尝试连接。 + func userInitiatedConnect() { + suppressAutomaticConnection = false + connectAutomatically() + } + + /// 与 `AgentManager` 的蓝牙占用方一致:交给 Agent 时为 true,交回本 App 时为 false。 + func setSuppressedForAgentOwningKeyboard(_ suppress: Bool) { + suppressAutomaticConnection = suppress + } + + func connectAutomatically() { + guard !suppressAutomaticConnection else { return } + guard central.state == .poweredOn else { + pendingConnect = true + return + } + + // 1. 用已知 UUID 直连(最快) + if let uuid = lastPeripheralUUID { + let known = central.retrievePeripherals(withIdentifiers: [uuid]) + if let p = known.first { + appendLog("用已知 UUID 直连: \(p.name ?? uuid.uuidString)") + self.peripheral = p + p.delegate = self + central.connect(p, options: nil) + bleConnectionStatus = "连接中…" + return + } + } + + // 2. 查找系统已连接设备 + let connected = central.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) + if let existing = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(Self.deviceNamePrefix) }) { + appendLog("发现系统已连接设备: \(existing.name ?? "?")") + self.peripheral = existing + existing.delegate = self + central.connect(existing, options: nil) + bleConnectionStatus = "连接中…" + return + } + + // 3. 扫描 + startScan() + } + + func startScan() { + guard central.state == .poweredOn else { + pendingConnect = true + return + } + isScanning = true + bleConnectionStatus = "扫描中…" + appendLog("开始扫描 AhaKey 设备…") + central.scanForPeripherals( + withServices: [Self.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Double(10) * 1_000_000_000)) + if self.isScanning { + self.central.stopScan() + self.isScanning = false + self.bleConnectionStatus = "等待设备" + self.appendLog("扫描超时,继续后台轮询设备") + } + } + } + + func disconnect() { + guard let peripheral else { return } + central.cancelPeripheralConnection(peripheral) + appendLog("用户主动断开") + } + + /// 发送原始命令到 0x7343(带队列,防止连发过载) + func writeCommand(_ data: Data) { + guard let commandChar, let peripheral else { + appendLog("命令通道未就绪", isError: true) + return + } + let writeType: CBCharacteristicWriteType = + commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(data, for: commandChar, type: writeType) + appendLog("→ CMD \(data.count)B: \(data.hexString)") + } + + func uploadOLEDFrames(_ frames: [Data], fps: Int, mode: UInt8 = 0, startIndex: UInt16 = 0) async throws { + guard let peripheral, let dataChar, let commandChar else { + throw OLEDUploadError.channelNotReady + } + guard !frames.isEmpty else { + throw OLEDUploadError.noFrames + } + guard frames.count <= AhaKeyCommand.oledMaxFrames else { + throw OLEDUploadError.tooManyFrames(max: AhaKeyCommand.oledMaxFrames) + } + + isUploadingOLED = true + oledUploadProgress = OLEDUploadProgress( + completedChunks: 0, + totalChunks: frames.reduce(0) { partialResult, frame in + partialResult + max(1, Int(ceil(Double(frame.count) / Double(AhaKeyCommand.oledChunkSize)))) + }, + completedFrames: 0, + totalFrames: frames.count + ) + appendLog("开始上传 LCD 数据: \(frames.count) 帧, FPS=\(fps), mode=\(mode), startIndex=\(startIndex), frameSlotSize=\(AhaKeyCommand.oledFrameSlotSize)") + + defer { + isUploadingOLED = false + oledUploadProgress = nil + } + + let writeType: CBCharacteristicWriteType = + dataChar.properties.contains(.write) ? .withResponse : .withoutResponse + var completedChunks = 0 + + for (frameIndex, frame) in frames.enumerated() { + let frameAddress = UInt32(Int(startIndex) + frameIndex) * UInt32(AhaKeyCommand.oledFrameSlotSize) + appendLog(" 帧 #\(frameIndex) 物理地址=0x\(String(format: "%08X", frameAddress))=\(frameAddress), 大小=\(frame.count)B") + let chunks = stride(from: 0, to: frame.count, by: AhaKeyCommand.oledChunkSize).map { offset in + let end = min(offset + AhaKeyCommand.oledChunkSize, frame.count) + return (offset: offset, data: Data(frame[offset ..< end])) + } + + for chunk in chunks { + let address = frameAddress + UInt32(chunk.offset) + let prepare = AhaKeyCommand.prepareWrite(chunkLength: chunk.data.count, address: address) + _ = try await sendCommandAwaitingResponse(prepare, expectedCommand: AhaKeyCommand.cmdPrepareWrite) + + try await writeDataChunk(chunk.data, to: peripheral, characteristic: dataChar, type: writeType) + completedChunks += 1 + oledUploadProgress = OLEDUploadProgress( + completedChunks: completedChunks, + totalChunks: oledUploadProgress?.totalChunks ?? completedChunks, + completedFrames: frameIndex, + totalFrames: frames.count + ) + } + + oledUploadProgress = OLEDUploadProgress( + completedChunks: completedChunks, + totalChunks: oledUploadProgress?.totalChunks ?? completedChunks, + completedFrames: frameIndex + 1, + totalFrames: frames.count + ) + } + + let delay = UInt16(max(1, 1000 / max(1, fps))) + let updateCommand = AhaKeyCommand.updatePicture( + mode: mode, + startIndex: startIndex, + frameCount: UInt16(frames.count), + timeDelayMs: delay + ) + appendLog("→ updatePicture mode=\(mode) startIndex=\(startIndex) frameCount=\(frames.count) delayMs=\(delay) hex=\(updateCommand.hexString)") + _ = try await sendCommandAwaitingResponse(updateCommand, expectedCommand: AhaKeyCommand.cmdUpdatePic) + appendLog("LCD 上传完成: \(frames.count) 帧, start=\(startIndex)") + _ = commandChar + } + + /// 按原有 0x80 + DATA 链路写帧,再以 0x84 提交给特定的套图/任务状态。 + func uploadTaskOLEDFrames( + _ frames: [Data], + fps: Int, + mode: UInt8, + set: UInt8, + state: UInt8, + startIndex: UInt16 + ) async throws { + guard let peripheral, let dataChar else { + throw OLEDUploadError.channelNotReady + } + guard !frames.isEmpty else { + throw OLEDUploadError.noFrames + } + guard frames.count <= AhaKeyCommand.oledMaxFrames else { + throw OLEDUploadError.tooManyFrames(max: AhaKeyCommand.oledMaxFrames) + } + + isUploadingOLED = true + oledUploadProgress = OLEDUploadProgress( + completedChunks: 0, + totalChunks: frames.reduce(0) { result, frame in + result + max(1, Int(ceil(Double(frame.count) / Double(AhaKeyCommand.oledChunkSize)))) + }, + completedFrames: 0, + totalFrames: frames.count + ) + defer { + isUploadingOLED = false + oledUploadProgress = nil + } + + let writeType: CBCharacteristicWriteType = + dataChar.properties.contains(.write) ? .withResponse : .withoutResponse + var completedChunks = 0 + appendLog("开始上传任务 LCD: mode=\(mode) set=\(set) state=\(state) frames=\(frames.count) start=\(startIndex)") + + for (frameIndex, frame) in frames.enumerated() { + let frameAddress = UInt32(Int(startIndex) + frameIndex) * UInt32(AhaKeyCommand.oledFrameSlotSize) + let chunks = stride(from: 0, to: frame.count, by: AhaKeyCommand.oledChunkSize).map { offset in + let end = min(offset + AhaKeyCommand.oledChunkSize, frame.count) + return (offset: offset, data: Data(frame[offset ..< end])) + } + + for chunk in chunks { + let address = frameAddress + UInt32(chunk.offset) + let prepare = AhaKeyCommand.prepareWrite(chunkLength: chunk.data.count, address: address) + _ = try await sendCommandAwaitingResponse(prepare, expectedCommand: AhaKeyCommand.cmdPrepareWrite) + try await writeDataChunk(chunk.data, to: peripheral, characteristic: dataChar, type: writeType) + completedChunks += 1 + oledUploadProgress = OLEDUploadProgress( + completedChunks: completedChunks, + totalChunks: oledUploadProgress?.totalChunks ?? completedChunks, + completedFrames: frameIndex, + totalFrames: frames.count + ) + } + + oledUploadProgress = OLEDUploadProgress( + completedChunks: completedChunks, + totalChunks: oledUploadProgress?.totalChunks ?? completedChunks, + completedFrames: frameIndex + 1, + totalFrames: frames.count + ) + } + + let delay = UInt16(max(1, 1000 / max(1, fps))) + let update = AhaKeyCommand.updateTaskPicture( + mode: mode, + set: set, + state: state, + startIndex: startIndex, + frameCount: UInt16(frames.count), + timeDelayMs: delay + ) + _ = try await sendCommandAwaitingResponse(update, expectedCommand: AhaKeyCommand.cmdUpdateTaskPic) + let slot = KeyboardTaskPictureSlot(mode: Int(mode), set: Int(set), state: Int(state)) + keyboardTaskPictureStates[slot] = AhaKeyTaskPictureState( + mode: Int(mode), + set: Int(set), + state: Int(state), + startIndex: Int(startIndex), + picLength: frames.count, + frameInterval: Int(delay), + allModeMaxPic: AhaKeyCommand.oledMaxFrames, + activeSet: activeTaskPictureSets[Int(mode)] ?? 0 + ) + appendLog("任务 LCD 上传完成: mode=\(mode) set=\(set) state=\(state) frames=\(frames.count)") + } + + /// 批量写入命令(每条间隔 50ms,避免设备过载)。**该批**全部写入后会在主线程执行 `completion`(若入队 0 条则立即执行)。 + func writeCommandsSequentially( + _ commands: [(data: Data, label: String)], + completion: (() -> Void)? = nil + ) { + if commands.isEmpty { + completion?() + return + } + writeBatches.append(WriteCommandBatch(commandsRemaining: commands.count, completion: completion)) + writeQueue.append(contentsOf: commands.map { ($0.data, $0.label) }) + drainWriteQueue() + } + + private func drainWriteQueue() { + guard !isWriting, !writeQueue.isEmpty else { return } + isWriting = true + let (data, label) = writeQueue.removeFirst() + if !writeBatches.isEmpty { + writeBatches[0].commandsRemaining -= 1 + if writeBatches[0].commandsRemaining == 0 { + let c = writeBatches.removeFirst().completion + c?() + } + } + appendLog(label) + writeCommand(data) + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(50) * 1_000_000) + self.isWriting = false + self.drainWriteQueue() + } + } + + /// 查询设备状态 + func queryDeviceStatus() { + let cmd = AhaKeyCommand.queryDeviceStatus() + appendLog("查询设备状态…") + writeCommand(cmd) + } + + /// 设置键位映射 + func setKeyMapping(mode: UInt8 = 0, keyIndex: UInt8, hidCodes: [UInt8]) { + let cmd = AhaKeyCommand.setKeyMapping(mode: mode, keyIndex: keyIndex, hidCodes: hidCodes) + let keyName = "Key\(keyIndex + 1)" + let codeNames = hidCodes.map { HIDUsage.name(for: $0) }.joined(separator: "+") + appendLog("写入 Mode\(mode) \(keyName) 键码: \(codeNames)") + writeCommand(cmd) + } + + /// 设置按键宏(固件 subMacro 子类型 0x74)。 + /// - parameter macroData: 已展平的 (action, param) 字节流。固件上限 98 字节。 + func setKeyMacro(mode: UInt8 = 0, keyIndex: UInt8, macroData: [UInt8]) { + let cmd = AhaKeyCommand.setKeyMacro(mode: mode, keyIndex: keyIndex, macroData: macroData) + appendLog("写入 Mode\(mode) Key\(keyIndex + 1) 宏: \(macroData.count) 字节 / \(macroData.count / 2) 步") + writeCommand(cmd) + } + + /// 设置按键描述(显示在 LCD 上) + func setKeyDescription(mode: UInt8 = 0, keyIndex: UInt8, text: String) { + let cmd = AhaKeyCommand.setKeyDescription(mode: mode, keyIndex: keyIndex, text: text) + appendLog("写入 Mode\(mode) Key\(keyIndex + 1) 描述: \(text)") + writeCommand(cmd) + } + + /// 保存配置到设备 Flash + func saveConfig() { + let cmd = AhaKeyCommand.saveConfig() + appendLog("保存配置到设备…") + writeCommand(cmd) + } + + func readPictureState(mode: UInt8) async throws -> AhaKeyPictureState { + let response = try await sendCommandAwaitingResponse( + AhaKeyCommand.readPicState(mode: mode), + expectedCommand: AhaKeyCommand.cmdReadPicState + ) + guard let state = AhaKeyResponseParser.parsePictureStateResponse(response.payload) else { + throw OLEDUploadError.invalidPictureStatePayload + } + appendLog(" 图片状态 mode=\(state.mode) start=\(state.startIndex) length=\(state.picLength) interval=\(state.frameInterval) max=\(state.allModeMaxPic)") + return state + } + + func clearTaskPicture(mode: UInt8, set: UInt8, state: UInt8) async throws { + let update = AhaKeyCommand.updateTaskPicture( + mode: mode, + set: set, + state: state, + startIndex: 0, + frameCount: 0, + timeDelayMs: 0 + ) + _ = try await sendCommandAwaitingResponse(update, expectedCommand: AhaKeyCommand.cmdUpdateTaskPic) + let slot = KeyboardTaskPictureSlot(mode: Int(mode), set: Int(set), state: Int(state)) + let current = keyboardTaskPictureStates[slot] + keyboardTaskPictureStates[slot] = AhaKeyTaskPictureState( + mode: Int(mode), + set: Int(set), + state: Int(state), + startIndex: 0, + picLength: 0, + frameInterval: 0, + allModeMaxPic: current?.allModeMaxPic ?? AhaKeyCommand.oledMaxFrames, + activeSet: activeTaskPictureSets[Int(mode)] ?? 0 + ) + } + + func readTaskPictureState(mode: UInt8, set: UInt8, state: UInt8) async throws -> AhaKeyTaskPictureState { + let response = try await sendCommandAwaitingResponse( + AhaKeyCommand.readTaskPictureState(mode: mode, set: set, state: state), + expectedCommand: AhaKeyCommand.cmdReadTaskPicState + ) + guard let picture = AhaKeyResponseParser.parseTaskPictureStateResponse(response.payload) else { + throw OLEDUploadError.invalidTaskPictureStatePayload + } + let slot = KeyboardTaskPictureSlot(mode: picture.mode, set: picture.set, state: picture.state) + keyboardTaskPictureStates[slot] = picture + activeTaskPictureSets[picture.mode] = picture.activeSet + return picture + } + + func readAllTaskPictureStates() async throws -> [AhaKeyTaskPictureState] { + var result: [AhaKeyTaskPictureState] = [] + for mode in 0 ..< 3 { + for set in 0 ..< 2 { + for state in IDEState.allCases { + result.append(try await readTaskPictureState( + mode: UInt8(mode), + set: UInt8(set), + state: state.rawValue + )) + } + } + } + return result + } + + func setActiveTaskPictureSet(mode: UInt8, set: UInt8) async throws -> Int { + let response = try await sendCommandAwaitingResponse( + AhaKeyCommand.setActiveTaskPictureSet(mode: mode, set: set), + expectedCommand: AhaKeyCommand.cmdSetActiveTaskPicSet + ) + guard response.payload.count >= 2, Int(response.payload[0]) == Int(mode) else { + throw OLEDUploadError.invalidTaskPictureStatePayload + } + let activeSet = Int(response.payload[1]) + activeTaskPictureSets[Int(mode)] = activeSet + return activeSet + } + + func saveConfigAwaitingResponse() async throws { + _ = try await sendCommandAwaitingResponse( + AhaKeyCommand.saveConfig(), + expectedCommand: AhaKeyCommand.cmdSaveConfig + ) + } + + /// 同步 IDE 状态到键盘 LED + func updateIDEState(_ state: IDEState) { + guard commandChar != nil else { return } + let cmd = AhaKeyCommand.updateState(state) + writeCommand(cmd) + } + + /// 虚拟拨杆 → BLE 0x91:通过 BLE 改键盘 sw_state(需要固件 patch) + /// value: 0=auto/up, 1=manual/down, 2=mid + func setSwitchStateViaBLE(_ value: UInt8) { + guard commandChar != nil else { return } + let cmd = AhaKeyCommand.setSwitchState(value) + writeCommand(cmd) + appendLog("→ 虚拟拨杆 sw_state=\(value)") + } + + /// 修改设备蓝牙名称 + func changeDeviceName(_ name: String) { + let cmd = AhaKeyCommand.changeName(name) + appendLog("修改设备名: \(name)") + writeCommand(cmd) + // 修改后保存并刷新 + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(100) * 1_000_000) + self.saveConfig() + } + } + + func clearLog() { + commLog.removeAll() + } + + /// 与内部 `appendLog` 相同(含 `~/Library/.../AhaKeyConfig/diagnostics/ble-comm.log` 与系统日志),供 Studio 等写入调试说明。 + func appendCommLogLine(_ message: String, isError: Bool = false) { + appendLog(message, isError: isError) + } + + // MARK: - Logging + + static let logFileURL: URL = { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("ble-comm.log") + }() + + private func appendLog(_ message: String, isError: Bool = false) { + let entry = BLELogEntry(timestamp: Date(), message: message, isError: isError) + commLog.append(entry) + if commLog.count > maxLogEntries { + commLog.removeFirst(commLog.count - maxLogEntries) + } + if isError { + log.error("\(message)") + } else { + log.info("\(message)") + } + let line = "[\(entry.formattedTime)] \(message)\n" + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: Self.logFileURL.path) { + if let fh = try? FileHandle(forWritingTo: Self.logFileURL) { + fh.seekToEndOfFile() + fh.write(data) + fh.closeFile() + } + } else { + try? data.write(to: Self.logFileURL) + } + } + } + + private func startRSSIPolling() { + rssiTimer?.invalidate() + rssiTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.peripheral?.readRSSI() + } + } + } + + private func startAutoReconnectPolling() { + autoReconnectTimer?.invalidate() + autoReconnectTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + guard self.central.state == .poweredOn else { return } + guard !self.isConnected, !self.isScanning else { return } + guard self.bleConnectionStatus != "连接中…" else { return } + self.appendLog("后台轮询中,尝试寻找设备…") + self.connectAutomatically() + } + } + } + + private func stopRSSIPolling() { + rssiTimer?.invalidate() + rssiTimer = nil + } + + /// 周期性查询设备状态,用于感知键盘物理档位变化(workMode / switchState / lightMode)。 + /// 固件不会在档位切换时主动 push,必须靠轮询。 + private func startStatusPolling() { + statusPollTimer?.invalidate() + statusPollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + guard self.isConnected else { return } + // 正在上传 LCD 时避免占用命令通道 + guard !self.isUploadingOLED else { return } + // 有 protocol 响应在等(如 readPictureState / saveConfig)时也跳过 + guard self.protocolResponseWaiters.isEmpty else { return } + self.queryDeviceStatus() + } + } + } + + private func stopStatusPolling() { + statusPollTimer?.invalidate() + statusPollTimer = nil + } + + private func startIDEStatePolling() { + ideStatePollTimer?.invalidate() + ideStatePollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.pollIDEStateFile() + } + } + } + + /// 主动触发一次共享文件读取(用户点击虚拟拨杆后立即调用,避免等下一次定时 poll) + func refreshAgentStateFromFileNow() { + pollIDEStateFile() + } + + /// 点击虚拟拨杆瞬间的乐观更新值。在文件 poll 把 agentSwitchState 刷新到目标值前先顶住, + /// 之后 polling 把真实值刷过来时再清掉,保证按一下立刻看到拨杆切档。 + @Published private(set) var optimisticSwitchOverride: Int? = nil + + func applyOptimisticSwitchOverride(_ value: UInt8) { + optimisticSwitchOverride = Int(value) + } + + private func clearOptimisticSwitchOverrideIfMatched() { + guard let opt = optimisticSwitchOverride else { return } + if agentSwitchState == opt || (isConnected && switchState == opt) { + optimisticSwitchOverride = nil + } + } + + private func pollIDEStateFile() { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/current-ide-state.json") + guard let data = try? Data(contentsOf: url), + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + if liveIDEStateValue != nil { liveIDEStateValue = nil } + if agentLightMode != nil { agentLightMode = nil } + if agentSwitchState != nil { agentSwitchState = nil } + if agentWorkMode != nil { agentWorkMode = nil } + return + } + let now = Date().timeIntervalSince1970 + // stateValue 是瞬时态(hook 触发),30s 过期;超时则置空,固件 LED 也会回到无 state 默认 + if let v = obj["stateValue"] as? Int, + let stateTs = (obj["stateTs"] as? Double) ?? (obj["ts"] as? Double), + now - stateTs <= 30 { + if liveIDEStateValue != v { liveIDEStateValue = v } + } else { + if liveIDEStateValue != nil { liveIDEStateValue = nil } + } + // lightMode/switchState/workMode 来自 BLE 通知,2 分钟没新数据视为 agent 已断连 + if let topTs = obj["ts"] as? Double, now - topTs <= 120 { + let lm = obj["lightMode"] as? Int + let sw = obj["switchState"] as? Int + let wm = obj["workMode"] as? Int + if agentLightMode != lm { agentLightMode = lm } + if agentSwitchState != sw { agentSwitchState = sw } + if agentWorkMode != wm { agentWorkMode = wm } + } else { + if agentLightMode != nil { agentLightMode = nil } + if agentSwitchState != nil { agentSwitchState = nil } + if agentWorkMode != nil { agentWorkMode = nil } + } + clearOptimisticSwitchOverrideIfMatched() + } + + /// 所有 AhaKey 主服务特征就绪后触发(仅一次) + private func onAllCharacteristicsReady() { + guard !didQueryAfterConnect else { return } + didQueryAfterConnect = true + appendLog("所有特征就绪,查询设备状态") + queryDeviceStatus() + queryAllPictureStates() + } + + /// 顺序查询每个 mode 的 0x83 图片元信息,结果累积到 keyboardPictureStates + private func queryAllPictureStates() { + Task { [weak self] in + guard let self else { return } + for slot in 0..<3 { + do { + let state = try await self.readPictureState(mode: UInt8(slot)) + self.keyboardPictureStates[slot] = KeyboardPictureState( + frameCount: state.picLength, + frameIntervalMs: state.frameInterval + ) + self.appendLog(" mode\(slot) flash: 帧数=\(state.picLength) 间隔=\(state.frameInterval)ms") + } catch { + self.appendLog(" mode\(slot) 图片状态查询失败: \(error)", isError: true) + } + } + } + } + + private func sendCommandAwaitingResponse(_ data: Data, expectedCommand: UInt8, timeoutSeconds: Double = 5.0) async throws -> CommandResponse { + defer { protocolResponseWaiters[expectedCommand] = nil } + return try await withThrowingTaskGroup(of: CommandResponse.self) { group in + group.addTask { [weak self] in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Task { @MainActor in + self?.protocolResponseWaiters[expectedCommand] = continuation + self?.writeCommand(data) + } + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Double(timeoutSeconds) * 1_000_000_000)) + throw OLEDUploadError.timeout(command: expectedCommand) + } + + let result = try await group.next() ?? (status: 0, payload: Data()) + group.cancelAll() + guard result.status == 0 else { + throw OLEDUploadError.deviceRejected(command: expectedCommand, status: result.status) + } + return result + } + } + + private func writeDataChunk( + _ data: Data, + to peripheral: CBPeripheral, + characteristic: CBCharacteristic, + type: CBCharacteristicWriteType, + timeoutSeconds: Double = 5.0 + ) async throws { + defer { dataWriteResultContinuation = nil } + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { [weak self] in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Task { @MainActor in + self?.dataWriteResultContinuation = continuation + let negotiatedLength = max(1, peripheral.maximumWriteValueLength(for: type)) + // 固件侧按 oledPacketSize (≈180B) 组帧,必须以它为子包上限, + // 否则会触发 CoreBluetooth "value's length is invalid" 或固件直接丢帧。 + let maxPacketLength = min(negotiatedLength, AhaKeyCommand.oledPacketSize) + self?.appendLog("→ DATA \(data.count)B, 分片 \(maxPacketLength)B (协商上限 \(negotiatedLength)B)") + Task { + for offset in stride(from: 0, to: data.count, by: maxPacketLength) { + let end = min(offset + maxPacketLength, data.count) + let packet = Data(data[offset ..< end]) + peripheral.writeValue(packet, for: characteristic, type: type) + try? await Task.sleep(nanoseconds: UInt64(12) * 1_000_000) + } + } + } + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Double(timeoutSeconds) * 1_000_000_000)) + throw OLEDUploadError.timeout(command: AhaKeyCommand.cmdWriteResult) + } + + _ = try await group.next() + group.cancelAll() + } + } +} + +// MARK: - 拨杆档位切换 → 系统通知(与 `switchState` 同源,放在本文件避免独立 .swift 未被索引器收录) + +/// 监听 `AhaKeyBLEManager.switchState` 的稳定变化,在拨杆切换档位时弹一条 macOS 通知。 +@MainActor +final class SwitchStateNotifier: ObservableObject { + static let shared = SwitchStateNotifier() + + private weak var bleManager: AhaKeyBLEManager? + private var cancellable: AnyCancellable? + private var lastObservedState: Int? + private var lastNotificationAt: Date? + private var hasInitialState = false + private var hasRequestedAuthorization = false + + private init() {} + + func bind(to manager: AhaKeyBLEManager) { + if bleManager === manager, cancellable != nil { return } + + bleManager = manager + lastObservedState = nil + hasInitialState = false + cancellable = manager.$switchState + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] newState in + self?.handleStateChange(newState) + } + } + + private func handleStateChange(_ newState: Int) { + defer { lastObservedState = newState } + + guard hasInitialState else { + hasInitialState = true + return + } + + guard let previous = lastObservedState, previous != newState else { return } + + if let last = lastNotificationAt, Date().timeIntervalSince(last) < 1.5 { + return + } + lastNotificationAt = Date() + + let switchedToAuto = (previous != 0 && newState == 0) + let switchedToManual = (previous == 0 && newState != 0) + + if switchedToAuto { + postNotification( + title: "拨杆 → 自动批准", + body: "Kimi:若已安装 AhaKey Kimi Hooks,自动档会直接接管当前会话批准;若刚装完或刚升级 kimi-cli,请先重开一次 kimi。Claude/Cursor/Codex 仍走各自钩子。", + identifier: "lab.jawa.ahakey.switch.auto", + isCritical: true + ) + } else if switchedToManual { + postNotification( + title: "拨杆 → 手动批准", + body: "Claude / Cursor / Codex:按各自确认链。Kimi:若已安装 AhaKey Kimi Hooks,手动档会直接把当前会话拉回手动批准。", + identifier: "lab.jawa.ahakey.switch.manual", + isCritical: false + ) + } + } + + private func postNotification(title: String, body: String, identifier: String, isCritical: Bool) { + let center = UNUserNotificationCenter.current() + let deliver = { [weak self] in + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = isCritical ? .defaultCritical : .default + let request = UNNotificationRequest(identifier: "\(identifier).\(UUID().uuidString)", + content: content, + trigger: nil) + center.add(request) { error in + if error != nil, isCritical { + Task { @MainActor in + self?.fallbackAlert(title: title, body: body) + } + } + } + } + + if hasRequestedAuthorization { + deliver() + return + } + hasRequestedAuthorization = true + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + if granted { + deliver() + } else if isCritical { + Task { @MainActor in + self.fallbackAlert(title: title, body: body) + } + } + } + } + + private func fallbackAlert(title: String, body: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = body + alert.alertStyle = .warning + alert.addButton(withTitle: "知道了") + alert.runModal() + } +} + +enum OLEDUploadError: LocalizedError { + case channelNotReady + case noFrames + case tooManyFrames(max: Int) + case noAvailablePictureSlot(needed: Int, max: Int) + case timeout(command: UInt8) + case deviceRejected(command: UInt8, status: UInt8) + case invalidPictureStatePayload + case invalidTaskPictureStatePayload + + var errorDescription: String? { + switch self { + case .channelNotReady: + return "BLE 数据通道还没准备好。" + case .noFrames: + return "没有可上传的图片帧。" + case .tooManyFrames(let max): + return "帧数超过设备上限,最多支持 \(max) 帧。" + case .noAvailablePictureSlot(let needed, let max): + return "动画需要 \(needed) 帧,但设备当前没有足够连续空间。总容量上限约为 \(max) 帧。" + case .timeout(let command): + return String(format: "等待设备响应超时: 0x%02X", command) + case .deviceRejected(let command, let status): + return String(format: "设备拒绝了命令 0x%02X,状态码 0x%02X", command, status) + case .invalidPictureStatePayload: + return "设备返回的动画槽位信息无法解析。" + case .invalidTaskPictureStatePayload: + return "设备返回的任务动画槽位信息无法解析;请确认键盘已烧录任务 GIF 固件。" + } + } +} + +// MARK: - CBCentralManagerDelegate + +extension AhaKeyBLEManager: CBCentralManagerDelegate { + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { @MainActor in + switch central.state { + case .poweredOn: + self.refreshBluetoothAuthorization() + self.appendLog("蓝牙已开启") + self.connectAutomatically() + case .poweredOff: + self.refreshBluetoothAuthorization() + self.appendLog("蓝牙已关闭", isError: true) + self.bleConnectionStatus = "蓝牙关闭" + case .unauthorized: + self.refreshBluetoothAuthorization() + self.appendLog("蓝牙权限未开启", isError: true) + self.bleConnectionStatus = "蓝牙权限未开启" + default: + self.refreshBluetoothAuthorization() + break + } + } + } + + nonisolated 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(Self.deviceNamePrefix) else { return } + + Task { @MainActor in + self.appendLog("发现设备: \(name) RSSI=\(RSSI)") + self.central.stopScan() + self.isScanning = false + self.peripheral = peripheral + peripheral.delegate = self + self.central.connect(peripheral, options: nil) + self.bleConnectionStatus = "连接中…" + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + Task { @MainActor in + self.isConnected = true + self.deviceName = peripheral.name + self.bleDeviceUUID = peripheral.identifier.uuidString + self.lastPeripheralUUID = peripheral.identifier + self.bleConnectionStatus = "已连接" + self.appendLog("已连接: \(peripheral.name ?? "?") UUID=\(peripheral.identifier.uuidString)") + self.autoReconnectTimer?.invalidate() + self.autoReconnectTimer = nil + peripheral.discoverServices([ + Self.serviceUUID, + Self.batteryServiceUUID, + Self.deviceInfoServiceUUID, + ]) + peripheral.readRSSI() + self.startRSSIPolling() + self.startStatusPolling() + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + Task { @MainActor in + self.bleConnectionStatus = "连接失败" + self.appendLog("连接失败: \(error?.localizedDescription ?? "未知")", isError: true) + self.startAutoReconnectPolling() + // 3 秒后重试 + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Double(3) * 1_000_000_000)) + if !self.isConnected { + self.connectAutomatically() + } + } + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + Task { @MainActor in + let dropped = self.writeQueue.count + let openBatches = self.writeBatches.count + if dropped > 0 || openBatches > 0 { + self.appendLog( + "BLE 已断开,丢弃未发出命令 \(dropped) 条(未闭合批 \(openBatches) 个)。\(error.map { "原因:\($0.localizedDescription)" } ?? "")", + isError: true + ) + } + self.isConnected = false + self.bleConnectionStatus = "已断开" + self.dataChar = nil + self.commandChar = nil + self.notifyChar = nil + self.batteryLevelChar = nil + self.dataCharReady = false + self.commandCharReady = false + self.notifyCharReady = false + // 不清 peripheral 和 lastPeripheralUUID——用于直连重试 + self.peripheral = nil + self.writeQueue.removeAll() + self.isWriting = false + self.writeBatches.removeAll() + self.didQueryAfterConnect = false + self.keyboardPictureStates.removeAll() + self.keyboardTaskPictureStates.removeAll() + self.activeTaskPictureSets.removeAll() + self.stopRSSIPolling() + self.stopStatusPolling() + self.startAutoReconnectPolling() + self.appendLog("已断开: \(error?.localizedDescription ?? "正常")") + + // 2 秒后自动重连 + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Double(2) * 1_000_000_000)) + if !self.isConnected { + self.appendLog("尝试自动重连…") + self.connectAutomatically() + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension AhaKeyBLEManager: CBPeripheralDelegate { + nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + Task { @MainActor in + guard let services = peripheral.services else { return } + for service in services { + self.appendLog("发现服务: \(service.uuid)") + switch service.uuid { + case Self.serviceUUID: + peripheral.discoverCharacteristics( + [Self.dataCharUUID, Self.infoCharUUID, Self.commandCharUUID, Self.notifyCharUUID], + for: service + ) + case Self.batteryServiceUUID: + peripheral.discoverCharacteristics([Self.batteryLevelCharUUID], for: service) + case Self.deviceInfoServiceUUID: + peripheral.discoverCharacteristics( + [Self.firmwareRevisionCharUUID, Self.modelNumberCharUUID], + for: service + ) + default: + break + } + } + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + Task { @MainActor in + for char in service.characteristics ?? [] { + switch char.uuid { + // AhaKey 主服务特征 + case Self.dataCharUUID: + self.dataChar = char + self.dataCharReady = true + peripheral.setNotifyValue(true, for: char) + self.appendLog("数据特征(0x7341) 已订阅通知") + case Self.commandCharUUID: + self.commandChar = char + self.commandCharReady = true + self.appendLog("命令特征(0x7343) 就绪") + case Self.notifyCharUUID: + self.notifyChar = char + self.notifyCharReady = true + peripheral.setNotifyValue(true, for: char) + self.appendLog("通知特征(0x7344) 已订阅") + case Self.infoCharUUID: + self.appendLog("设备信息(0x7342) 就绪") + + // 标准 Battery Level + case Self.batteryLevelCharUUID: + self.batteryLevelChar = char + peripheral.readValue(for: char) + if char.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: char) + } + self.appendLog("电池特征(0x2A19) 读取中") + + // 标准 Device Information + case Self.firmwareRevisionCharUUID: + peripheral.readValue(for: char) + case Self.modelNumberCharUUID: + peripheral.readValue(for: char) + + default: + break + } + } + + // 检查 AhaKey 三个核心特征是否全部就绪,再发查询 + if self.dataCharReady && self.commandCharReady && self.notifyCharReady { + self.onAllCharacteristicsReady() + } + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard let data = characteristic.value else { return } + Task { @MainActor in + self.handleNotification(from: characteristic.uuid, data: data) + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + Task { @MainActor in + self.signalStrength = RSSI.intValue + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + Task { @MainActor in + if let error { + self.appendLog("写入特征 \(characteristic.uuid) 失败: \(error.localizedDescription)", isError: true) + } else { + self.appendLog("写入特征 \(characteristic.uuid) 完成") + } + } + } + + private func handleNotification(from uuid: CBUUID, data: Data) { + let hex = data.hexString + switch uuid { + case Self.dataCharUUID: + appendLog("← DATA(0x7341): \(hex)") + parseProtocolResponse(data) + case Self.notifyCharUUID: + appendLog("← NOTIFY(0x7344): \(hex)") + parseProtocolResponse(data) + case Self.batteryLevelCharUUID: + if let level = data.first { + batteryLevel = Int(level) + appendLog("← 电池: \(batteryLevel)%") + } + case Self.firmwareRevisionCharUUID: + if let str = String(data: data, encoding: .utf8) { + firmwareRevision = str + } + case Self.modelNumberCharUUID: + if let str = String(data: data, encoding: .utf8) { + modelNumber = str + } + default: + appendLog("← 未知(\(uuid)): \(hex)") + } + } + + private func parseProtocolResponse(_ data: Data) { + if let status = AhaKeyResponseParser.parseDeviceStatus(data) { + batteryLevel = status.battery + firmwareMainVersion = status.firmwareMain + firmwareSubVersion = status.firmwareSub + workMode = status.workMode + NotificationCenter.default.post( + name: .ahaKeyKeyboardWorkModeChanged, + object: nil, + userInfo: ["workMode": status.workMode] + ) + lightMode = status.lightMode + switchState = status.switchState + activeTaskPictureSets[status.workMode] = status.activePictureSet + appendLog(" 状态: 电量=\(status.battery) 固件=\(status.firmwareMain).\(status.firmwareSub) 模式=\(status.workMode) 灯=\(status.lightMode) 开关=\(status.switchState) GIF套图=\(status.activePictureSet)") + } else if AhaKeyResponseParser.isProtocolFrame(data) { + if let response = AhaKeyResponseParser.parseCommandResponse(data) { + protocolResponseWaiters.removeValue(forKey: response.cmd)?.resume(returning: (response.status, response.payload)) + + if response.cmd == AhaKeyCommand.cmdWriteResult { + if response.status == 0 { + dataWriteResultContinuation?.resume() + } else { + dataWriteResultContinuation?.resume(throwing: OLEDUploadError.deviceRejected(command: response.cmd, status: response.status)) + } + dataWriteResultContinuation = nil + } + + if response.status == 0 { + appendLog(" ✓ 命令 0x\(String(format: "%02X", response.cmd)) 成功") + } else { + let payloadHex = response.payload.isEmpty ? "—" : response.payload.hexString + appendLog(" 命令 0x\(String(format: "%02X", response.cmd)) 失败: status=0x\(String(format: "%02X", response.status)) payload=\(payloadHex)", isError: true) + } + } + } else { + let bytes = data.map { String(format: "0x%02X", $0) }.joined(separator: ", ") + appendLog(" 原始 [\(data.count)B]: \(bytes)") + } + } + + /// 发送探测命令 + func sendProbeCommands() { + guard commandChar != nil else { + appendLog("命令通道未就绪", isError: true) + return + } + appendLog("═══ 开始探测 ═══") + + let probes: [(String, Data)] = [ + ("设备状态查询", AhaKeyCommand.queryDeviceStatus()), + ("读配置 0x01", Data([0xAA, 0xBB, 0x01, 0xCC, 0xDD])), + ("读配置 0x03", Data([0xAA, 0xBB, 0x03, 0xCC, 0xDD])), + ("读配置 0x05", Data([0xAA, 0xBB, 0x05, 0xCC, 0xDD])), + ] + for (label, data) in probes { + appendLog("→ \(label): \(data.hexString)") + writeCommand(data) + } + + if let batteryLevelChar { + peripheral?.readValue(for: batteryLevelChar) + appendLog("→ 重读电池电量") + } + + appendLog("═══ 探测完毕,等待回调 ═══") + } +} + +extension Notification.Name { + /// `userInfo["workMode"]` 为 `Int`,与键盘物理档位一致。 + static let ahaKeyKeyboardWorkModeChanged = Notification.Name("lab.jawa.ahakeyconfig.keyboardWorkModeChanged") +} + +// MARK: - Data Extension + +extension Data { + var hexString: String { + map { String(format: "%02X", $0) }.joined(separator: " ") + } +} diff --git a/platforms/macos/Sources/BLE/AhaKeyProtocol.swift b/Sources/BLE/AhaKeyProtocol.swift similarity index 78% rename from platforms/macos/Sources/BLE/AhaKeyProtocol.swift rename to Sources/BLE/AhaKeyProtocol.swift index 76e00fd6..6ef42b18 100644 --- a/platforms/macos/Sources/BLE/AhaKeyProtocol.swift +++ b/Sources/BLE/AhaKeyProtocol.swift @@ -28,7 +28,11 @@ enum AhaKeyCommand { static let cmdWriteResult: UInt8 = 0x81 static let cmdUpdatePic: UInt8 = 0x82 static let cmdReadPicState: UInt8 = 0x83 + static let cmdUpdateTaskPic: UInt8 = 0x84 + static let cmdReadTaskPicState: UInt8 = 0x85 + static let cmdSetActiveTaskPicSet: UInt8 = 0x86 static let cmdUpdateState: UInt8 = 0x90 // IDE 状态 → LED 变色 + static let cmdSetSwState: UInt8 = 0x91 // 虚拟拨杆:0=auto/up, 1=manual/down, 2=mid(需固件 patch 支持) // 按键子类型 (KeySubType) static let subShortcut: UInt8 = 0x73 @@ -59,7 +63,7 @@ enum AhaKeyCommand { /// - Parameters: /// - mode: 工作模式 0-2 /// - keyIndex: 0=Key1, 1=Key2, 2=Key3, 3=Key4 - /// - text: 显示在 OLED 上的按键描述(最多 20 字节 ASCII) + /// - text: 显示在 LCD 上的按键描述(最多 20 字节 ASCII) static func setKeyDescription(mode: UInt8 = 0, keyIndex: UInt8, text: String) -> Data { let textBytes = Array(text.sanitizedASCII(maxLength: 20).utf8) let payload: [UInt8] = [subDescription, mode, keyIndex] + textBytes @@ -102,7 +106,7 @@ enum AhaKeyCommand { return Data(header + [cmdPrepareWrite] + payload + trailer) } - /// 更新 OLED 动画参数 → AA BB 82 [mode] [start_index:2 LE] [frame_count:2 LE] [time_delay:2 LE] CC DD + /// 更新 LCD 动画参数 → AA BB 82 [mode] [start_index:2 LE] [frame_count:2 LE] [time_delay:2 LE] CC DD static func updatePicture(mode: UInt8, startIndex: UInt16, frameCount: UInt16, timeDelayMs: UInt16) -> Data { let payload: [UInt8] = [ mode, @@ -116,11 +120,51 @@ enum AhaKeyCommand { return Data(header + [cmdUpdatePic] + payload + trailer) } + /// 写入任务状态动画 → AA BB 84 [mode] [set] [state] [start:2 LE] [count:2 LE] [delay:2 LE] CC DD + static func updateTaskPicture( + mode: UInt8, + set: UInt8, + state: UInt8, + startIndex: UInt16, + frameCount: UInt16, + timeDelayMs: UInt16 + ) -> Data { + let payload: [UInt8] = [ + mode, + set, + state, + UInt8(startIndex & 0xFF), + UInt8((startIndex >> 8) & 0xFF), + UInt8(frameCount & 0xFF), + UInt8((frameCount >> 8) & 0xFF), + UInt8(timeDelayMs & 0xFF), + UInt8((timeDelayMs >> 8) & 0xFF), + ] + return Data(header + [cmdUpdateTaskPic] + payload + trailer) + } + + /// 读取任务状态动画 → AA BB 85 [mode] [set] [state] CC DD + static func readTaskPictureState(mode: UInt8, set: UInt8, state: UInt8) -> Data { + Data(header + [cmdReadTaskPicState, mode, set, state] + trailer) + } + + /// 设置或查询当前套图。set=0xFF 时只查询,不改变键盘状态。 + static func setActiveTaskPictureSet(mode: UInt8, set: UInt8 = 0xFF) -> Data { + Data(header + [cmdSetActiveTaskPicSet, mode, set] + trailer) + } + /// IDE 状态同步 → AA BB 90 [state] CC DD - /// 驱动键盘 LED 变色,反映 Claude/Cursor 当前状态 + /// 驱动键盘 LED 变色,并让新版固件立即切换对应 LCD 动图。 static func updateState(_ state: IDEState) -> Data { Data(header + [cmdUpdateState, state.rawValue] + trailer) } + + /// 虚拟拨杆 → AA BB 91 [sw_state] CC DD + /// 需要键盘固件已升级到含 0x91 处理分支的版本(command_solve.c 的 patch)。 + /// 老版本固件收到会触发 command_return(0x91, 0) 并忽略。 + static func setSwitchState(_ value: UInt8) -> Data { + Data(header + [cmdSetSwState, value] + trailer) + } } /// IDE 状态枚举(原厂 ClaudeState) @@ -160,6 +204,7 @@ struct AhaKeyDeviceStatus { let workMode: Int let lightMode: Int let switchState: Int + let activePictureSet: Int } struct AhaKeyPictureState { @@ -170,6 +215,17 @@ struct AhaKeyPictureState { let allModeMaxPic: Int } +struct AhaKeyTaskPictureState: Hashable { + let mode: Int + let set: Int + let state: Int + let startIndex: Int + let picLength: Int + let frameInterval: Int + let allModeMaxPic: Int + let activeSet: Int +} + /// AhaKey 协议响应解析器 enum AhaKeyResponseParser { static func parseCommandResponse(_ data: Data) -> (cmd: UInt8, status: UInt8, payload: Data)? { @@ -193,7 +249,7 @@ enum AhaKeyResponseParser { let payload = data[2 ..< data.count - 2] // payload[0] = command echo (0x00), skip it - guard payload.count >= 8, payload[payload.startIndex] == 0x00 else { return nil } + guard payload.count >= 9, payload[payload.startIndex] == 0x00 else { return nil } let base = payload.startIndex + 1 // skip cmd echo return AhaKeyDeviceStatus( @@ -203,7 +259,8 @@ enum AhaKeyResponseParser { firmwareSub: Int(payload[base + 3]), workMode: Int(payload[base + 4]), lightMode: Int(payload[base + 5]), - switchState: Int(payload[base + 6]) + switchState: Int(payload[base + 6]), + activePictureSet: Int(payload[base + 7]) ) } @@ -225,6 +282,30 @@ enum AhaKeyResponseParser { ) } + static func parseTaskPictureStateResponse(_ payload: Data) -> AhaKeyTaskPictureState? { + guard payload.count >= 12 else { return nil } + + let mode = Int(payload[0]) + let set = Int(payload[1]) + let state = Int(payload[2]) + let startIndex = Int(UInt16(payload[3]) | (UInt16(payload[4]) << 8)) + let picLength = Int(UInt16(payload[5]) | (UInt16(payload[6]) << 8)) + let frameInterval = Int(UInt16(payload[7]) | (UInt16(payload[8]) << 8)) + let allModeMaxPic = Int(UInt16(payload[9]) | (UInt16(payload[10]) << 8)) + let activeSet = Int(payload[11]) + + return AhaKeyTaskPictureState( + mode: mode, + set: set, + state: state, + startIndex: startIndex, + picLength: picLength, + frameInterval: frameInterval, + allModeMaxPic: allModeMaxPic, + activeSet: activeSet + ) + } + /// 检查是否是 AhaKey 协议帧 static func isProtocolFrame(_ data: Data) -> Bool { data.count >= 4 @@ -324,7 +405,7 @@ enum HIDUsage { } extension String { - /// 设备 OLED 描述只稳定支持 ASCII;非 ASCII 字符会在设备端变成乱码。 + /// 设备 LCD 描述只稳定支持 ASCII;非 ASCII 字符会在设备端变成乱码。 func sanitizedASCII(maxLength: Int) -> String { var result = String() result.reserveCapacity(min(maxLength, count)) diff --git a/platforms/macos/Sources/Models/AhaKeyStudioModels.swift b/Sources/Models/AhaKeyStudioModels.swift similarity index 81% rename from platforms/macos/Sources/Models/AhaKeyStudioModels.swift rename to Sources/Models/AhaKeyStudioModels.swift index 01059f73..1abf1804 100644 --- a/platforms/macos/Sources/Models/AhaKeyStudioModels.swift +++ b/Sources/Models/AhaKeyStudioModels.swift @@ -33,7 +33,7 @@ enum AhaKeyModeSlot: Int, CaseIterable, Codable, Identifiable { case .mode1: "Cursor · Composer Accept/Reject" case .mode2: - "Codex · CLI ↵ / Esc" + "Codex · ↵ / Esc" } } @@ -42,9 +42,19 @@ enum AhaKeyModeSlot: Int, CaseIterable, Codable, Identifiable { case .mode0: "针对 Claude Code 终端权限菜单:Key2 直接输入 Y(同意),Key3 直接输入 N(拒绝)。" case .mode1: - "针对 Cursor Composer / Agent:Key2 发 ↵、Key3 发 ⌫(与裸键一致)。若需与「⌘↵ 接受 / ⌘⌫ 拒绝」等组合键一致,请在编辑器里为对应键加修饰,并在 Cursor 设置 → Keyboard Shortcuts 中绑成相同组合。" + "针对 Cursor Composer / Agent:Key2 发 ↵、Key3 发 ⌫(与裸键一致)。" case .mode2: - "针对 OpenAI Codex CLI:Key2 发送 ↵ 确认审批,Key3 发送 Esc 取消。" + "针对 Codex 终端审批:Key2 发送 ↵ 确认,Key3 发送 Esc 取消。" + } + } + + /// 鼠标 hover 问号 icon 时弹出的详细补充。无补充返回 nil → 不显示问号。 + var guidanceHoverDetail: String? { + switch self { + case .mode1: + return "若需与「⌘↵ 接受 / ⌘⌫ 拒绝」等组合键一致,请在编辑器里为对应键加修饰,并在 Cursor 设置 → Keyboard Shortcuts 中绑成相同组合。" + case .mode0, .mode2: + return nil } } } @@ -65,7 +75,7 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { case .lightBar: "灯条" case .oledDisplay: - "OLED 屏幕" + "LCD 屏幕" case .key1: "Key 1" case .key2: @@ -101,7 +111,8 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { var systemImage: String { switch self { case .lightBar: - "lightspectrum.horizontal" + // lightspectrum.horizontal requires macOS 13; fall back to light.max on macOS 12 + if #available(macOS 13, *) { "lightspectrum.horizontal" } else { "light.max" } case .oledDisplay: "rectangle.inset.filled" case .key1: @@ -131,6 +142,8 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { nil } } + + var isKey: Bool { keyRole != nil } } enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { @@ -315,22 +328,32 @@ enum VoicePreset: String, CaseIterable, Codable, Identifiable { case typeless case wechat case claudeCode + case kimiCode case codex case doubao case custom var id: String { rawValue } + /// claudeCode / kimiCode 与 macOSNative 底层路由完全相同,合并展示为同一选项。 + /// 保留枚举 case 是为了向下兼容已存储的配置数据;迁移在 AhaKeyStudioStore 完成。 + var isMacOSNativeFamily: Bool { + self == .macOSNative || self == .claudeCode || self == .kimiCode + } + + /// Picker 中实际展示的选项(隐藏已合并的旧 case 和未实现的 codex) + static var visibleCases: [VoicePreset] { + allCases.filter { !($0 == .claudeCode || $0 == .kimiCode || $0 == .codex) } + } + var title: String { switch self { - case .macOSNative: - "macOS 原生语音" + case .macOSNative, .claudeCode, .kimiCode: + "macOS 原生转写" case .typeless: "Typeless / Fn" case .wechat: "微信语音" - case .claudeCode: - "Claude Code" case .codex: "Codex" case .doubao: @@ -342,15 +365,15 @@ enum VoicePreset: String, CaseIterable, Codable, Identifiable { var detail: String { switch self { - case .macOSNative: - "AhaKey Studio 会在后台直接调用苹果原生语音转写。按一次开始,再按一次结束,并把识别文字写回当前光标。Mode 0 出厂固件的 F18 也能直接接管。" + case .macOSNative, .claudeCode, .kimiCode: + "调用苹果原生语音转写,识别完成后以 ⌘V 写回当前光标位置。适合 Claude Code、Kimi Code、Codex 等 CLI 终端及任意输入框。按一次开始,再按一次结束。" case .typeless: "预设对应快捷键:Typeless 内仍选 Fn/Globe。本 Studio 默认用 F19 作为语音触发键(与 macOS 原生 F18 错开);按下后向系统注入「按住 Fn」供随声写使用。Mode 0 出厂语音键 F18 仍会额外注册兼容。请授予输入监控与辅助功能。" case .wechat: "AhaKey Studio 会在后台把语音键的按下/松开转换成 Fn/Globe,便于接入微信语音。" - case .claudeCode: - "使用 macOS 原生语音识别,把识别结果以 ⌘V 粘贴到 Claude Code 当前光标位置。按一次开始、再按一次结束。" - case .codex, .doubao: + case .doubao: + "豆包输入法 Mac 版需要直接接收真实语音键事件。AhaKey Studio 会切到豆包输入源,并把 F18 配置为豆包长按语音快捷键;按住语音键说话,松开后由豆包提交文字。" + case .codex: "规划中,保留入口。" case .custom: "直接自己指定底层快捷键。" @@ -359,7 +382,7 @@ enum VoicePreset: String, CaseIterable, Codable, Identifiable { var availableInV1: Bool { switch self { - case .codex, .doubao: + case .codex: false default: true @@ -377,6 +400,8 @@ enum VoicePreset: String, CaseIterable, Codable, Identifiable { ShortcutBinding(keyCode: HIDUsage.f18) case .claudeCode: ShortcutBinding(keyCode: HIDUsage.f18) + case .kimiCode: + ShortcutBinding(keyCode: HIDUsage.f18) case .codex: ShortcutBinding(keyCode: HIDUsage.f18) case .doubao: @@ -742,28 +767,141 @@ struct AhaKeyKeyDraft: Codable, Equatable, Identifiable { } } +enum AhaKeyTaskDisplayState: Int, Codable, CaseIterable, Identifiable { + case notification = 0 + case permissionRequest = 1 + case postToolUse = 2 + case preToolUse = 3 + case sessionStart = 4 + case stop = 5 + case taskCompleted = 6 + case userPromptSubmit = 7 + case sessionEnd = 8 + + var id: Int { rawValue } + + var title: String { + switch self { + case .notification: return "通知" + case .permissionRequest: return "等待授权" + case .postToolUse: return "工具完成" + case .preToolUse: return "工具执行" + case .sessionStart: return "会话开始" + case .stop: return "停止" + case .taskCompleted: return "任务完成" + case .userPromptSubmit: return "用户提交" + case .sessionEnd: return "会话结束 / 默认" + } + } + + var ideState: IDEState { + IDEState(rawValue: UInt8(rawValue)) ?? .sessionEnd + } +} + +struct AhaKeyTaskGIFAssetDraft: Codable, Equatable, Identifiable { + var state: AhaKeyTaskDisplayState + var localAssetPath: String? + var framesPerSecond: Int + + var id: Int { state.rawValue } + + init(state: AhaKeyTaskDisplayState, localAssetPath: String? = nil, framesPerSecond: Int = 12) { + self.state = state + self.localAssetPath = localAssetPath + self.framesPerSecond = min(20, max(5, framesPerSecond)) + } +} + +struct AhaKeyTaskGIFSetDraft: Codable, Equatable { + var assets: [AhaKeyTaskGIFAssetDraft] + + init(assets: [AhaKeyTaskGIFAssetDraft]) { + self.assets = Self.normalizedAssets(assets) + } + + static func defaultSet(assetPath: String?, framesPerSecond: Int) -> AhaKeyTaskGIFSetDraft { + AhaKeyTaskGIFSetDraft(assets: AhaKeyTaskDisplayState.allCases.map { state in + AhaKeyTaskGIFAssetDraft( + state: state, + localAssetPath: state == .sessionEnd ? assetPath : nil, + framesPerSecond: framesPerSecond + ) + }) + } + + func asset(for state: AhaKeyTaskDisplayState) -> AhaKeyTaskGIFAssetDraft { + assets.first { $0.state == state } + ?? AhaKeyTaskGIFAssetDraft(state: state) + } + + mutating func updateAsset(_ asset: AhaKeyTaskGIFAssetDraft) { + if let index = assets.firstIndex(where: { $0.state == asset.state }) { + assets[index] = asset + } else { + assets.append(asset) + } + assets = Self.normalizedAssets(assets) + } + + private static func normalizedAssets(_ candidates: [AhaKeyTaskGIFAssetDraft]) -> [AhaKeyTaskGIFAssetDraft] { + AhaKeyTaskDisplayState.allCases.map { state in + candidates.first { $0.state == state } ?? AhaKeyTaskGIFAssetDraft(state: state) + } + } +} + struct AhaKeyOLEDDraft: Codable, Equatable { var localAssetPath: String? var statusLine: String var framesPerSecond: Int + var taskGIFSets: [AhaKeyTaskGIFSetDraft] + var activeGIFSet: Int + /// 0=旧版每 Mode 单 GIF 草稿,1=已写入任务 GIF 固件结构。 + var taskGIFSchemaVersion: Int private enum CodingKeys: String, CodingKey { case localAssetPath case statusLine case framesPerSecond + case taskGIFSets + case activeGIFSet + case taskGIFSchemaVersion } - init(localAssetPath: String?, statusLine: String, framesPerSecond: Int = 12) { + init( + localAssetPath: String?, + statusLine: String, + framesPerSecond: Int = 12, + taskGIFSets: [AhaKeyTaskGIFSetDraft]? = nil, + activeGIFSet: Int = 0, + taskGIFSchemaVersion: Int = 1 + ) { self.localAssetPath = localAssetPath self.statusLine = statusLine - self.framesPerSecond = framesPerSecond + self.framesPerSecond = min(20, max(5, framesPerSecond)) + self.taskGIFSets = Self.normalizedSets( + taskGIFSets ?? [], + legacyAssetPath: localAssetPath, + legacyFramesPerSecond: framesPerSecond + ) + self.activeGIFSet = min(1, max(0, activeGIFSet)) + self.taskGIFSchemaVersion = max(0, taskGIFSchemaVersion) } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) localAssetPath = try container.decodeIfPresent(String.self, forKey: .localAssetPath) statusLine = try container.decode(String.self, forKey: .statusLine) - framesPerSecond = try container.decodeIfPresent(Int.self, forKey: .framesPerSecond) ?? 12 + framesPerSecond = min(20, max(5, try container.decodeIfPresent(Int.self, forKey: .framesPerSecond) ?? 12)) + let decodedSets = try container.decodeIfPresent([AhaKeyTaskGIFSetDraft].self, forKey: .taskGIFSets) ?? [] + taskGIFSets = Self.normalizedSets( + decodedSets, + legacyAssetPath: localAssetPath, + legacyFramesPerSecond: framesPerSecond + ) + activeGIFSet = min(1, max(0, try container.decodeIfPresent(Int.self, forKey: .activeGIFSet) ?? 0)) + taskGIFSchemaVersion = max(0, try container.decodeIfPresent(Int.self, forKey: .taskGIFSchemaVersion) ?? 0) } func encode(to encoder: Encoder) throws { @@ -771,9 +909,51 @@ struct AhaKeyOLEDDraft: Codable, Equatable { try container.encodeIfPresent(localAssetPath, forKey: .localAssetPath) try container.encode(statusLine, forKey: .statusLine) try container.encode(framesPerSecond, forKey: .framesPerSecond) + try container.encode(taskGIFSets, forKey: .taskGIFSets) + try container.encode(activeGIFSet, forKey: .activeGIFSet) + try container.encode(taskGIFSchemaVersion, forKey: .taskGIFSchemaVersion) } - static func `default`(for mode: AhaKeyModeSlot) -> AhaKeyOLEDDraft { + func taskAsset(set: Int, state: AhaKeyTaskDisplayState) -> AhaKeyTaskGIFAssetDraft { + let normalizedSet = min(1, max(0, set)) + guard taskGIFSets.indices.contains(normalizedSet) else { + return AhaKeyTaskGIFAssetDraft(state: state) + } + return taskGIFSets[normalizedSet].asset(for: state) + } + + mutating func updateTaskAsset(set: Int, asset: AhaKeyTaskGIFAssetDraft) { + ensureTaskGIFSets() + let normalizedSet = min(1, max(0, set)) + taskGIFSets[normalizedSet].updateAsset(asset) + if normalizedSet == 0, asset.state == .sessionEnd { + localAssetPath = asset.localAssetPath + framesPerSecond = asset.framesPerSecond + } + } + + mutating func ensureTaskGIFSets() { + taskGIFSets = Self.normalizedSets( + taskGIFSets, + legacyAssetPath: localAssetPath, + legacyFramesPerSecond: framesPerSecond + ) + activeGIFSet = min(1, max(0, activeGIFSet)) + } + + private static func normalizedSets( + _ candidates: [AhaKeyTaskGIFSetDraft], + legacyAssetPath: String?, + legacyFramesPerSecond: Int + ) -> [AhaKeyTaskGIFSetDraft] { + var result = Array(candidates.prefix(2)) + while result.count < 2 { + result.append(.defaultSet(assetPath: legacyAssetPath, framesPerSecond: legacyFramesPerSecond)) + } + return result + } + + static func defaultDraft(for mode: AhaKeyModeSlot) -> AhaKeyOLEDDraft { let statusLine: String switch mode { case .mode0: @@ -781,7 +961,7 @@ struct AhaKeyOLEDDraft: Codable, Equatable { case .mode1: statusLine = "Cursor · ↵ 接受改动 / ⌫ 拒绝改动。" case .mode2: - statusLine = "Codex · CLI 审批 ↵ / Esc。" + statusLine = "Codex · 审批 ↵ / Esc。" } return AhaKeyOLEDDraft( localAssetPath: DefaultOLEDAssets.bundledAssetPath(for: mode), @@ -817,7 +997,7 @@ struct AhaKeyModeDraft: Codable, Equatable, Identifiable { let container = try decoder.container(keyedBy: CodingKeys.self) mode = try container.decode(AhaKeyModeSlot.self, forKey: .mode) keys = try container.decode([AhaKeyKeyDraft].self, forKey: .keys) - oled = try container.decodeIfPresent(AhaKeyOLEDDraft.self, forKey: .oled) ?? .default(for: mode) + oled = try container.decodeIfPresent(AhaKeyOLEDDraft.self, forKey: .oled) ?? AhaKeyOLEDDraft.defaultDraft(for: mode) lightBar = try container.decodeIfPresent(AhaKeyLightBarDraft.self, forKey: .lightBar) ?? .default(for: mode) } @@ -911,7 +1091,7 @@ struct AhaKeyModeDraft: Codable, Equatable, Identifiable { voicePreset: nil ), ], - oled: .default(for: mode), + oled: AhaKeyOLEDDraft.defaultDraft(for: mode), lightBar: .default(for: mode) ) } @@ -980,6 +1160,17 @@ enum AhaKeyStudioStore { } next.updateMode(mode0) + // claudeCode / kimiCode 已合并到 macOSNative,迁移所有 mode 里的旧 preset。 + for modeSlot in AhaKeyModeSlot.allCases { + var modeDraft = next.draft(for: modeSlot) + var voiceKey = modeDraft.key(for: .voice) + if voiceKey.voicePreset == .claudeCode || voiceKey.voicePreset == .kimiCode { + voiceKey.voicePreset = .macOSNative + modeDraft.updateKey(voiceKey) + next.updateMode(modeDraft) + } + } + // 旧 Mode 0 = Cursor / 旧 Mode 1 = Claude 的用户,自动对调成新默认布局。 // 仅当两个 mode 的 approve/reject 都完全等于旧默认时触发,保护手动改过的配置。 let cursorApproveBinding = ShortcutBinding(modifiers: [.command], keyCode: HIDUsage.enter) @@ -1031,7 +1222,7 @@ enum AhaKeyStudioStore { "当前模式还未上传动图,后续可替换成你的自定义 GIF。", "Cursor · ⌘↵ 接受改动 / ⌘⌫ 拒绝改动。", "Claude Code · 终端权限菜单 Y/N。", - "Codex · CLI 审批 ↵ / Esc。", + "Codex · 审批 ↵ / Esc。", ] let legacyApproveBinding = ShortcutBinding(keyCode: HIDUsage.enter) let legacyRejectBinding = ShortcutBinding(keyCode: HIDUsage.escape) @@ -1043,10 +1234,10 @@ enum AhaKeyStudioStore { let target = AhaKeyModeDraft.default(for: mode) if legacyOLEDStatusLines.contains(modeDraft.oled.statusLine) { - modeDraft.oled.statusLine = AhaKeyOLEDDraft.default(for: mode).statusLine + modeDraft.oled.statusLine = AhaKeyOLEDDraft.defaultDraft(for: mode).statusLine } - // OLED 素材路径自愈:用户没选过自定义 GIF(为 nil)或引用的是旧 bundle 路径时, + // LCD 素材路径自愈:用户没选过自定义 GIF(为 nil)或引用的是旧 bundle 路径时, // 刷成当前构建下内置 GIF 的绝对路径;用户自选的外部路径原样保留。 if let bundled = DefaultOLEDAssets.bundledAssetPath(for: mode) { if modeDraft.oled.localAssetPath == nil diff --git a/platforms/macos/Sources/Utilities/AgentManager.swift b/Sources/Utilities/AgentManager.swift similarity index 67% rename from platforms/macos/Sources/Utilities/AgentManager.swift rename to Sources/Utilities/AgentManager.swift index 73a6db4a..8119e2eb 100644 --- a/platforms/macos/Sources/Utilities/AgentManager.swift +++ b/Sources/Utilities/AgentManager.swift @@ -6,7 +6,7 @@ private let log = Logger(subsystem: "lab.jawa.ahakeyconfig", category: "AgentMan // MARK: - 蓝牙占用方(AhaKey Studio 与 Agent 是两套独立进程,同一时刻只应有一个 GATT 连接键盘) /// 由谁持有与键盘的 BLE 连接。 -/// - `ahaKeyStudio`:主 App 连接,用于改键、OLED、本机 LED 测试等。 +/// - `ahaKeyStudio`:主 App 连接,用于改键、LCD、本机 LED 测试等。 /// - `agentDaemon`:仅运行 `ahakeyconfig-agent`(Hook → Unix socket → 写 0x90 状态、读拨杆),由 LaunchAgent 拉起。 enum BluetoothConnectionOwner: String, CaseIterable, Identifiable { case ahaKeyStudio @@ -24,7 +24,7 @@ enum BluetoothConnectionOwner: String, CaseIterable, Identifiable { var shortDetail: String { switch self { case .ahaKeyStudio: return "本 App 连接蓝牙,用于配置与同步。Agent 的 LaunchJob 在持有方为 App 时不会加载,避免抢连接。" - case .agentDaemon: return "仅 Agent 连接蓝牙。Claude/Cursor/Codex Hook 才能驱动灯条与拨杆查询;本 App 里无法对键盘发 BLE 命令。" + case .agentDaemon: return "仅 Agent 连接蓝牙。Claude/Cursor/Codex/Kimi Code CLI Hook 才能驱动灯条与拨杆查询;本 App 里无法对键盘发 BLE 命令。" } } } @@ -40,10 +40,11 @@ final class AgentManager: ObservableObject { @Published private(set) var isInstalled = false @Published private(set) var isRunning = false @Published private(set) var isAgentBLEConnected = false // agent 的 BLE 是否真正连上键盘 - @Published private(set) var hooksInstalled = false // Claude / Cursor / Codex hooks 是否装了任何一个 + @Published private(set) var hooksInstalled = false // Claude / Cursor / Codex / Kimi hooks 是否装了任何一个 @Published private(set) var claudeHooksInstalled = false @Published private(set) var cursorHooksInstalled = false @Published private(set) var codexHooksInstalled = false + @Published private(set) var kimiHooksInstalled = false /// 用户选择的蓝牙占用方(存 UserDefaults,启动时应用一次) @Published var bluetoothConnectionOwner: BluetoothConnectionOwner = .agentDaemon @@ -107,6 +108,16 @@ final class AgentManager: ObservableObject { "/Applications/Codex.app/Contents/Resources/codex" } + private var kimiConfigPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".kimi/config.toml").path + } + + private var kimiCliFallbackRoot: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share/uv/tools/kimi-cli").path + } + private var localBinDirectoryPath: String { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".local/bin", isDirectory: true).path @@ -134,8 +145,13 @@ final class AgentManager: ObservableObject { } init() { - bluetoothConnectionOwner = .agentDaemon - UserDefaults.standard.set(BluetoothConnectionOwner.agentDaemon.rawValue, forKey: Self.bluetoothOwnerKey) + if let raw = UserDefaults.standard.string(forKey: Self.bluetoothOwnerKey), + let stored = BluetoothConnectionOwner(rawValue: raw) { + bluetoothConnectionOwner = stored + } else { + bluetoothConnectionOwner = .agentDaemon + UserDefaults.standard.set(BluetoothConnectionOwner.agentDaemon.rawValue, forKey: Self.bluetoothOwnerKey) + } refresh() } @@ -147,21 +163,60 @@ final class AgentManager: ObservableObject { claudeHooksInstalled = detectClaudeHooksInstalled() cursorHooksInstalled = detectCursorHooksInstalled() codexHooksInstalled = detectCodexHooksInstalled() - hooksInstalled = claudeHooksInstalled || cursorHooksInstalled || codexHooksInstalled + kimiHooksInstalled = detectKimiHooksInstalled() + hooksInstalled = claudeHooksInstalled || cursorHooksInstalled || codexHooksInstalled || kimiHooksInstalled if isRunning { - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let self else { return } - let bleConnected = self.querySocketBLEConnected() - DispatchQueue.main.async { self.isAgentBLEConnected = bleConnected } + let socketPath = socketPath + DispatchQueue.global(qos: .utility).async { [weak self, socketPath] in + let bleConnected = Self.querySocketBLEConnected(socketPath: socketPath) + DispatchQueue.main.async { self?.isAgentBLEConnected = bleConnected } } } else { isAgentBLEConnected = false } } + /// 通知 agent 设置/清除虚拟拨杆覆盖。fire-and-forget;agent 会: + /// 1) 落进 UserDefaults 持久化 + /// 2) 写入共享文件让主 App 立即看到 + /// 3) 如果固件已 patch 0x91,agent 会同步给键盘真改 sw_state + /// value=nil 表示清除覆盖(回到读真实 GPIO 值)。 + func sendSwitchOverride(_ value: UInt8?) { + DispatchQueue.global(qos: .userInitiated).async { [socketPath] in + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return } + defer { close(fd) } + var tv = timeval(tv_sec: 2, 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)) + } + } + guard ok == 0 else { return } + let valuePart: String = value.map { "\($0)" } ?? "null" + let payload = "{\"cmd\":\"set_switch_override\",\"value\":\(valuePart)}\n" + guard let data = payload.data(using: .utf8) else { return } + _ = data.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return -1 } + return write(fd, base, ptr.count) + } + var buf = [UInt8](repeating: 0, count: 256) + _ = read(fd, &buf, buf.count) // 等回包再关 fd,避免 agent 还没处理就被 reset + } + } + /// 向 agent socket 发 status 命令,switchState 非 null 即代表 BLE 已连上键盘。 /// 同步执行,需在后台线程调用。 - private func querySocketBLEConnected() -> Bool { + nonisolated private static func querySocketBLEConnected(socketPath: String) -> Bool { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return false } defer { close(fd) } @@ -310,8 +365,20 @@ final class AgentManager: ObservableObject { guard let text = try? String(contentsOfFile: codexConfigPath, encoding: .utf8) else { return false } - return text.contains(codexHookBlockStart) - && text.contains(codexHookBlockEnd) + // AhaKey 写入的 BEGIN/END 块即可判定(不依赖文件中是否仍能匹配到 ahakeyconfig-agent 字面量,避免因路径别名/重装 App 路径变化导致误判未装) + if text.contains(codexHookBlockStart), text.contains(codexHookBlockEnd) { + return true + } + return isAhakeyHookCommand(text) + && (text.contains("hook Codex") || text.contains("CodexPermissionRequest")) + } + + private func detectKimiHooksInstalled() -> Bool { + guard let text = try? String(contentsOfFile: kimiConfigPath, encoding: .utf8) else { + return false + } + return text.contains(kimiHookBlockStart) + && text.contains(kimiHookBlockEnd) && isAhakeyHookCommand(text) } @@ -327,18 +394,8 @@ final class AgentManager: ObservableObject { // MARK: - 安装/卸载 LaunchAgent - func install() { - agentUserAlert = nil - isAgentOperationInProgress = true - defer { isAgentOperationInProgress = false } - - guard isAgentBinaryPresentInBundle else { - agentUserAlert = "应用包内没有可执行的 ahakeyconfig-agent(路径:…/Contents/MacOS/ahakeyconfig-agent)。请确认发版脚本已把该二进制一并打进 .app;仅有主程序时无法安装守护进程。" - return - } - - // 1. 创建 LaunchAgent plist - let plist = """ + private func launchAgentPlist() -> String { + """ @@ -362,17 +419,47 @@ final class AgentManager: ObservableObject { """ + } + @discardableResult + private func writeLaunchAgentPlist() -> Bool { do { try ensureLaunchAgentsDirectory() - try plist.write(toFile: plistPath, atomically: true, encoding: .utf8) + try launchAgentPlist().write(toFile: plistPath, atomically: true, encoding: .utf8) log.info("LaunchAgent 已安装: \(self.plistPath)") + return true } catch { log.error("LaunchAgent 安装失败: \(error)") agentUserAlert = "无法写入 LaunchAgent 配置文件:\(error.localizedDescription)\n\n将写入:\(plistPath)\n已尝试创建目录:\(launchAgentsDirectoryURL.path)\n若仍失败,请检查对「~/Library」是否有写权限,或本机管理策略是否禁止用户 LaunchAgents。" + return false + } + } + + private func installedAgentBinaryPath() -> String? { + guard let plist = NSDictionary(contentsOfFile: plistPath), + let args = plist["ProgramArguments"] as? [String], + let first = args.first else { return nil } + return first + } + + private func launchAgentNeedsRewrite() -> Bool { + installedAgentBinaryPath() != agentBinaryPath + } + + func install() { + agentUserAlert = nil + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + + guard isAgentBinaryPresentInBundle else { + agentUserAlert = "应用包内没有可执行的 ahakeyconfig-agent(路径:…/Contents/MacOS/ahakeyconfig-agent)。请确认发版脚本已把该二进制一并打进 .app;仅有主程序时无法安装守护进程。" return } + // 1. 先卸载旧 job,再写入 plist。否则同 Label 已加载时 launchd 可能继续持有旧 ProgramArguments。 + unloadAgentLaunchJobRemovingSocket() + guard writeLaunchAgentPlist() else { return } + // 2. 仅当用户希望 Agent 持有蓝牙时才 load(否则只写入 plist,避免装完立刻抢 GATT) var loadFailed = false if bluetoothConnectionOwner == .agentDaemon { @@ -385,10 +472,11 @@ final class AgentManager: ObservableObject { } } - // 3. 安装 Claude / Cursor / Codex hooks(直接指向 agent 二进制 hook 子命令) + // 3. 安装 Claude / Cursor / Codex / Kimi hooks(直接指向 agent 二进制 hook 子命令) let claudeLine = installClaudeHooks() let cursorLine = installCursorHooks() let codexLine = installCodexHooks() + let kimiLine = installKimiHooks() refresh() @@ -399,6 +487,7 @@ final class AgentManager: ObservableObject { if !claudeLine.isEmpty { lines.append(claudeLine) } if !cursorLine.isEmpty { lines.append(cursorLine) } if !codexLine.isEmpty { lines.append(codexLine) } + if !kimiLine.isEmpty { lines.append(kimiLine) } let tail = lines.joined(separator: "\n\n") if let err = agentUserAlert { agentUserAlert = err + (tail.isEmpty ? "" : "\n\n——\n\n" + tail) @@ -415,10 +504,11 @@ final class AgentManager: ObservableObject { // 2. 清理老版本 shell hook 脚本(如果存在) try? FileManager.default.removeItem(atPath: legacyHookScriptPath) - // 3. 移除 Claude / Cursor / Codex hooks 中的 ahakey 条目(同时覆盖老 shell 脚本与新二进制命令) + // 3. 移除 Claude / Cursor / Codex / Kimi hooks 中的 ahakey 条目(同时覆盖老 shell 脚本与新二进制命令) removeClaudeHooks() removeCursorHooks() removeCodexHooks() + _ = removeKimiHooks() // 4. 清理 socket if FileManager.default.fileExists(atPath: socketPath) { @@ -439,6 +529,10 @@ final class AgentManager: ObservableObject { agentUserAlert = "尚未安装 LaunchAgent。请先点「安装并启用」。" return } + if launchAgentNeedsRewrite() { + unloadAgentLaunchJobRemovingSocket() + guard writeLaunchAgentPlist() else { return } + } isAgentOperationInProgress = true let loadRes = runLaunchctlDetailed(["load", plistPath]) let startRes = runLaunchctlDetailed(["start", label]) @@ -505,6 +599,9 @@ final class AgentManager: ObservableObject { /// Codex 0.125 使用 `~/.codex/config.toml` 的 inline `[[hooks.Event]]`。 var userCodexConfigFilePath: String { codexConfigPath } + /// Kimi Code CLI(Beta)使用 `~/.kimi/config.toml` 的 `[[hooks]]`。 + var userKimiConfigFilePath: String { kimiConfigPath } + /// Cursor CLI / Agent 的全局 `permissions` 等(控制 Shell 等是否仍弹层确认,与 `hooks.json` 独立)。 var userCursorCliConfigFilePath: String { cursorCliConfigPath } @@ -535,6 +632,15 @@ final class AgentManager: ObservableObject { return (try? String(contentsOfFile: path, encoding: .utf8)) ?? "(存在但无法读取:\(path))" } + /// `~/.kimi/config.toml` 原样读出(Kimi Hooks 配置为文本 TOML)。 + func readUserKimiConfigForDisplay() -> String { + let path = kimiConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "(文件不存在:\(path))\n\n可先点「安装 Kimi Hooks」创建并写入 AhaKey 标记块;须已安装并使用 Kimi Code CLI:https://moonshotai.github.io/kimi-cli/" + } + return (try? String(contentsOfFile: path, encoding: .utf8)) ?? "(存在但无法读取:\(path))" + } + /// 备份当前 `cli-config` 后,合并 `permissions.allow`(不删你已有项),并设置 `approvalMode` 为 `auto`。 /// 用于减轻「hook 已 allow 但 Cursor 仍要求再点一次」中 **Cursor 自己那一层** 的拦阻。 /// - Returns: 给用户看的结果说明。 @@ -673,7 +779,7 @@ final class AgentManager: ObservableObject { /// 只读;由 `ahakeyconfig-agent` 在 `PermissionRequest` 与 Cursor 批准类 hook 中写入。 func readPermissionRequestLog() -> String { (try? String(contentsOfFile: permissionRequestLogPath, encoding: .utf8)) - ?? "尚无记录。在 Claude 中触发 PermissionRequest,或在 Cursor 中让 Agent 调工具/Shell/MCP 后,会在此追加带 `ide` / `hookEvent` 的 JSON 行。若始终为空,请确认已安装 Agent、Hooks、蓝牙由 Agent 占用,且 `~/Library/.../AhaKeyConfig/diagnostics/` 可写。" + ?? "尚无记录。在 Claude 中触发 PermissionRequest,在 Cursor 中让 Agent 调工具/Shell/MCP,或在 Kimi Code CLI 中触发工具调用后,会在此追加带 `ide` / `hookEvent` 的 JSON 行。若始终为空,请确认已安装 Agent、Hooks、蓝牙由 Agent 占用,且 `~/Library/.../AhaKeyConfig/diagnostics/` 可写。" } /// 只读;由 `ahakeyconfig-agent hook Codex*` 子进程写入,用于判断 Codex 客户端/终端是否真的触发了 hook。 @@ -691,10 +797,12 @@ final class AgentManager: ObservableObject { "PreToolUse", "PostToolUse", "Stop", + "SubagentStop", // Claude Code 拆分后:手动终止任务时触发此事件 "SessionStart", "SessionEnd", "UserPromptSubmit", "TaskCompleted", + "PreCompact", ] /// Shell 安全地引用一个路径(单引号包裹 + 转义内部单引号) @@ -821,6 +929,18 @@ final class AgentManager: ObservableObject { ("Stop", "CodexStop", 10), ] + private let kimiHookBlockStart = "# BEGIN AhaKey Kimi Hooks" + private let kimiHookBlockEnd = "# END AhaKey Kimi Hooks" + private let kimiHookEntries: [(event: String, agentEvent: String, timeout: Int)] = [ + ("Notification", "KimiNotification", 10), + ("SessionStart", "KimiSessionStart", 10), + ("SessionEnd", "KimiSessionEnd", 10), + ("PreToolUse", "KimiPreToolUse", 20), + ("PostToolUse", "KimiPostToolUse", 10), + ("UserPromptSubmit", "KimiUserPromptSubmit", 10), + ("Stop", "KimiStop", 10), + ] + /// 单独安装 Claude hooks func installClaudeHooksOnly() { isAgentOperationInProgress = true @@ -870,6 +990,32 @@ final class AgentManager: ObservableObject { refresh() } + /// 单独安装 Kimi Code CLI hooks(`~/.kimi/config.toml`,Beta)。 + func installKimiHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + let s = installKimiHooks() + agentUserAlert = s.isEmpty + ? """ + Kimi Hooks 已写入 ~/.kimi/config.toml。 + + **AhaKey 拨杆接管也会一并重打到本机 kimi-cli**。如果 kimi 当前已经打开,请**完全关闭并重新打开一次**;重开后,**拨杆 0/1 会直接接管当前会话的自动批准**,**不需要 `/reload`,也不需要 `/yolo`**。 + 以后若你**升级了 kimi-cli**,再次点击一次「安装 Kimi Hooks」即可把这层拨杆接管补回去,然后再重开一次 kimi。 + + 安装完成。Hooks 为 Beta,行为以官方文档为准。 + """ + : s + refresh() + } + + /// 单独移除 Kimi Hooks 标记块。 + func removeKimiHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + agentUserAlert = removeKimiHooks() + refresh() + } + private func installCursorHooks() -> String { // Cursor 的目录可能不存在,先建好 let cursorDir = (cursorHooksPath as NSString).deletingLastPathComponent @@ -927,6 +1073,13 @@ final class AgentManager: ObservableObject { do { try config.write(toFile: codexConfigPath, atomically: true, encoding: .utf8) + guard FileManager.default.fileExists(atPath: codexConfigPath), + let written = try? String(contentsOfFile: codexConfigPath, encoding: .utf8), + written.contains(codexHookBlockStart), + written.contains(codexHookBlockEnd) else { + log.error("installCodexHooks: 写入后校验失败 \(self.codexConfigPath)") + return "Codex Hooks:已尝试写入 \(codexConfigPath),但校验时未发现 AhaKey 标记块。请确认对「用户主目录 /.codex」有写权限,或关闭占用该文件的其它程序。" + } log.info("Codex hooks 已写入 ~/.codex/config.toml") let cliRepair = repairCodexCliPathIfNeeded() return cliRepair.isEmpty @@ -981,14 +1134,18 @@ final class AgentManager: ObservableObject { } private func isExecutableOnPath(_ command: String) -> Bool { + executablePathOnPath(command) != nil + } + + private func executablePathOnPath(_ command: String) -> String? { let path = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" for dir in path.split(separator: ":") { let candidate = (String(dir) as NSString).appendingPathComponent(command) if FileManager.default.isExecutableFile(atPath: candidate) { - return true + return candidate } } - return false + return nil } @discardableResult @@ -1093,6 +1250,404 @@ final class AgentManager: ObservableObject { .replacingOccurrences(of: "\"", with: "\\\"") } + private func installKimiHooks() -> String { + let kimiDir = (kimiConfigPath as NSString).deletingLastPathComponent + do { + try FileManager.default.createDirectory(atPath: kimiDir, withIntermediateDirectories: true) + } catch { + return "Kimi Hooks:无法创建目录 \(kimiDir):\(error.localizedDescription)" + } + + var config = (try? String(contentsOfFile: kimiConfigPath, encoding: .utf8)) ?? "" + config = removeKimiHookBlock(from: config) + config = removeLegacyKimiHookEntries(from: config) + config = config.trimmingCharacters(in: .whitespacesAndNewlines) + if !config.isEmpty { config += "\n\n" } + config += buildKimiHookBlock() + config += "\n" + + do { + try config.write(toFile: kimiConfigPath, atomically: true, encoding: .utf8) + log.info("Kimi hooks 已写入 ~/.kimi/config.toml") + return patchInstalledKimiCliForAhaKeyDialControl() + } catch { + log.error("installKimiHooks: \(error.localizedDescription)") + return "Kimi Hooks:无法写入 \(kimiConfigPath):\(error.localizedDescription)" + } + } + + private struct KimiCliPatchTargets { + let approvalPyPath: String + let slashPyPath: String + let sourceHint: String + } + + private enum KimiCliPatchStatus { + case alreadyPatched + case patched + } + + private func patchInstalledKimiCliForAhaKeyDialControl() -> String { + guard let targets = resolveKimiCliPatchTargets() else { + return """ + Kimi Hooks 已写入 ~/.kimi/config.toml,但**未找到可重打补丁的本机 kimi-cli 安装**。 + + 请确认终端里存在 `kimi` 命令;确认后再次点击「安装 Kimi Hooks」即可重试拨杆接管补丁。 + """ + } + + do { + _ = try patchKimiApprovalPy(atPath: targets.approvalPyPath) + _ = try patchKimiSlashPy(atPath: targets.slashPyPath) + log.info("Kimi CLI dial-control patch ensured at \(targets.sourceHint)") + return "" + } catch { + log.error("patchInstalledKimiCliForAhaKeyDialControl: \(error.localizedDescription)") + return """ + Kimi Hooks 已写入 ~/.kimi/config.toml,但**本机 kimi-cli 拨杆接管补丁未完成**: + \(error.localizedDescription) + + 你可在确认 `kimi` 可执行后,再次点击「安装 Kimi Hooks」重试。 + """ + } + } + + private func resolveKimiCliPatchTargets() -> KimiCliPatchTargets? { + if let kimiPath = executablePathOnPath("kimi"), + let targets = resolveKimiCliPatchTargets(fromKimiEntryPath: kimiPath) { + return targets + } + + let fallbackRoot = URL(fileURLWithPath: kimiCliFallbackRoot, isDirectory: true) + if let targets = resolveKimiCliPatchTargets(fromEnvRoot: fallbackRoot, sourceHint: fallbackRoot.path) { + return targets + } + return nil + } + + private func resolveKimiCliPatchTargets(fromKimiEntryPath path: String) -> KimiCliPatchTargets? { + guard let wrapper = try? String(contentsOfFile: path, encoding: .utf8), + let firstLine = wrapper.components(separatedBy: .newlines).first, + firstLine.hasPrefix("#!") else { + return nil + } + let shebang = String(firstLine.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !shebang.isEmpty else { return nil } + let pythonPath = shebang.components(separatedBy: .whitespaces).first ?? shebang + let envRoot = URL(fileURLWithPath: pythonPath) + .deletingLastPathComponent() + .deletingLastPathComponent() + return resolveKimiCliPatchTargets(fromEnvRoot: envRoot, sourceHint: path) + } + + private func resolveKimiCliPatchTargets(fromEnvRoot envRoot: URL, sourceHint: String) -> KimiCliPatchTargets? { + let libRoot = envRoot.appendingPathComponent("lib", isDirectory: true) + guard let children = try? FileManager.default.contentsOfDirectory(at: libRoot, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { + return nil + } + for child in children.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard child.lastPathComponent.hasPrefix("python") else { continue } + let pkgRoot = child.appendingPathComponent("site-packages/kimi_cli", isDirectory: true) + let approval = pkgRoot.appendingPathComponent("soul/approval.py").path + let slash = pkgRoot.appendingPathComponent("soul/slash.py").path + if FileManager.default.fileExists(atPath: approval), + FileManager.default.fileExists(atPath: slash) { + return KimiCliPatchTargets( + approvalPyPath: approval, + slashPyPath: slash, + sourceHint: sourceHint + ) + } + } + return nil + } + + private func patchKimiApprovalPy(atPath path: String) throws -> KimiCliPatchStatus { + let marker = "_AHAKEY_SOCKET_PATH = \"/tmp/ahakey.sock\"" + let helperAnchor = "type Response = Literal[\"approve\", \"approve_for_session\", \"reject\"]\n" + let helperBlock = """ + type Response = Literal["approve", "approve_for_session", "reject"] + + _AHAKEY_SOCKET_PATH = "/tmp/ahakey.sock" + _AHAKEY_APPROVAL_CACHE_TTL_S = 0.35 + _ahakey_cache_at = 0.0 + _ahakey_cache_value: dict[str, object] | None = None + + + def _load_ahakey_override_uncached() -> dict[str, object] | None: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(2.0) + sock.connect(_AHAKEY_SOCKET_PATH) + sock.sendall(b'{"cmd":"approval_status"}\\n') + + chunks: list[bytes] = [] + while True: + part = sock.recv(4096) + if not part: + break + chunks.append(part) + if b"\\n" in part: + break + except OSError: + return None + + raw = b"".join(chunks).decode("utf-8", errors="ignore").strip() + if not raw: + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return None + switch_state = payload.get("switchState") + if not isinstance(switch_state, int): + return None + return { + "switch_state": switch_state, + "is_auto": switch_state == 0, + "mode_label": "auto" if switch_state == 0 else "manual", + } + + + def get_ahakey_approval_override(*, force_refresh: bool = False) -> dict[str, object] | None: + global _ahakey_cache_at, _ahakey_cache_value + + now = time.monotonic() + if not force_refresh and (now - _ahakey_cache_at) < _AHAKEY_APPROVAL_CACHE_TTL_S: + return _ahakey_cache_value + + value = _load_ahakey_override_uncached() + _ahakey_cache_at = now + _ahakey_cache_value = value + return value + """ + let oldImports = "import uuid\n" + let newImports = """ + import json + import socket + import time + import uuid + """ + let oldApprovalLogic = """ + if self.is_auto_approve(): + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="afk" if self.is_afk() else "yolo", + ) + return ApprovalResult(approved=True) + + if action in self._state.auto_approve_actions: + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="auto_session", + ) + return ApprovalResult(approved=True) + """ + let newApprovalLogic = """ + ahakey_override = get_ahakey_approval_override(force_refresh=True) + if ahakey_override is not None and bool(ahakey_override["is_auto"]): + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="ahakey_dial_auto", + ) + return ApprovalResult(approved=True) + + ahakey_manual_lock = ahakey_override is not None and not bool(ahakey_override["is_auto"]) + + if not ahakey_manual_lock and self.is_auto_approve(): + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="afk" if self.is_afk() else "yolo", + ) + return ApprovalResult(approved=True) + + if not ahakey_manual_lock and action in self._state.auto_approve_actions: + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="auto_session", + ) + return ApprovalResult(approved=True) + """ + return try patchTextFile( + atPath: path, + marker: marker, + replacements: [ + (oldImports, newImports), + (helperAnchor, helperBlock + "\n"), + (oldApprovalLogic, newApprovalLogic), + ], + friendlyName: "kimi_cli/soul/approval.py" + ) + } + + private func patchKimiSlashPy(atPath path: String) throws -> KimiCliPatchStatus { + let marker = "from kimi_cli.soul.approval import get_ahakey_approval_override" + let oldImport = "from kimi_cli import logger\n" + let newImport = """ + from kimi_cli import logger + from kimi_cli.soul.approval import get_ahakey_approval_override + """ + let oldYoloLead = """ + # Inspect only the yolo flag: afk is independent and is toggled by /afk. + """ + let newYoloLead = """ + ahakey_override = get_ahakey_approval_override(force_refresh=True) + if ahakey_override is not None: + mode_label = "自动批准" if bool(ahakey_override["is_auto"]) else "手动批准" + wire_send( + TextPart( + text=( + f"AhaKey 拨杆接管中:当前为{mode_label}。" + "请直接拨动键盘上的物理拨杆切换;`/yolo` 不会覆盖拨杆。" + ) + ) + ) + return + + # Inspect only the yolo flag: afk is independent and is toggled by /afk. + """ + return try patchTextFile( + atPath: path, + marker: marker, + replacements: [ + (oldImport, newImport), + (oldYoloLead, newYoloLead), + ], + friendlyName: "kimi_cli/soul/slash.py" + ) + } + + private func patchTextFile( + atPath path: String, + marker: String, + replacements: [(String, String)], + friendlyName: String + ) throws -> KimiCliPatchStatus { + let url = URL(fileURLWithPath: path) + var text = try String(contentsOf: url, encoding: .utf8) + if text.contains(marker) { + return .alreadyPatched + } + for (old, new) in replacements { + guard text.contains(old) else { + throw NSError( + domain: "AhaKeyKimiPatch", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "未在 \(friendlyName) 中找到可替换的上游锚点,可能是 kimi-cli 版本已变。"] + ) + } + text = text.replacingOccurrences(of: old, with: new) + } + try text.write(to: url, atomically: true, encoding: .utf8) + return .patched + } + + @discardableResult + private func removeKimiHooks() -> String { + let path = kimiConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "未找到 \(path),无需移除 Kimi Hooks。" + } + guard let config = try? String(contentsOfFile: path, encoding: .utf8) else { + return "无法读取 \(path),请检查权限。" + } + let next = removeLegacyKimiHookEntries(from: removeKimiHookBlock(from: config)) + guard next != config else { + return "在 \(path) 中未发现 AhaKey Kimi hook 标记块或旧版裸 hook。" + } + do { + try next.write(toFile: path, atomically: true, encoding: .utf8) + log.info("Kimi hooks 中 AhaKey 标记块与旧版裸 hook 已移除") + return "已从 \(path) 移除 AhaKey Kimi Hooks。" + } catch { + return "已生成移除后的内容,但无法写回 \(path):\(error.localizedDescription)" + } + } + + private func buildKimiHookBlock() -> String { + let binQuoted = shellQuote(agentBinaryPath) + var lines: [String] = [ + kimiHookBlockStart, + "# Managed by AhaKey Studio. Kimi CLI (Beta): multiple [[hooks]] entries; each runs with JSON on stdin.", + "# Dial integration is managed by AhaKey Studio. Re-click 'Install Kimi Hooks' after kimi-cli upgrades, then reopen kimi once.", + ] + for item in kimiHookEntries { + let cmdToml = escapeTomlBasicString("/bin/zsh -lc \(shellQuote("\(binQuoted) hook \(item.agentEvent)"))") + lines.append("") + lines.append("[[hooks]]") + lines.append("event = \"\(item.event)\"") + lines.append("matcher = \"\"") + lines.append("command = \"\(cmdToml)\"") + lines.append("timeout = \(item.timeout)") + } + lines.append("") + lines.append(kimiHookBlockEnd) + return lines.joined(separator: "\n") + } + + private func removeKimiHookBlock(from config: String) -> String { + var lines = config.components(separatedBy: .newlines) + while let start = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == kimiHookBlockStart }), + let end = lines[start...].firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == kimiHookBlockEnd }) { + lines.removeSubrange(start...end) + } + return lines.joined(separator: "\n") + .replacingOccurrences(of: "\n\n\n", with: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + + /// 清理未包在 BEGIN/END 标记块中的旧版 AhaKey Kimi hook,避免同一事件重复触发两次。 + private func removeLegacyKimiHookEntries(from config: String) -> String { + let lines = config.components(separatedBy: .newlines) + var kept: [String] = [] + var idx = 0 + + while idx < lines.count { + let trimmed = lines[idx].trimmingCharacters(in: .whitespaces) + guard trimmed == "[[hooks]]" else { + kept.append(lines[idx]) + idx += 1 + continue + } + + var block = [lines[idx]] + idx += 1 + while idx < lines.count { + let nextTrimmed = lines[idx].trimmingCharacters(in: .whitespaces) + if nextTrimmed == "[[hooks]]" || nextTrimmed == kimiHookBlockStart || nextTrimmed == kimiHookBlockEnd { + break + } + block.append(lines[idx]) + idx += 1 + } + + let joined = block.joined(separator: "\n") + if isAhakeyHookCommand(joined), joined.contains("hook Kimi") { + continue + } + kept.append(contentsOf: block) + } + + return kept.joined(separator: "\n") + .replacingOccurrences(of: "\n\n\n", with: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + /// 供「卸载主流程」等内部调用,无 UI 提示。 private func removeCursorHooks() { _ = performRemoveCursorHooksUserMessage(writeAndLog: true, preferCompactMessage: true) diff --git a/platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift b/Sources/Utilities/AhaTypeTextOptimizer.swift similarity index 100% rename from platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift rename to Sources/Utilities/AhaTypeTextOptimizer.swift diff --git a/platforms/macos/Sources/Utilities/CloudAccountManager.swift b/Sources/Utilities/CloudAccountManager.swift similarity index 100% rename from platforms/macos/Sources/Utilities/CloudAccountManager.swift rename to Sources/Utilities/CloudAccountManager.swift diff --git a/platforms/macos/Sources/Utilities/DebugSigningFixer.swift b/Sources/Utilities/DebugSigningFixer.swift similarity index 100% rename from platforms/macos/Sources/Utilities/DebugSigningFixer.swift rename to Sources/Utilities/DebugSigningFixer.swift diff --git a/platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift b/Sources/Utilities/DefaultOLEDAssets.swift similarity index 93% rename from platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift rename to Sources/Utilities/DefaultOLEDAssets.swift index 653d2a3d..c51e0ad1 100644 --- a/platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift +++ b/Sources/Utilities/DefaultOLEDAssets.swift @@ -1,6 +1,6 @@ import Foundation -/// 访问 app bundle 内置的默认 OLED 素材。 +/// 访问 app bundle 内置的默认 LCD 素材。 /// 资源由 scripts/build-debug.sh 从项目根的 Resources/DefaultOLED/ 拷贝到 /// AhaKey Studio.app/Contents/Resources/DefaultOLED/。 enum DefaultOLEDAssets { @@ -13,9 +13,9 @@ enum DefaultOLEDAssets { case .mode0: return "claude_0" case .mode1: - return "cursor_0" + return "cursor" case .mode2: - return nil + return "codex" } } diff --git a/platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift b/Sources/Utilities/NativeSpeechTranscriptionService.swift similarity index 68% rename from platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift rename to Sources/Utilities/NativeSpeechTranscriptionService.swift index 6b6be447..b21cd8c7 100644 --- a/platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift +++ b/Sources/Utilities/NativeSpeechTranscriptionService.swift @@ -10,12 +10,34 @@ final class NativeSpeechTranscriptionService: ObservableObject { @Published private(set) var microphoneGranted = false @Published private(set) var speechRecognitionGranted = false + @Published private(set) var siriEnabled = false + @Published private(set) var dictationEnabled = false @Published private(set) var isRecording = false @Published private(set) var statusMessage = "等待苹果原生转写就绪。" @Published private(set) var transcriptPreview = "" @Published private(set) var lastCommittedText = "" - @Published private(set) var lastPermissionCheckSummary = "尚未检查麦克风与语音转写权限。" + @Published private(set) var lastPermissionCheckSummary = "尚未检查麦克风、语音转写与 Siri 权限。" + // MARK: 录音触发方式配置 + /// 短按(切换式):录音结束后是否调用 AhaType 整理 + @Published var shortPressAhaTypeEnabled: Bool = UserDefaults.standard.object(forKey: "nativeSpeech.shortPressAhaType") as? Bool ?? true { + didSet { UserDefaults.standard.set(shortPressAhaTypeEnabled, forKey: "nativeSpeech.shortPressAhaType") } + } + /// 长按模式(按住录音,松手发送)始终开启,不再由用户关闭 + @Published var longPressEnabled: Bool = true + /// 长按模式结束后是否调用 AhaType(默认关闭:快速直发) + @Published var longPressAhaTypeEnabled: Bool = UserDefaults.standard.object(forKey: "nativeSpeech.longPressAhaType") as? Bool ?? false { + didSet { UserDefaults.standard.set(longPressAhaTypeEnabled, forKey: "nativeSpeech.longPressAhaType") } + } + /// 长按判定阈值(毫秒) + @Published var longPressThresholdMs: Int = UserDefaults.standard.object(forKey: "nativeSpeech.longPressThresholdMs") as? Int ?? 500 { + didSet { UserDefaults.standard.set(longPressThresholdMs, forKey: "nativeSpeech.longPressThresholdMs") } + } + + /// 当前是否处于长按录音模式(按住中,松手会直接发送) + @Published private(set) var isLongPressRecording = false + + private var longPressTimerWork: DispatchWorkItem? private var audioEngine: AVAudioEngine? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? @@ -66,6 +88,17 @@ final class NativeSpeechTranscriptionService: ObservableObject { let currentSpeechStatus = SFSpeechRecognizer.authorizationStatus() let currentSpeechGranted = currentSpeechStatus == .authorized + let currentSiriEnabled = Self.readBooleanPreference( + domain: "com.apple.assistant.support", + key: "Assistant Enabled" + ) ?? false + let currentDictationEnabled = Self.readBooleanPreference( + domain: "com.apple.assistant.support", + key: "Dictation Enabled" + ) ?? Self.readBooleanPreference( + domain: "com.apple.HIToolbox", + key: "AppleDictationAutoEnable" + ) ?? false if requestIfNeeded { if micStatus == .notDetermined { @@ -88,37 +121,129 @@ final class NativeSpeechTranscriptionService: ObservableObject { let timeLabel = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) microphoneGranted = currentMicGranted speechRecognitionGranted = currentSpeechGranted + siriEnabled = currentSiriEnabled + dictationEnabled = currentDictationEnabled lastPermissionCheckSummary = - "麦克风 \(currentMicGranted ? "已开启" : "未开启") · 语音转写 \(currentSpeechGranted ? "已开启" : "未开启") · 检查于 \(timeLabel)" + "麦克风 \(currentMicGranted ? "已开启" : "未开启") · 语音转写 \(currentSpeechGranted ? "已开启" : "未开启") · Siri \(currentSiriEnabled ? "已开启" : "未开启") · 听写 \(currentDictationEnabled ? "已开启" : "未开启") · 检查于 \(timeLabel)" - if !currentMicGranted || !currentSpeechGranted { - statusMessage = "还缺苹果原生语音权限,请先打开麦克风和语音转写权限。" + if !currentMicGranted || !currentSpeechGranted || !currentSiriEnabled || !currentDictationEnabled { + statusMessage = "还缺苹果原生语音权限,请先打开麦克风、语音转写、Siri 与听写。" } else if !isRecording { statusMessage = "苹果原生转写已就绪,按一次语音键开始,再按一次结束。" } - appendDiagnostic("permissions mic=\(currentMicGranted) speech=\(currentSpeechGranted)") + appendDiagnostic("permissions mic=\(currentMicGranted) speech=\(currentSpeechGranted) siri=\(currentSiriEnabled) dictation=\(currentDictationEnabled)") + } + + // MARK: - 语音键事件入口(VoiceRelayService 调用) + + /// keyDown 时调用:若长按模式启用,开启长按计时器;否则立即开始录音或等 keyUp 切换。 + func handleVoiceKeyDown() { + if longPressEnabled, !isRecording { + // 启动长按计时:阈值内松开 → 短按;超时后仍按着 → 进入长按录音 + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.longPressTimerWork = nil + if !self.isRecording { + self.isLongPressRecording = true + self.startRecording() + self.appendDiagnostic("long press threshold reached → start long press recording") + } + } + longPressTimerWork = work + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(longPressThresholdMs) / 1000, + execute: work + ) + } else if !longPressEnabled { + // 无长按:keyDown 直接切换(兼容旧行为) + toggleRecordingFromVoiceKey() + } + // 若 longPressEnabled 且已在录音中,keyDown 不做任何事,等 keyUp 判断 + } + + /// keyUp 时调用:若长按模式活跃 → 结束并直接发送;否则短按切换。 + func handleVoiceKeyUp() { + if isLongPressRecording { + // 长按录音结束:停止并按长按配置决定是否用 AhaType + isLongPressRecording = false + longPressTimerWork?.cancel() + longPressTimerWork = nil + appendDiagnostic("long press key up → stop + \(longPressAhaTypeEnabled ? "ahatype" : "direct")") + stopRecording(bypassAhaType: !longPressAhaTypeEnabled) + return + } + + if let work = longPressTimerWork { + // 计时器还没触发 → 短按,取消计时并切换录音 + work.cancel() + longPressTimerWork = nil + appendDiagnostic("short press (keyUp before threshold) → toggle") + if isRecording { + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } else { + startRecording() + } + } else if longPressEnabled, isRecording { + // 长按模式开启时,短按第一次已进入切换式录音;第二次短按没有 timer, + // 仍应按短按配置结束录音,保持“按一次开始,再按一次结束”的体验。 + appendDiagnostic("short press while recording → stop") + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } else if !longPressEnabled { + // 无长按模式:keyDown 已处理,keyUp 不重复 + } } func toggleRecordingFromVoiceKey() { if isRecording { - stopRecording() + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) } else { startRecording() } } func stopRecording() { + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } + + func requestMicrophonePermission() { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + if status == .notDetermined { + AVCaptureDevice.requestAccess(for: .audio) { _ in + Task { @MainActor in + self.refreshPermissions() + } + } + } else { + refreshPermissions() + } + } + + func requestSpeechRecognitionPermission() { + let status = SFSpeechRecognizer.authorizationStatus() + if status == .notDetermined { + SFSpeechRecognizer.requestAuthorization { _ in + Task { @MainActor in + self.refreshPermissions() + } + } + } else { + refreshPermissions() + } + } + + func stopRecording(bypassAhaType: Bool) { guard isRecording else { return } isRecording = false statusMessage = "正在结束录音并整理文字…" VoiceStatusHUDController.shared.show(.recognizing) - appendDiagnostic("stop recording requested") + pendingFinalizeBypassAhaType = bypassAhaType + appendDiagnostic("stop recording requested bypassAhaType=\(bypassAhaType)") finalizeWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in Task { @MainActor in - self?.finalizeCurrentTranscriptIfNeeded(reason: "timeout_finalize") + self?.finalizeCurrentTranscriptIfNeeded(reason: "timeout_finalize", bypassAhaType: bypassAhaType) } } finalizeWorkItem = workItem @@ -130,13 +255,20 @@ final class NativeSpeechTranscriptionService: ObservableObject { } private func startRecording() { - guard microphoneGranted, speechRecognitionGranted else { + guard microphoneGranted, speechRecognitionGranted, siriEnabled, dictationEnabled else { let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) let speechStatus = SFSpeechRecognizer.authorizationStatus() refreshPermissions(requestIfNeeded: true) - statusMessage = missingPermissionMessage(micStatus: micStatus, speechStatus: speechStatus) - appendDiagnostic("blocked start recording micStatus=\(micStatus.rawValue) speechStatus=\(speechStatus.rawValue)") - VoiceRelayService.shared.showsPermissionOnboarding = true + statusMessage = missingPermissionMessage( + micStatus: micStatus, + speechStatus: speechStatus, + siriEnabled: siriEnabled, + dictationEnabled: dictationEnabled + ) + appendDiagnostic("blocked start recording micStatus=\(micStatus.rawValue) speechStatus=\(speechStatus.rawValue) siri=\(siriEnabled) dictation=\(dictationEnabled)") + if !VoiceRelayService.shared.isPermissionOnboardingSuppressed { + VoiceRelayService.shared.showsPermissionOnboarding = true + } NSApp.activate(ignoringOtherApps: true) return } @@ -273,6 +405,8 @@ final class NativeSpeechTranscriptionService: ObservableObject { return false } + private var pendingFinalizeBypassAhaType = false + private func handleRecognition(result: SFSpeechRecognitionResult?, error: Error?) { if let result { let newText = result.bestTranscription.formattedString @@ -284,7 +418,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { } appendDiagnostic("partial result=\(newText) isFinal=\(result.isFinal)") if result.isFinal { - finalizeCurrentTranscriptIfNeeded(reason: "final_result") + finalizeCurrentTranscriptIfNeeded(reason: "final_result", bypassAhaType: pendingFinalizeBypassAhaType) return } } @@ -292,7 +426,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { if let error { appendDiagnostic("recognition error: \(error.localizedDescription)") if !currentTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - finalizeCurrentTranscriptIfNeeded(reason: "error_with_text") + finalizeCurrentTranscriptIfNeeded(reason: "error_with_text", bypassAhaType: pendingFinalizeBypassAhaType) } else { cancelRecognitionPipeline() statusMessage = "苹果原生转写失败:\(error.localizedDescription)" @@ -304,7 +438,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { } } - private func finalizeCurrentTranscriptIfNeeded(reason: String) { + private func finalizeCurrentTranscriptIfNeeded(reason: String, bypassAhaType: Bool = false) { finalizeWorkItem?.cancel() finalizeWorkItem = nil @@ -324,12 +458,18 @@ final class NativeSpeechTranscriptionService: ObservableObject { } hasCommittedThisRecording = true - statusMessage = AhaTypeTextOptimizer.shared.isEnabled ? "AhaType 整理中…" : "准备粘贴…" - VoiceStatusHUDController.shared.show(AhaTypeTextOptimizer.shared.isEnabled ? .ahaType : .pasting) - appendDiagnostic("finalize begin reason=\(reason) rawText=\(text)") + let willUseAhaType = !bypassAhaType && AhaTypeTextOptimizer.shared.isEnabled + statusMessage = willUseAhaType ? "AhaType 整理中…" : "准备粘贴…" + VoiceStatusHUDController.shared.show(willUseAhaType ? .ahaType : .pasting) + appendDiagnostic("finalize begin reason=\(reason) bypass=\(bypassAhaType) rawText=\(text)") Task { @MainActor in - let output = await AhaTypeTextOptimizer.shared.processIfEnabled(text) + let output: String + if willUseAhaType { + output = await AhaTypeTextOptimizer.shared.processIfEnabled(text) + } else { + output = text + } if self.injectText(output) { self.lastCommittedText = output self.statusMessage = output == text ? "已写入:\(output)" : "AhaType 已整理并写入:\(output)" @@ -373,15 +513,37 @@ final class NativeSpeechTranscriptionService: ObservableObject { private func missingPermissionMessage( micStatus: AVAuthorizationStatus, - speechStatus: SFSpeechRecognizerAuthorizationStatus + speechStatus: SFSpeechRecognizerAuthorizationStatus, + siriEnabled: Bool, + dictationEnabled: Bool ) -> String { - if micStatus != .authorized && speechStatus != .authorized { - return "还缺麦克风和语音转写权限。请先在系统设置里放开这两项,再按一次语音键。" - } + var missing: [String] = [] if micStatus != .authorized { - return "还缺麦克风权限。请先在系统设置里给 AhaKey Studio 打开麦克风,再按一次语音键。" + missing.append("麦克风") + } + if speechStatus != .authorized { + missing.append("语音转写") } - return "还缺语音转写权限。请先在系统设置里给 AhaKey Studio 打开语音转写,再按一次语音键。" + if !siriEnabled { + missing.append("Siri") + } + if !dictationEnabled { + missing.append("听写") + } + return "还缺\(missing.joined(separator: "、"))权限。请先在系统设置里打开后,再按一次语音键。" + } + + private static func readBooleanPreference(domain: String, key: String) -> Bool? { + guard let value = CFPreferencesCopyAppValue(key as CFString, domain as CFString) else { + return nil + } + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return CFBooleanGetValue((value as! CFBoolean)) + } + if let number = value as? NSNumber { + return number.boolValue + } + return nil } private func injectText(_ text: String) -> Bool { diff --git a/platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift b/Sources/Utilities/OLEDFrameEncoder.swift similarity index 98% rename from platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift rename to Sources/Utilities/OLEDFrameEncoder.swift index 5b5aa327..cefa86d2 100644 --- a/platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift +++ b/Sources/Utilities/OLEDFrameEncoder.swift @@ -15,7 +15,7 @@ enum OLEDFrameEncodingError: LocalizedError { case .noFrames: return "没有可编码的图片帧。" case .cannotCreateContext: - return "无法创建 OLED 编码上下文。" + return "无法创建 LCD 编码上下文。" case .sourceFileTooLarge(let fileSize, let maxBytes): let f = ByteCountFormatter() f.allowedUnits = [.useMB, .useKB, .useBytes] diff --git a/platforms/macos/Sources/Utilities/VoiceRelayService.swift b/Sources/Utilities/VoiceRelayService.swift similarity index 83% rename from platforms/macos/Sources/Utilities/VoiceRelayService.swift rename to Sources/Utilities/VoiceRelayService.swift index e8422a25..0efafd22 100644 --- a/platforms/macos/Sources/Utilities/VoiceRelayService.swift +++ b/Sources/Utilities/VoiceRelayService.swift @@ -1,5 +1,6 @@ import AppKit import ApplicationServices +import Carbon import Combine import CoreGraphics import Foundation @@ -23,6 +24,7 @@ private struct VoiceTriggerBinding: Hashable { private enum VoiceRouteAction: Hashable { case macOSDictation case functionRelay(appName: String) + case doubaoPassThrough var title: String { switch self { @@ -30,6 +32,8 @@ private enum VoiceRouteAction: Hashable { "macOS 原生语音" case let .functionRelay(appName): appName + case .doubaoPassThrough: + "豆包输入法" } } } @@ -64,6 +68,7 @@ final class VoiceRelayService: ObservableObject { private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? private var didRequestPermissionsThisLaunch = false + private var suppressPermissionOnboardingUntil: Date? private var shadowSuppressUntil: TimeInterval = 0 @@ -170,7 +175,7 @@ final class VoiceRelayService: ObservableObject { self.inputMonitoringGranted = inputMonitoring self.accessibilityGranted = accessibility && postEventAccess self.lastPermissionCheckSummary = lastCheckSummary - self.showsPermissionOnboarding = !(inputMonitoring && accessibility && postEventAccess) + self.showsPermissionOnboarding = !(inputMonitoring && accessibility && postEventAccess) && !self.isPermissionOnboardingSuppressed self.refreshStatusMessage() } @@ -193,6 +198,30 @@ final class VoiceRelayService: ObservableObject { showsPermissionOnboarding = false } + func requestInputMonitoringPermission() { + _ = CGRequestListenEventAccess() + performPermissionRead(requestIfNeeded: false, preferRequestAPI: true) + } + + func requestAccessibilityPermission() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + performPermissionRead(requestIfNeeded: false, preferRequestAPI: true) + } + + func suppressPermissionOnboarding(for seconds: TimeInterval = 4.0) { + suppressPermissionOnboardingUntil = Date().addingTimeInterval(seconds) + showsPermissionOnboarding = false + } + + var isPermissionOnboardingSuppressed: Bool { + if let until = suppressPermissionOnboardingUntil, until > Date() { + return true + } + suppressPermissionOnboardingUntil = nil + return false + } + /// Inspector 调试:模拟当前模式下按一次实体语音键(Typeless/微信 = 切换 Fn 按住;macOS 原生 = 切换系统转写)。 func simulateInspectorVoiceKeyTap(for mode: AhaKeyModeSlot) { let route: VoiceRoute? = routeQueue.sync { @@ -201,7 +230,7 @@ final class VoiceRelayService: ObservableObject { guard let route else { appendDiagnostic("inspector simulate: no route for mode=\(mode.rawValue)") Task { @MainActor in - lastInspectorSimulateHint = "当前模式没有语音路由:请先在「语音软件」里选 Typeless / 微信 / macOS 原生(不要选「自定义」)。" + lastInspectorSimulateHint = "当前模式没有语音路由:请先在「语音软件」里选 Typeless / 微信 / 豆包 / macOS 原生(不要选「自定义」)。" } return } @@ -220,12 +249,19 @@ final class VoiceRelayService: ObservableObject { lastInspectorSimulateHint = "已切换 Fn 按住状态;Typeless 请在 App 内把随声写设为 Fn/Globe(本 Studio 默认监听 F19,出厂语音键 F18 仍兼容)。再点一次为松开。" } } + case .doubaoPassThrough: + configureDoubaoVoiceShortcutIfNeeded() + ensureInputSource(id: Self.doubaoInputSourceID, label: route.action.title) + Task { @MainActor in + lastInspectorSimulateHint = "豆包需要真实 F18 长按事件;已切到豆包输入源并配置长按 F18,请用实体语音键测试。" + } } appendDiagnostic("inspector simulate mode=\(mode.rawValue) action=\(route.action.title)") } func updateRoutes(from draft: AhaKeyStudioDraft) { let builtRoutes = Self.buildRoutes(from: draft) + let needsDoubaoPreparation = builtRoutes.contains { $0.action == .doubaoPassThrough } // 只有路由集合真的变化时才释放"按住"状态,避免 SwiftUI 频繁重建/无关 onChange // 间接把 functionRelay 的 hold 状态冲掉(典型表现:微信按住说话过几秒自动结束)。 @@ -248,6 +284,14 @@ final class VoiceRelayService: ObservableObject { self.refreshStatusMessage() } } + + if needsDoubaoPreparation { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.configureDoubaoVoiceShortcutIfNeeded() + self.ensureInputSource(id: Self.doubaoInputSourceID, label: "豆包输入法") + } + } } // MARK: - Event Monitoring (CGEventTap) @@ -363,15 +407,30 @@ final class VoiceRelayService: ObservableObject { switch route.action { case .macOSDictation: - if type == .keyDown, !isAutoRepeat { + if isAutoRepeat { return nil } + if type == .keyDown { + Task { @MainActor in + NativeSpeechTranscriptionService.shared.handleVoiceKeyDown() + } + } else if type == .keyUp { Task { @MainActor in - NativeSpeechTranscriptionService.shared.toggleRecordingFromVoiceKey() + NativeSpeechTranscriptionService.shared.handleVoiceKeyUp() } } // keyDown/keyUp 都吞掉,避免硬件发出的 F17/F18 漏到前台 App(比如 Claude CLI // 所在的终端会把它翻译成 \e[...~ 字样)。 return nil + case .doubaoPassThrough: + if type == .keyDown, !isAutoRepeat { + configureDoubaoVoiceShortcutIfNeeded() + ensureInputSource(id: Self.doubaoInputSourceID, label: route.action.title) + appendDiagnostic("doubao pass-through keyDown → let physical event reach IME") + } else if type == .keyUp { + appendDiagnostic("doubao pass-through keyUp → let physical event reach IME") + } + return Unmanaged.passUnretained(event) + case .functionRelay: // 与微信 / Typeless「按住说话」一致:跟手硬件 keyDown;keyUp 若过短则略延长 Fn 按住,避免脉冲键无反应。 if isAutoRepeat { @@ -520,6 +579,85 @@ final class VoiceRelayService: ObservableObject { } } + private func ensureInputSource(id inputSourceID: String, label: String) { + guard currentInputSourceID() != inputSourceID else { return } + if selectInputSource(id: inputSourceID) { + appendDiagnostic("input source selected id=\(inputSourceID) for \(label)") + } else { + appendDiagnostic("input source select failed id=\(inputSourceID) for \(label)") + } + } + + private func currentInputSourceID() -> String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { + return nil + } + return inputSourceID(from: source) + } + + private func selectInputSource(id targetID: String) -> Bool { + let sources = TISCreateInputSourceList(nil, true).takeRetainedValue() as NSArray + for item in sources { + let source = item as! TISInputSource + guard inputSourceID(from: source) == targetID else { continue } + return TISSelectInputSource(source) == noErr + } + return false + } + + private func inputSourceID(from source: TISInputSource) -> String? { + guard let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { + return nil + } + return Unmanaged.fromOpaque(raw).takeUnretainedValue() as String + } + + private func configureDoubaoVoiceShortcutIfNeeded() { + let defaults = UserDefaults.standard + var global = defaults.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + var changed = false + + func set(_ key: String, _ value: Any) { + if !NSDictionary(dictionary: global).isEqual(to: [key: value]) && "\(global[key] ?? "")" != "\(value)" { + global[key] = value + changed = true + } + } + + set("isStartASRShortcutEnable", true) + set("isGloableASRShortcutEnable", true) + set("asrShortcutKeyCode", Int(Self.fnTriggerMacKeyCode)) + set("asrShortcutModifierFlags", 0) + set("asrShortcutKeyDisplay", "F18") + set("asrLongPressShortcutKeyCode", Int(Self.fnTriggerMacKeyCode)) + set("asrLongPressShortcutModifierFlags", 0) + set("asrLongPressShortcutKeyDisplay", "F18") + + if changed { + defaults.setPersistentDomain(global, forName: UserDefaults.globalDomain) + defaults.synchronize() + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("DoubaoImeSettings.asrLongPressShortcutKeyNotification"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("DoubaoImeSettings.enableStartASRShortcutNotification"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("DoubaoImeSettings.enableGloableASRShortcutNotification"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + appendDiagnostic("doubao shortcut configured: longPress F18") + } + } + // MARK: - Helpers private func refreshStatusMessage() { @@ -590,11 +728,18 @@ final class VoiceRelayService: ObservableObject { // Claude Code preset 复用 macOS 原生 ASR:录音 → 识别 → ⌘V 粘到当前光标。 // 这样按键会被我们的 monitor 吃掉,不会漏到 Claude CLI 终端里变成 CSI 乱码。 .macOSDictation - case .codex, .doubao, .custom: + case .kimiCode: + .macOSDictation + case .doubao: + .doubaoPassThrough + case .codex, .custom: nil } } + private static let doubaoInputSourceID = "com.bytedance.inputmethod.doubaoime.pinyin" + private static let fnTriggerMacKeyCode: CGKeyCode = 79 + private func appendDiagnostic(_ message: String) { let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n" let url = diagnosticLogURL diff --git a/platforms/macos/Sources/Utilities/VoiceStatusHUDController.swift b/Sources/Utilities/VoiceStatusHUDController.swift similarity index 100% rename from platforms/macos/Sources/Utilities/VoiceStatusHUDController.swift rename to Sources/Utilities/VoiceStatusHUDController.swift diff --git a/platforms/macos/Sources/Views/AhaKeyStudioView.swift b/Sources/Views/AhaKeyStudioView.swift similarity index 51% rename from platforms/macos/Sources/Views/AhaKeyStudioView.swift rename to Sources/Views/AhaKeyStudioView.swift index 1bba9d57..327f4f20 100644 --- a/platforms/macos/Sources/Views/AhaKeyStudioView.swift +++ b/Sources/Views/AhaKeyStudioView.swift @@ -23,9 +23,21 @@ struct AhaKeyStudioView: View { // AhaKeyStudio 交还蓝牙给 Agent 的过渡期:保持"已连接"显示,直到 Agent 接管或超时。 @State private var isTransitioningToKeyboardControl = false @State private var showsOLEDPlaybackPreview = false + @State private var selectedOLEDGIFSet = 0 + @State private var selectedOLEDTaskState: AhaKeyTaskDisplayState = .sessionEnd @State private var showsDeviceInfo = false @State private var showsCloudAccount = false @State private var showsAhaTypeLoginRequiredToast = false + @AppStorage(UnifiedOnboardingStorage.completedKey) private var unifiedOnboardingCompleted = false + @State private var isEditingInspector = false + @State private var showsDiagnostics = false + @State private var showsKeyHelp = false + @State private var selectedTriggerTab: Int = 0 + /// 每次主 App 自占 BLE 连接成功只跑一次默认 OLED 自动同步。 + /// .onChange(of: isConnected) 在断开时重置;下次重连时再触发一次。 + @State private var oledAutoSyncDoneForConnection: Bool = false + @State private var showsHelpCenter = false + @State private var showsGuidanceDetail = false init(bleManager: AhaKeyBLEManager) { self.bleManager = bleManager @@ -42,6 +54,11 @@ struct AhaKeyStudioView: View { _lightBarPreview = State(initialValue: .aiRunning) } + private struct TaskGIFChange { + let slot: KeyboardTaskPictureSlot + let asset: AhaKeyTaskGIFAssetDraft + } + var body: some View { VStack(spacing: 0) { topBar @@ -54,12 +71,13 @@ struct AhaKeyStudioView: View { Divider() statusBar } - .frame(minWidth: 1280, minHeight: 820) + .frame(minWidth: 1180, minHeight: 680) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { agentManager.applyStoredBluetoothPreferenceOnLaunch(bleManager: bleManager) voiceRelay.start() nativeSpeech.start() + bleManager.refreshBluetoothAuthorization() applyCursorRejectMacroSelfHealIfNeeded() voiceRelay.updateRoutes(from: studioDraft) SwitchStateNotifier.shared.bind(to: bleManager) @@ -68,18 +86,53 @@ struct AhaKeyStudioView: View { object: nil, userInfo: ["workMode": bleManager.workMode] ) + scheduleStartupPermissionOnboarding() } .onChange(of: studioDraft) { newValue in AhaKeyStudioStore.save(newValue) voiceRelay.updateRoutes(from: newValue) } // 键盘物理档位变化(BLE 查询/通知上报)→ 自动切到对应 Mode 标签, - // 这样 OLED 预览、快捷键草稿、发出去的 updateState 三者一致。 + // 这样 LCD 预览、快捷键草稿、发出去的 updateState 三者一致。 .onChange(of: bleManager.workMode) { newValue in if let slot = AhaKeyModeSlot(rawValue: newValue), slot != selectedMode { selectedMode = slot } } + .onChange(of: bleManager.isConnected) { connected in + if !connected { oledAutoSyncDoneForConnection = false } + } + .onChange(of: bleManager.keyboardPictureStates) { _ in + guard !oledAutoSyncDoneForConnection else { return } + // 三个 mode 都查回来才动手 + guard bleManager.keyboardPictureStates.count == AhaKeyModeSlot.allCases.count else { return } + oledAutoSyncDoneForConnection = true + Task { await autoSyncDefaultOLEDsIfNeeded() } + } + .onChange(of: bleManager.bluetoothPermissionGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: bleManager.bluetoothPoweredOn) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: voiceRelay.inputMonitoringGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: voiceRelay.accessibilityGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.microphoneGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.speechRecognitionGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.siriEnabled) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.dictationEnabled) { _ in + refreshStartupPermissionOnboarding() + } .alert("Agent", isPresented: Binding( get: { agentManager.agentUserAlert != nil }, set: { if !$0 { agentManager.agentUserAlert = nil } } @@ -101,13 +154,7 @@ struct AhaKeyStudioView: View { .sheet(isPresented: $showsOLEDPlaybackPreview) { OLEDMotionPreviewSheet( modeTitle: selectedMode.title, - assetPath: currentModeDraft.oled.localAssetPath - ) - } - .sheet(isPresented: $voiceRelay.showsPermissionOnboarding) { - VoicePermissionOnboardingSheet( - voiceRelay: voiceRelay, - nativeSpeech: nativeSpeech + assetPath: currentOLEDTaskAsset.localAssetPath ) } .sheet(isPresented: $showsDeviceInfo) { @@ -121,13 +168,14 @@ struct AhaKeyStudioView: View { } private var topBar: some View { - HStack(spacing: 18) { + HStack(spacing: 14) { VStack(alignment: .leading, spacing: 0) { Text("AhaKey Studio") .font(.system(size: 22, weight: .semibold, design: .rounded)) } + .layoutPriority(1) - HStack(spacing: 10) { + HStack(spacing: 8) { infoPill( title: isEffectivelyConnected ? "已连接" : (bleManager.isScanning ? "扫描中" : "未连接"), subtitle: bleManager.deviceName ?? "等待设备", @@ -144,6 +192,7 @@ struct AhaKeyStudioView: View { accent: currentSwitchTitle == "自动批准" ? .mint : .indigo ) } + .layoutPriority(2) Spacer(minLength: 0) @@ -168,12 +217,14 @@ struct AhaKeyStudioView: View { .help("安装/修复 Agent 与 Hook,并启动 Agent 控制键盘。") } - Button(configurationModeButtonTitle) { - handleConfigurationModeButton() + Button { + NSPasteboard.general.clearContents() + } label: { + Image(systemName: "arrow.counterclockwise.circle") + .imageScale(.medium) } - .buttonStyle(.borderedProminent) - .disabled(isSyncing || agentManager.isAgentOperationInProgress) - .help(configurationModeButtonHelp) + .buttonStyle(.bordered) + .help("清空剪贴板 / 刷新剪贴板") Menu { Button("恢复当前模式默认值") { @@ -183,10 +234,6 @@ struct AhaKeyStudioView: View { bleManager.disconnect() bleManager.userInitiatedConnect() } - Button("清空 OLED 预览") { - clearCurrentOLED() - } - Divider() Button("设备信息 · Agent…") { showsDeviceInfo = true } @@ -205,8 +252,12 @@ struct AhaKeyStudioView: View { NSApp.terminate(nil) } } label: { - Label("更多", systemImage: "ellipsis.circle") + Image(systemName: "ellipsis.circle") + .imageScale(.large) } + .menuStyle(.borderlessButton) + .frame(width: 32, height: 28) + .help("更多") } .padding(.horizontal, 24) .padding(.vertical, 18) @@ -226,19 +277,21 @@ struct AhaKeyStudioView: View { .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) } } - .padding(.horizontal, 12) + .frame(width: 138, alignment: .leading) + .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 10) .fill(Color(nsColor: .controlBackgroundColor)) ) - .help("日常使用由 Agent 控制键盘;需要改键、OLED 或同步时,进入编辑配置后由 AhaKey Studio 临时接管蓝牙。") + .help("日常使用由 Agent 控制键盘;需要改键、LCD 或同步时,进入编辑配置后由 AhaKey Studio 临时接管蓝牙。") } private var ahaTypeModeStatus: some View { - HStack(spacing: 8) { + HStack(spacing: 7) { Circle() .fill(ahaType.isEnabled ? Color.green : Color.gray.opacity(0.55)) .frame(width: 8, height: 8) @@ -250,6 +303,7 @@ struct AhaKeyStudioView: View { .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) } Toggle("", isOn: Binding( get: { ahaType.isEnabled }, @@ -265,7 +319,8 @@ struct AhaKeyStudioView: View { .toggleStyle(.switch) .controlSize(.small) } - .padding(.horizontal, 12) + .frame(width: 150, alignment: .leading) + .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 10) @@ -281,11 +336,24 @@ struct AhaKeyStudioView: View { VStack(alignment: .leading, spacing: 8) { AhaKeyKeyboardCanvasView( modeDraft: currentModeDraft, + oledAssetPath: currentOLEDTaskAsset.localAssetPath, + oledFramesPerSecond: currentOLEDTaskAsset.framesPerSecond, selectedPart: selectedPart, lightBarPreview: lightBarPreview, switchTitle: currentSwitchTitle, dirtyParts: dirtyPartsForCurrentMode(), - onSelect: { selectedPart = $0 } + onSelect: { selectedPart = $0 }, + onModeSwitch: { cycleModeForward() }, + onKeySimulate: { role in + if role == .voice { + nativeSpeech.toggleRecordingFromVoiceKey() + } + }, + onSwitchToggle: { toggleVirtualSwitch() }, + liveLightMode: liveCanvasLightMode, + liveIDEStateValue: liveCanvasIDEStateValue, + switchState: liveCanvasSwitchState, + keyboardPictureFrameCount: bleManager.keyboardPictureStates[selectedMode.rawValue]?.frameCount ) .aspectRatio(109.0 / 54.0, contentMode: .fit) .frame(maxWidth: .infinity) @@ -296,17 +364,6 @@ struct AhaKeyStudioView: View { .padding(.leading, 20) } .frame(maxWidth: .infinity) - - HStack(spacing: 12) { - manualCallout( - title: "主流程", - detail: "默认键盘控制 -> 点编辑配置 -> 修改 -> 同步并返回控制" - ) - manualCallout( - title: "模式切换", - detail: "短按设备按键切换模式,OLED 会先显示描述约 1 秒,再回到该模式动图" - ) - } } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -330,36 +387,118 @@ struct AhaKeyStudioView: View { Spacer(minLength: 0) } - Text(selectedMode.guidance) - .font(.callout) - .foregroundStyle(.secondary) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(selectedMode.guidance) + .font(.callout) + .foregroundStyle(.secondary) + if let detail = selectedMode.guidanceHoverDetail { + Button { + showsGuidanceDetail.toggle() + } label: { + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + .imageScale(.small) + } + .buttonStyle(.borderless) + .help(detail) + .onHover { showsGuidanceDetail = $0 } + .popover(isPresented: $showsGuidanceDetail, arrowEdge: .top) { + Text(detail) + .font(.callout) + .padding(14) + .frame(width: 320) + .fixedSize(horizontal: false, vertical: true) + } + } + } } } private var inspectorPane: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { - inspectorHeader - - Group { - switch selectedPart { - case .key1, .key2, .key3, .key4: - keyInspector - case .oledDisplay: - oledInspector - case .lightBar: - lightBarInspector - case .toggleSwitch: - switchInspector + if isEditingInspector { + // ── Level 2: 全量编辑 ────────────────────────────────── + HStack(spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isEditingInspector = false + returnToKeyboardControl() + } + } label: { + Label("取消编辑", systemImage: "xmark") + .font(.callout.weight(.medium)) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isEditingInspector = false + finishEditingConfiguration() + } + } label: { + Label(isSyncing ? "同步中…" : "返回并保存", systemImage: isSyncing ? "arrow.trianglehead.2.clockwise" : "checkmark") + .font(.callout.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .disabled(isSyncing) + } + + Label(selectedPart.title, systemImage: selectedPart.systemImage) + .font(.system(size: 18, weight: .semibold)) + + Group { + switch selectedPart { + case .key1, .key2, .key3, .key4: keyInspector + case .oledDisplay: oledInspector + case .lightBar: lightBarInspector + case .toggleSwitch: switchInspector + } + } + + } else { + // ── Level 1: 摘要卡片 ────────────────────────────────── + inspectorHeader + + VStack(alignment: .leading, spacing: 0) { + partSummaryContent + } + .padding(.horizontal, 14) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.black.opacity(0.07), lineWidth: 1) + ) + ) + + // macOS HIG:主操作按钮跟随内容尺寸,右下角对齐,标准 control size + HStack { + Spacer() + Button { + enterEditingConfiguration() + withAnimation(.easeInOut(duration: 0.2)) { isEditingInspector = true } + } label: { + Label("修改", systemImage: "pencil") + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .keyboardShortcut("e", modifiers: .command) } + .padding(.top, 6) } - .disabled(!isEditingConfiguration) } .padding(24) } .frame(width: 390) .frame(maxHeight: .infinity) .background(Color(nsColor: .controlBackgroundColor).opacity(0.35)) + .onChange(of: selectedPart) { _ in + withAnimation(.easeInOut(duration: 0.18)) { isEditingInspector = false } + } } private var inspectorHeader: some View { @@ -373,6 +512,31 @@ struct AhaKeyStudioView: View { .font(.caption) .foregroundStyle(.orange) } + if selectedPart.isKey { + Button { + showsKeyHelp.toggle() + } label: { + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + .imageScale(.medium) + } + .buttonStyle(.borderless) + .onHover { showsKeyHelp = $0 } + .popover(isPresented: $showsKeyHelp, arrowEdge: .leading) { + VStack(alignment: .leading, spacing: 8) { + Text("如何使用") + .font(.headline) + Divider() + Text("1. 点击虚拟键盘对应按键选中它。") + Text("2. 语音键先选预设;其他键按需选单键或宏。") + Text("3. 配置完成后点「返回并保存」同步到键盘。") + Text("4. 切模式时 LCD 先显示描述,再回到该模式动图。") + } + .font(.callout) + .padding(16) + .frame(width: 270) + } + } } Text(selectedPart.subtitle) .font(.callout) @@ -380,24 +544,17 @@ struct AhaKeyStudioView: View { } } - private var keyInspector: some View { - let key = currentSelectedKey - return VStack(alignment: .leading, spacing: 16) { - if key.role == .voice { - GroupBox("语音软件") { - VStack(alignment: .leading, spacing: 12) { - VoicePresetPicker( - selectedPreset: key.voicePreset ?? .custom, - onSelect: applyVoicePreset - ) + // MARK: - 权限诊断弹窗 - if (key.voicePreset ?? .custom) == .macOSNative { - Text("只要 AhaKey Studio 在后台运行,Mode 0 出厂语音键发出的 F18 就会被直接接管到苹果原生转写。现在不再依赖系统听写快捷键。") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.top, 4) + private var diagnosticsSheet: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("权限诊断") + .font(.system(size: 20, weight: .semibold)) + Spacer() + Button("关闭") { showsDiagnostics = false } + .buttonStyle(.bordered) } GroupBox("后台语音桥") { @@ -410,54 +567,111 @@ struct AhaKeyStudioView: View { .font(.callout.weight(.semibold)) Spacer() } - HStack(spacing: 10) { permissionBadge(title: "输入监控", granted: voiceRelay.inputMonitoringGranted) permissionBadge(title: "辅助功能", granted: voiceRelay.accessibilityGranted) } - Text(voiceRelay.statusMessage) .font(.caption) .foregroundStyle(.secondary) - Text(voiceRelay.lastPermissionCheckSummary) .font(.caption) .foregroundStyle(.secondary) - Text(voiceRelay.activeRouteSummary) .font(.caption) .foregroundStyle(.secondary) - HStack(spacing: 10) { Button("再次申请权限") { - voiceRelay.refreshPermissions(requestIfNeeded: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { - openCombinedVoicePrivacySettingsURL() - } + requestPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) } .buttonStyle(.borderedProminent) - Button("重新检查权限") { voiceRelay.refreshPermissions(deferredTCCRequery: true) } .buttonStyle(.bordered) + RestartToApplyPermissionsButton() + } + } + .padding(.top, 4) + } + + GroupBox("苹果原生转写") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle() + .fill(nativeSpeech.isRecording ? Color.red : (nativeSpeech.microphoneGranted && nativeSpeech.speechRecognitionGranted ? Color.green : Color.orange)) + .frame(width: 10, height: 10) + Text(nativeSpeech.isRecording ? "录音转写中" : "等待触发") + .font(.callout.weight(.semibold)) + Spacer() + } + HStack(spacing: 10) { + permissionBadge(title: "麦克风", granted: nativeSpeech.microphoneGranted) + permissionBadge(title: "语音转写", granted: nativeSpeech.speechRecognitionGranted) + permissionBadge(title: "Siri", granted: nativeSpeech.siriEnabled) + permissionBadge(title: "听写", granted: nativeSpeech.dictationEnabled) + } + Text(nativeSpeech.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Text(nativeSpeech.lastPermissionCheckSummary) + .font(.caption) + .foregroundStyle(.secondary) + Divider() + HStack(spacing: 10) { + Circle() + .fill(nativeSpeech.isRecording ? Color.red : Color.clear) + .frame(width: 8, height: 8) + Text(nativeSpeech.isRecording ? "录音中" : "转写测试") + .font(.callout.weight(.semibold)) + Spacer() + if !nativeSpeech.transcriptPreview.isEmpty { + Text(nativeSpeech.transcriptPreview) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else if !nativeSpeech.lastCommittedText.isEmpty { + Text("最近写入:\(nativeSpeech.lastCommittedText)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + HStack(spacing: 8) { + Button(nativeSpeech.isRecording ? "结束并写入" : "开始录音") { + nativeSpeech.toggleRecordingFromVoiceKey() + } + .buttonStyle(.borderedProminent) + Button("重新检查权限") { + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) RestartToApplyPermissionsButton() + if !nativeSpeechPermissionsReady { + Button("打开系统设置") { openNativeSpeechPrivacySettings() } + .buttonStyle(.bordered) + } } } .padding(.top, 4) } - if let preset = key.voicePreset, preset == .typeless || preset == .wechat { - GroupBox("Typeless / 微信") { + let voiceKey = currentModeDraft.key(for: .voice) + if let preset = voiceKey.voicePreset, preset == .typeless || preset == .wechat || preset == .doubao { + GroupBox("Fn 语音输入法") { VStack(alignment: .leading, spacing: 8) { - Text("「开始录音」只用于「macOS 原生语音」。当前预设由硬件语音键或下方按钮触发:会向系统注入 Fn 按住/松开,供输入法「按住说话」使用。") + Text("Typeless / 微信会注入 Fn 按住/松开;豆包会切到豆包输入源并放行真实 F18。") .font(.caption) .foregroundStyle(.secondary) - Text("排查请看 voice-relay.log( matched · function relay · post fn )。路径:~/Library/Application Support/AhaKeyConfig/diagnostics/") + Text("排查请看 voice-relay.log(matched · function relay · post fn)。路径:~/Library/Application Support/AhaKeyConfig/diagnostics/") .font(.caption2) .foregroundStyle(.tertiary) - Button("模拟按一次语音键(切换 Fn 按住)") { + Button("模拟按一次语音键") { voiceRelay.simulateInspectorVoiceKeyTap(for: selectedMode) } .buttonStyle(.borderedProminent) @@ -471,172 +685,146 @@ struct AhaKeyStudioView: View { } } - if (key.voicePreset ?? .custom) == .macOSNative { - GroupBox("苹果原生转写") { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Circle() - .fill(nativeSpeech.isRecording ? Color.red : (nativeSpeech.microphoneGranted && nativeSpeech.speechRecognitionGranted ? Color.green : Color.orange)) - .frame(width: 10, height: 10) - Text(nativeSpeech.isRecording ? "录音转写中" : "等待语音键触发") - .font(.callout.weight(.semibold)) - Spacer() - } - - HStack(spacing: 10) { - permissionBadge(title: "麦克风", granted: nativeSpeech.microphoneGranted) - permissionBadge(title: "语音转写", granted: nativeSpeech.speechRecognitionGranted) - } - - Text(nativeSpeech.statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - - Text(nativeSpeech.lastPermissionCheckSummary) - .font(.caption) - .foregroundStyle(.secondary) - - if !nativeSpeech.transcriptPreview.isEmpty { - Text("实时识别:\(nativeSpeech.transcriptPreview)") - .font(.caption) - .foregroundStyle(.secondary) - } else if !nativeSpeech.lastCommittedText.isEmpty { - Text("最近写入:\(nativeSpeech.lastCommittedText)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Divider() - + GroupBox("AhaType 状态") { + VStack(alignment: .leading, spacing: 8) { + HStack { Toggle(isOn: Binding( get: { ahaType.isEnabled }, set: { ahaType.setEnabled($0) } )) { - VStack(alignment: .leading, spacing: 2) { - Text("AhaType 云端整理") - .font(.caption.weight(.semibold)) - Text("开启后,识别结果会先经云端大模型整理,再粘贴到当前光标。") - .font(.caption2) - .foregroundStyle(.secondary) - } + Text("AhaType 云端整理") + .font(.callout.weight(.semibold)) } .toggleStyle(.switch) - - Text(ahaType.statusMessage) + Spacer() + Button("刷新") { ahaType.refreshFromDisk() } + .buttonStyle(.borderless) .font(.caption) - .foregroundStyle(.secondary) - - Text(ahaType.lastQuotaSummary) - .font(.caption2) - .foregroundStyle(.tertiary) - - HStack(spacing: 10) { - Button(nativeSpeech.isRecording ? "结束并写入" : "开始录音") { - nativeSpeech.toggleRecordingFromVoiceKey() - } - .buttonStyle(.borderedProminent) - - Button("刷新 AhaType") { - ahaType.refreshFromDisk() - } - .buttonStyle(.bordered) - - Button("重新检查权限") { - nativeSpeech.refreshPermissions(deferredTCCRequery: true) - } - .buttonStyle(.bordered) - - RestartToApplyPermissionsButton() - - if !nativeSpeech.microphoneGranted || !nativeSpeech.speechRecognitionGranted { - Button("打开系统设置") { - openNativeSpeechPrivacySettings() - } - .buttonStyle(.bordered) - } - } } - .padding(.top, 4) + Text(ahaType.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Text(ahaType.lastQuotaSummary) + .font(.caption2) + .foregroundStyle(.tertiary) } + .padding(.top, 4) } + } + .padding(24) + } + .frame(width: 500, height: 620) + } - GroupBox("当前出厂固件直接体验") { - VStack(alignment: .leading, spacing: 8) { - Text("1. 保持 AhaKey Studio 在后台运行。") - Text("2. 在系统设置里为 AhaKey Studio 打开“输入监控”“辅助功能”“麦克风”“语音转写”。若已打开仍显示未授权,先完全退出本应用再开一次。") - Text("3. 确认设备当前在 Mode 0。") - Text("4. 不用同步设备,直接按实体语音键即可开始苹果原生转写;再按一次会结束并把文字写回当前光标。") - } - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) - } - } else { - GroupBox("按键职责") { - VStack(alignment: .leading, spacing: 8) { - Text(key.role.manualText) - .font(.callout) - Text("当前会把快捷键和按键描述一起写入键盘。切换模式时,设备会先显示描述,再回到该模式的 OLED 动图。") - .font(.caption) - .foregroundStyle(.secondary) + // MARK: - Inspector Level 1 Summary + + private func summaryRow(_ title: String, value: String, dot: Color? = nil) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(title) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .leading) + HStack(spacing: 5) { + if let dot { + Circle() + .fill(dot) + .frame(width: 7, height: 7) + .offset(y: -1) } - .frame(maxWidth: .infinity, alignment: .leading) - } + Text(value) + .font(.callout) + .lineLimit(2) } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.06)) + .frame(height: 0.5) + } + } - GroupBox("将写入键盘的按键绑定") { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text(key.displaySummary) - .font(.system(.title3, design: .rounded).weight(.semibold)) - .lineLimit(2) - Spacer() - Text(key.usesMacro ? "固件宏" : "底层 HID") - .font(.caption) - .foregroundStyle(.secondary) - } + @ViewBuilder + private var partSummaryContent: some View { + switch selectedPart { + case .key1: voiceKeySummary + case .key2, .key3, .key4: actionKeySummary + case .oledDisplay: oledSummary + case .lightBar: lightBarSummary + case .toggleSwitch: switchSummary + } + } - Picker("", selection: selectedKeyBindingModeBinding) { - Text("单键 / 组合键").tag(KeyBindingMode.shortcut) - Text("宏").tag(KeyBindingMode.macro) - } - .pickerStyle(.segmented) - .labelsHidden() - .disabled(key.role == .voice && (key.voicePreset ?? .custom) != .custom) + @ViewBuilder + private var voiceKeySummary: some View { + let key = currentSelectedKey + let preset = key.voicePreset ?? .custom + summaryRow("输入方式", value: preset.title) + summaryRow("快捷键", value: key.displaySummary) + if preset.isMacOSNativeFamily { + summaryRow("触发方式", value: "短按 + 长按") + let permCount = [nativeSpeech.microphoneGranted, nativeSpeech.speechRecognitionGranted, + nativeSpeech.siriEnabled, nativeSpeech.dictationEnabled].filter { $0 }.count + summaryRow("转写权限", value: "\(permCount)/4 已授权", + dot: permCount == 4 ? .green : .orange) + } + summaryRow("语音桥", value: voiceRelay.isListening ? "运行中" : "等待权限", + dot: voiceRelay.isListening ? .green : .orange) + summaryRow("按键描述", value: key.description.isEmpty ? "—" : key.description) + } - if key.usesMacro { - macroEditor(for: key) - } else { - ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) - } + @ViewBuilder + private var actionKeySummary: some View { + let key = currentSelectedKey + summaryRow("绑定", value: key.displaySummary) + summaryRow("类型", value: key.usesMacro ? "固件宏(\(key.macro.count) 步)" : "单键 / 组合键") + summaryRow("按键描述", value: key.description.isEmpty ? "—" : key.description) + } - if key.role == .voice { - Text(voicePresetDetail) - .font(.caption) - .foregroundStyle(.secondary) - } + @ViewBuilder + private var oledSummary: some View { + let oled = currentModeDraft.oled + summaryRow("动图", value: oled.localAssetPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "默认动图") + summaryRow("播放速度", value: "\(oled.framesPerSecond) FPS") + summaryRow("状态行", value: oled.statusLine.isEmpty ? "—" : String(oled.statusLine.prefix(32))) + } - if key.role == .voice && (key.voicePreset ?? .custom) != .custom { - Text("语音键预设会固定使用单键绑定;如需录制宏,请先把预设改为“自定义快捷键”。") - .font(.caption) - .foregroundStyle(.orange) - } - } - .padding(.top, 4) - } + @ViewBuilder + private var lightBarSummary: some View { + ForEach(LightBarPreviewState.allCases) { state in + summaryRow(state.title, value: AhaKeyLightBarDraft.hardwareEffect(for: state).title) + } + } + + @ViewBuilder + private var switchSummary: some View { + let agentReady = agentManager.isInstalled && agentManager.isRunning && agentManager.hooksInstalled + summaryRow("当前档位", value: currentSwitchTitle, + dot: currentSwitchTitle == "自动批准" ? .green : .indigo) + summaryRow("Agent", value: agentReady ? "就绪" : "未就绪", + dot: agentReady ? .green : .orange) + summaryRow("作用范围", value: "Claude · Cursor · Codex · Kimi") + } + // MARK: - Inspector Level 2 Detail + + private var keyInspector: some View { + let key = currentSelectedKey + return VStack(alignment: .leading, spacing: 16) { GroupBox("按键描述") { VStack(alignment: .leading, spacing: 8) { TextField("例如 Record / Accept / Reject / Enter", text: selectedKeyDescriptionBinding) .textFieldStyle(.roundedBorder) if currentSelectedKey.description.containsNonASCII { - Text("设备 OLED 只稳定支持 ASCII。中文、emoji 和全角字符会在写入时被自动过滤,避免乱码。") + Text("设备 LCD 只稳定支持 ASCII。中文、emoji 和全角字符会在写入时被自动过滤,避免乱码。") .font(.caption) .foregroundStyle(.orange) } Text("设备实际写入:\(currentSelectedKeySanitizedDescription.isEmpty ? "空白" : currentSelectedKeySanitizedDescription)") .font(.caption) .foregroundStyle(.secondary) - Text("同步到键盘后,短按实体键切换模式时,OLED 会先短暂显示这里的描述,然后回到该模式的动图。") + Text("同步到键盘后,短按实体键切换模式时,LCD 会先短暂显示这里的描述,然后回到该模式的动图。") .font(.caption) .foregroundStyle(.secondary) if selectedMode == .mode0 { @@ -648,16 +836,185 @@ struct AhaKeyStudioView: View { .padding(.top, 4) } - GroupBox("如何使用") { - VStack(alignment: .leading, spacing: 8) { - Text("1. 在左侧点中这个实体按键。") - Text("2. 如果是语音键,先选软件预设;其他键按需选“单键 / 组合键”或“宏”。") - Text("3. 填好按键描述后点击顶部“同步并返回控制”。") - Text("4. 在设备侧短按按键切换模式时,会先看到描述,再回到该模式动图。") + if key.role == .voice { + GroupBox("语音输入方式") { + VStack(alignment: .leading, spacing: 12) { + VoicePresetPicker( + selectedPreset: key.voicePreset ?? .custom, + onSelect: applyVoicePreset + ) + if (key.voicePreset ?? .custom).isMacOSNativeFamily { + Text("只要 AhaKey Studio 在后台运行,Mode 0 出厂语音键发出的 F18 就会被直接接管到苹果原生转写。现在不再依赖系统听写快捷键。") + .font(.caption) + .foregroundStyle(.secondary) + } + Text("语音键的输入方式独立于当前 Mode,在任意 Mode 下都可使用相同的语音输入设置。") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.top, 4) } - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) + } else { + GroupBox("按键职责") { + VStack(alignment: .leading, spacing: 8) { + Text(key.role.manualText) + .font(.callout) + Text("当前会把快捷键和按键描述一起写入键盘。切换模式时,设备会先显示描述,再回到该模式的 LCD 动图。") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // ── 触发方式(短按 / 长按 Tab)────────────────────────────── + GroupBox("触发方式") { + VStack(alignment: .leading, spacing: 12) { + Picker("", selection: $selectedTriggerTab) { + Text("短按").tag(0) + Text("长按").tag(1) + } + .pickerStyle(.segmented) + .labelsHidden() + + Divider() + + if key.role == .voice { + // ── 语音键触发方式 ────────────────────────────── + if selectedTriggerTab == 0 { + VStack(alignment: .leading, spacing: 10) { + Label("按一下开始,再按一下结束", systemImage: "hand.tap.fill") + .font(.callout.weight(.semibold)) + Text("录音结束后根据下方开关决定是否经 AhaType 整理,再写入光标。") + .font(.caption) + .foregroundStyle(.secondary) + Toggle(isOn: $nativeSpeech.shortPressAhaTypeEnabled) { + HStack(spacing: 6) { + Text("使用 AhaType 整理") + .font(.callout) + if !ahaType.isEnabled { + Text("(AhaType 总开关已关闭)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(.switch) + .disabled(!ahaType.isEnabled) + + Divider() + + // 绑定摘要(短按 = 语音键 HID 绑定) + HStack { + Text(key.displaySummary) + .font(.system(.callout, design: .rounded).weight(.semibold)) + .lineLimit(1) + Spacer() + Text(key.usesMacro ? "固件宏" : "底层 HID") + .font(.caption) + .foregroundStyle(.secondary) + } + Picker("", selection: selectedKeyBindingModeBinding) { + Text("单键 / 组合键").tag(KeyBindingMode.shortcut) + Text("宏").tag(KeyBindingMode.macro) + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled((key.voicePreset ?? .custom) != .custom) + if key.usesMacro { + macroEditor(for: key) + } else { + ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) + } + Text(voicePresetDetail) + .font(.caption) + .foregroundStyle(.secondary) + if (key.voicePreset ?? .custom) != .custom { + Text("语音键预设会固定使用单键绑定;如需录制宏,请先把预设改为自定义快捷键。") + .font(.caption) + .foregroundStyle(.orange) + } + } + } else { + // 长按 Tab(语音键)— 始终开启,仅配置 AhaType 与阈值 + VStack(alignment: .leading, spacing: 10) { + Label("按住录音,松手即发送", systemImage: "hand.draw.fill") + .font(.callout.weight(.semibold)) + Text("按住键盘录音键不松手开始录音,松手后直接将 ASR 结果写入,响应更快。") + .font(.caption) + .foregroundStyle(.secondary) + Toggle(isOn: $nativeSpeech.longPressAhaTypeEnabled) { + HStack(spacing: 6) { + Text("使用 AhaType 整理") + .font(.callout) + if !ahaType.isEnabled { + Text("(AhaType 总开关已关闭)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(.switch) + .disabled(!ahaType.isEnabled) + HStack(spacing: 10) { + Text("触发阈值") + .font(.callout) + Slider( + value: Binding( + get: { Double(nativeSpeech.longPressThresholdMs) }, + set: { nativeSpeech.longPressThresholdMs = Int($0) } + ), + in: 200...1000, + step: 50 + ) + Text("\(nativeSpeech.longPressThresholdMs) ms") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 58, alignment: .trailing) + } + } + } + } else { + // ── 普通键触发方式 ────────────────────────────── + if selectedTriggerTab == 0 { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(key.displaySummary) + .font(.system(.callout, design: .rounded).weight(.semibold)) + .lineLimit(2) + Spacer() + Text(key.usesMacro ? "固件宏" : "底层 HID") + .font(.caption) + .foregroundStyle(.secondary) + } + Picker("", selection: selectedKeyBindingModeBinding) { + Text("单键 / 组合键").tag(KeyBindingMode.shortcut) + Text("宏").tag(KeyBindingMode.macro) + } + .pickerStyle(.segmented) + .labelsHidden() + if key.usesMacro { + macroEditor(for: key) + } else { + ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) + } + } + } else { + VStack(alignment: .leading, spacing: 8) { + Label("需要固件 v2+ 支持", systemImage: "exclamationmark.triangle") + .font(.callout.weight(.semibold)) + .foregroundStyle(.orange) + Text("长按绑定不同快捷键需固件升级后生效,当前仅短按绑定会写入设备。") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.top, 4) } + .onChange(of: selectedPart) { _ in selectedTriggerTab = 0 } + } } @@ -680,7 +1037,7 @@ struct AhaKeyStudioView: View { } if key.macro.isEmpty { - Text("空宏。点下方“添加步骤”开始录制。") + Text("空宏。点下方「添加步骤」开始录制。") .font(.caption) .foregroundStyle(.secondary) } else { @@ -862,10 +1219,50 @@ struct AhaKeyStudioView: View { private var oledInspector: some View { VStack(alignment: .leading, spacing: 16) { - GroupBox("当前模式的 OLED 动图") { - VStack(alignment: .leading, spacing: 14) { + GroupBox("任务状态动图") { + VStack(alignment: .leading, spacing: 12) { + Picker("套图", selection: $selectedOLEDGIFSet) { + Text("套图 A").tag(0) + Text("套图 B").tag(1) + } + .pickerStyle(.segmented) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], + spacing: 8 + ) { + ForEach(AhaKeyTaskDisplayState.allCases) { state in + let isSelected = state == selectedOLEDTaskState + let asset = currentModeDraft.oled.taskAsset(set: selectedOLEDGIFSet, state: state) + Button { + selectedOLEDTaskState = state + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(state.title) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text(asset.localAssetPath == nil ? "未配置" : "已选 GIF") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(isSelected ? Color.accentColor.opacity(0.14) : Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(isSelected ? Color.accentColor : Color.black.opacity(0.08), lineWidth: isSelected ? 1.5 : 1) + ) + } + .buttonStyle(.plain) + } + } + ZStack { - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: 8) .fill(Color.black.opacity(0.9)) .frame(height: 140) @@ -874,68 +1271,48 @@ struct AhaKeyStudioView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(height: 112) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 6)) } else { - VStack(spacing: 10) { - Image(systemName: "photo.artframe") - .font(.system(size: 28)) - .foregroundStyle(.white.opacity(0.8)) - Text("当前仅支持动图") - .foregroundStyle(.white.opacity(0.85)) - Text("文字、token、模型状态显示开发中") + VStack(spacing: 6) { + Image(systemName: "play.tv") + .font(.system(size: 24)) + .foregroundStyle(.white.opacity(0.75)) + Text("未配置 GIF") .font(.caption) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(.white.opacity(0.7)) } } } - HStack(spacing: 10) { - Button("选择动图") { + HStack(spacing: 8) { + Button("选择 GIF") { selectOLEDGIF() } .buttonStyle(.bordered) - Button("预览动图") { + Button("预览") { showsOLEDPlaybackPreview = true } .buttonStyle(.bordered) - .disabled(currentModeDraft.oled.localAssetPath == nil) - - Button(bleManager.isUploadingOLED ? "上传中…" : "上传到 \(selectedMode.title)") { - uploadCurrentOLEDToDevice() - } - .buttonStyle(.borderedProminent) - .disabled(!bleManager.isConnected || bleManager.isUploadingOLED || currentModeDraft.oled.localAssetPath == nil) + .disabled(currentOLEDTaskAsset.localAssetPath == nil) Button("清空") { clearCurrentOLED() } .buttonStyle(.bordered) - Spacer() + Spacer(minLength: 0) - Text("当前目标:\(selectedMode.title)") + Text("\(selectedOLEDTaskState.title)") .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } Stepper(value: oledFramesPerSecondBinding, in: 5 ... 20) { - Text("播放速度 \(currentModeDraft.oled.framesPerSecond) FPS") + Text("播放速度 \(currentOLEDTaskAsset.framesPerSecond) FPS") } - if let progress = bleManager.oledUploadProgress, bleManager.isUploadingOLED { - VStack(alignment: .leading, spacing: 6) { - ProgressView(value: progress.fractionCompleted) - Text("已写入 \(progress.completedFrames)/\(progress.totalFrames) 帧,分块 \(progress.completedChunks)/\(progress.totalChunks)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Text("硬性限制:GIF 源文件 ≤ 2 MB。建议 5–20 FPS、最多 74 帧;将自动缩放到 160×80(RGB565)。") - .font(.caption) - .foregroundStyle(.secondary) - Text(currentModeDraft.oled.statusLine) .font(.caption) .foregroundStyle(.secondary) @@ -943,13 +1320,38 @@ struct AhaKeyStudioView: View { .padding(.top, 4) } - GroupBox("显示逻辑") { + GroupBox("套图切换") { VStack(alignment: .leading, spacing: 8) { - Text("切换到当前模式时,OLED 会先显示该模式的按键描述,约 1 秒后回到该模式动图。") - Text("后续会继续增加文字状态、token 用量、模型环境等信息显示能力。") + let deviceSet = bleManager.activeTaskPictureSets[selectedMode.rawValue] + HStack { + Text(deviceSet == nil ? "设备套图未读取" : "设备当前:套图 \((deviceSet ?? 0) + 1)") + .font(.callout.weight(.medium)) + Spacer() + Button { + Task { + do { + let next = ((deviceSet ?? 0) + 1) % 2 + _ = try await bleManager.setActiveTaskPictureSet( + mode: UInt8(selectedMode.rawValue), + set: UInt8(next) + ) + syncStatusMessage = "已切换 \(selectedMode.title) 到套图 \(next + 1)。" + } catch { + syncStatusMessage = "切换设备套图失败:\(error.localizedDescription)" + } + } + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + .buttonStyle(.bordered) + .help("切换设备当前 GIF 套图") + .disabled(!bleManager.isConnected || isSyncing) + } + Text("电源键双击同样切换当前 Mode 的套图,并在断电后保留。") + .font(.caption) + .foregroundStyle(.secondary) } - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) } } } @@ -962,7 +1364,7 @@ struct AhaKeyStudioView: View { VStack(alignment: .leading, spacing: 6) { Text("出厂灯条映射(只读)") .font(.subheadline.weight(.semibold)) - Text("灯条由键盘固件根据 Hook 上报的 IDE 状态点亮,本软件不能改写。下表为各业务场景对应的典型 Hook 状态与出厂灯效说明;画布与「预览到设备」均按此表展示。") + Text("灯条由键盘固件根据 Hook 上报的 IDE 状态点亮,本软件不能改写。下表展示的是各业务场景通常对应的 Hook 状态与出厂灯效。下方画布只预览到虚拟键盘;写入设备统一使用底部操作。") .font(.caption) .foregroundStyle(.secondary) } @@ -1014,25 +1416,17 @@ struct AhaKeyStudioView: View { HStack { Text(lightBarPreview.title) .font(.system(.title3, design: .rounded).weight(.semibold)) - Spacer() - Button("预览到设备") { - previewCurrentLightEffectOnDevice() - } - .buttonStyle(.borderedProminent) - .disabled(!bleManager.isConnected || currentDevicePreviewIDEState == nil) } Text("当前画布预览:\(currentLightEffect.title)") .font(.callout) .foregroundStyle(.secondary) - Text(currentLightPreviewHint) + Text("当前业务状态对应 Hook:\(lightBarPreview.ideState.label)") .font(.caption) - .foregroundStyle(currentDevicePreviewIDEState == nil ? .orange : .secondary) - if bleManager.isConnected && bleManager.workMode != 0 { - Text("ℹ️ 出厂固件只在物理 Mode 0(1、2 灯)下完整映射了 state → 灯效;当前键盘在 Mode \(bleManager.workMode),点预览多半看不到效果,把拨杆切到 Mode 0 再试。") - .font(.caption) - .foregroundStyle(.orange) - } + .foregroundStyle(.tertiary) + Text("预览仅更新左侧虚拟键盘画布;需要写入设备时请使用底部通用写入按钮。") + .font(.caption) + .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 10)], spacing: 10) { ForEach(LightBarPreviewState.allCases) { state in @@ -1067,7 +1461,7 @@ struct AhaKeyStudioView: View { GroupBox("说明") { VStack(alignment: .leading, spacing: 8) { - Text("若需让某套灯效出现,请用下方「预览到设备」向固件发送一次对应可达的 IDE 状态试灯;或在 Agent 连上键盘后,通过实际触发 Hook 观察。") + Text("这里的灯效预览只作用于虚拟键盘。写入设备后,实际灯效由 Hook 状态和键盘固件映射共同决定。") } .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) @@ -1087,7 +1481,7 @@ struct AhaKeyStudioView: View { .fill(currentSwitchTitle == "自动批准" ? Color.green : Color.indigo) .frame(width: 10, height: 10) } - Text("拨杆是物理档位,不是按下瞬态。0 档显示“自动批准”,1 档显示“手动批准”。这里只读取键盘上报的位置,不模拟物理拨动。") + Text("拨杆是物理档位,不是按下瞬态。0 档显示「自动批准」,1 档显示「手动批准」。这里只读取键盘上报的位置,不模拟物理拨动。") .font(.callout) .foregroundStyle(.secondary) } @@ -1102,7 +1496,7 @@ struct AhaKeyStudioView: View { Label("自动批准依赖 Agent 与 Hook,且须蓝牙由 Agent 占用", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) .font(.callout.weight(.semibold)) - Text("Claude:PermissionRequest 时返回 allow。Cursor:preToolUse(以及你自建的 beforeShell beforeMCP 钩子)stdout 会返回 permission=allow。Codex:PermissionRequest 自动档返回 allow,手动档交回 Codex 确认。若 agent 未连键盘或本 App 占着 BLE,会退成交回确认。须 Agent 在跑、Hook 已装。涉及 shell/删文件等高危时建议用手动档。需要逐条确认时请把拨杆切到“手动批准”。") + Text("Claude:PermissionRequest allow。Cursor:preToolUse 等与 cli-config。Codex:PermissionRequest allow。Kimi:安装过 AhaKey Kimi Hooks 后,**拨杆会直接接管当前会话的自动批准**;若刚装完或刚升级 kimi-cli,请**完全关闭并重新打开一次 kimi**。钩子 stdout 只对 **`permissionDecision: deny`** 有特殊拦截语义。Agent 须在跑且蓝牙由其占用。") .font(.callout) .foregroundStyle(.secondary) } @@ -1111,8 +1505,10 @@ struct AhaKeyStudioView: View { GroupBox("如何理解这个部件") { VStack(alignment: .leading, spacing: 8) { - Text("自动批准:Claude/Codex 用 PermissionRequest;Cursor 用 preToolUse 等并返回 JSON permission。均需 Agent + Hooks,且设备蓝牙由 Agent 连接。") - Text("手动批准:会交回用户/终端确认。若 Cursor 或 Codex 仍弹窗,请看 diagnostics 里的 ide 与 diagnostic 字段。") + Text("拨杆对 Claude / Cursor / Codex / Kimi **同时生效**,与键盘当前所在 Mode 无关。Agent 后台同时监听所有 IDE 的 Hook,拨杆拨动后四个 IDE 的批准行为立即切换。") + Divider() + Text("自动批准:**Claude / Codex PermissionRequest**,**Cursor preToolUse**(含 cli-config)。**Kimi**:安装过 AhaKey Kimi Hooks 后,拨杆会直接接管**当前会话**的自动批准;刚装完或刚升级 kimi-cli 时,重开一次 kimi 即可。") + Text("手动批准:会交回用户/终端确认。若 Cursor、Codex 或 Kimi 仍弹窗,请看 diagnostics 里的 ide 与 diagnostic 字段。") Text("若仍出现手动:在「设备信息」里打开「工具批准诊断」查看 permission-request.log(含 ide、hookEvent、diagnostic 等)。") } .font(.callout) @@ -1131,7 +1527,7 @@ struct AhaKeyStudioView: View { Image(systemName: agentReady ? "checkmark.seal.fill" : "exclamationmark.circle.fill") .foregroundStyle(agentReady ? .green : .orange) Text(agentReady - ? "Agent 与 Hook 已就位时,拨杆会参与 Claude/Codex 的 PermissionRequest 与 Cursor 的 preToolUse 等批准链。" + ? "Agent 就绪时 Claude/Cursor/Codex 可随拨杆走批准。**Kimi**:安装过 AhaKey Kimi Hooks 后,拨杆会直接接管当前会话;若刚装完或刚升级 kimi-cli,重开一次 kimi 即可。" : "拨杆在 IDE 中生效需先安装 Agent 与 Hook,并把蓝牙交给 Agent;否则仅为状态显示。") .font(.callout) } @@ -1140,7 +1536,7 @@ struct AhaKeyStudioView: View { VStack(alignment: .leading, spacing: 4) { agentChecklistRow(label: "LaunchAgent 已安装", ok: agentManager.isInstalled) agentChecklistRow(label: "Agent 已连接蓝牙", ok: agentManager.isRunning) - agentChecklistRow(label: "Claude / Cursor / Codex Hook 已配置", ok: agentManager.hooksInstalled) + agentChecklistRow(label: "Claude / Cursor / Codex / Kimi Hook 已配置", ok: agentManager.hooksInstalled) } .padding(.leading, 4) @@ -1210,6 +1606,34 @@ struct AhaKeyStudioView: View { .font(.callout) .foregroundStyle(.secondary) } + Button("权限诊断") { + showsDiagnostics = true + } + .buttonStyle(.borderless) + .help("查看语音权限状态与诊断日志") + .sheet(isPresented: $showsDiagnostics) { + diagnosticsSheet + } + + Button("新手引导") { + voiceRelay.showsPermissionOnboarding = false + unifiedOnboardingCompleted = false + } + .buttonStyle(.borderless) + .help("重新打开 AhaKey Studio 新手引导") + + Button("帮助中心") { + showsHelpCenter = true + } + .buttonStyle(.borderless) + .help("打开内嵌的帮助中心") + .sheet(isPresented: $showsHelpCenter) { + HelpCenterSheet( + studioDraft: studioDraft, + selectedMode: selectedMode, + bleManager: bleManager + ) + } } .padding(.horizontal, 24) .padding(.vertical, 12) @@ -1230,16 +1654,82 @@ struct AhaKeyStudioView: View { } private var currentSwitchTitle: String { - bleManager.switchState == 0 ? "自动批准" : "手动批准" + // 用统一的 liveKeyboardSwitchState:主 App 自占 BLE 时是 bleManager.switchState, + // 否则取 agent 共享文件里的值(含用户拨杆覆盖)。否则点了画布拨杆, + // 因为 bleManager.switchState 一直是初始 0,画布会一直停留在「自动批准」。 + liveKeyboardSwitchState == 0 ? "自动批准" : "手动批准" + } + + /// 取键盘当前实时状态 (lightMode/switchState/workMode): + /// - 主 App 已自连 BLE(编辑配置时)→ 用主 App 自己的 BLE 读数 + /// - 主 App 未连,但 agent 仍占用 BLE 在写共享文件 → 读 agent 发布的缓存 + /// - 两者都没有 → nil(画布回落到模拟) + private var liveKeyboardLightMode: Int? { + if bleManager.isConnected { return bleManager.lightMode } + return bleManager.agentLightMode + } + private var liveKeyboardSwitchState: Int { + // 用户刚点完虚拟拨杆但 agent / BLE 还没回报新值时,优先用乐观值,按下立刻可见 + if let opt = bleManager.optimisticSwitchOverride { return opt } + if bleManager.isConnected { return bleManager.switchState } + return bleManager.agentSwitchState ?? 1 + } + private var liveKeyboardWorkMode: Int? { + if bleManager.isConnected { return bleManager.workMode } + return bleManager.agentWorkMode + } + private var liveCanvasLightMode: Int? { + guard let workMode = liveKeyboardWorkMode, selectedMode.rawValue == workMode else { return nil } + return liveKeyboardLightMode + } + private var liveCanvasIDEStateValue: Int? { + guard let workMode = liveKeyboardWorkMode, selectedMode.rawValue == workMode else { return nil } + return bleManager.liveIDEStateValue + } + private var liveCanvasSwitchState: Int { liveKeyboardSwitchState } + + private func cycleModeForward() { + let all = AhaKeyModeSlot.allCases + let next = all[(all.firstIndex(of: selectedMode)! + 1) % all.count] + selectedMode = next + } + + /// 用户点击虚拟拨杆:在当前 effective switchState 基础上 0↔1 翻转, + /// 同时走两条路保证最大兼容性: + /// - 主 App 已自占 BLE → 直发 0x91(需 patch 固件支持,老固件会被忽略) + /// - 否则委托 agent socket 设置 override(hook 批准逻辑立刻生效;agent 占 BLE 时 + /// 也会替我们发 0x91 给键盘)。 + private func toggleVirtualSwitch() { + let current = liveKeyboardSwitchState + let next: UInt8 = current == 0 ? 1 : 0 + // 1) 立刻设乐观值 → 画布按钮即时翻转 + bleManager.applyOptimisticSwitchOverride(next) + // 2) 主 App 自占 BLE 时直接发 0x91(需固件已 patch 0x91) + if bleManager.isConnected { + bleManager.setSwitchStateViaBLE(next) + } + // 3) 让 agent 设置软覆盖 + 替我们走 BLE + AgentManager.shared.sendSwitchOverride(next) + // 4) 短延迟后强制重读共享文件,确认真实值已对齐(agent 写文件通常 < 100ms) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak bleManager] in + bleManager?.refreshAgentStateFromFileNow() + } + syncStatusMessage = next == 0 + ? "虚拟拨杆 → 自动批准(hook 自动放行;灯效若不变需先刷支持 0x91 的固件)" + : "虚拟拨杆 → 手动批准(hook 交回终端确认)" + } + + private var currentOLEDTaskAsset: AhaKeyTaskGIFAssetDraft { + currentModeDraft.oled.taskAsset(set: selectedOLEDGIFSet, state: selectedOLEDTaskState) } private var currentOLEDPreviewImage: NSImage? { - guard let path = currentModeDraft.oled.localAssetPath else { return nil } + guard let path = currentOLEDTaskAsset.localAssetPath else { return nil } return NSImage(contentsOfFile: path) } private var currentOLEDAssetURL: URL? { - guard let path = currentModeDraft.oled.localAssetPath else { return nil } + guard let path = currentOLEDTaskAsset.localAssetPath else { return nil } return URL(fileURLWithPath: path) } @@ -1247,14 +1737,6 @@ struct AhaKeyStudioView: View { AhaKeyLightBarDraft.hardwareEffect(for: lightBarPreview) } - private var currentDevicePreviewIDEState: IDEState? { - currentLightEffect.previewIDEState(forSwitchState: bleManager.switchState) - } - - private var currentLightPreviewHint: String { - currentLightEffect.previewHint(forSwitchState: bleManager.switchState) - } - private var isEditingConfiguration: Bool { agentManager.bluetoothConnectionOwner == .ahaKeyStudio } @@ -1318,7 +1800,7 @@ struct AhaKeyStudioView: View { } return "没有未同步改动,直接把蓝牙交还给 Agent。" } - return "临时由 AhaKey Studio 接管蓝牙,用于改键、OLED、同步和本机灯效测试。" + return "临时由 AhaKey Studio 接管蓝牙,用于改键、LCD、同步和本机灯效测试。" } private var voicePresetDetail: String { @@ -1371,10 +1853,12 @@ struct AhaKeyStudioView: View { private var oledFramesPerSecondBinding: Binding { Binding( - get: { currentModeDraft.oled.framesPerSecond }, + get: { currentOLEDTaskAsset.framesPerSecond }, set: { newValue in updateCurrentMode { mode in - mode.oled.framesPerSecond = min(20, max(5, newValue)) + var asset = mode.oled.taskAsset(set: selectedOLEDGIFSet, state: selectedOLEDTaskState) + asset.framesPerSecond = min(20, max(5, newValue)) + mode.oled.updateTaskAsset(set: selectedOLEDGIFSet, asset: asset) } } ) @@ -1407,8 +1891,10 @@ struct AhaKeyStudioView: View { private func clearCurrentOLED() { updateCurrentMode { mode in - mode.oled.localAssetPath = nil - mode.oled.statusLine = AhaKeyOLEDDraft.default(for: selectedMode).statusLine + var asset = mode.oled.taskAsset(set: selectedOLEDGIFSet, state: selectedOLEDTaskState) + asset.localAssetPath = nil + mode.oled.updateTaskAsset(set: selectedOLEDGIFSet, asset: asset) + mode.oled.statusLine = "已清空套图 \(selectedOLEDGIFSet + 1) · \(selectedOLEDTaskState.title),写入设备后生效。" } } @@ -1550,10 +2036,12 @@ struct AhaKeyStudioView: View { } let frameCount = OLEDFrameEncoder.frameCount(at: url) updateCurrentMode { mode in - mode.oled.localAssetPath = url.path - mode.oled.statusLine = "已选 \(max(frameCount, 1)) 帧 GIF 预览;切换模式时会先显示描述,再回到当前模式动图。" + var asset = mode.oled.taskAsset(set: selectedOLEDGIFSet, state: selectedOLEDTaskState) + asset.localAssetPath = url.path + mode.oled.updateTaskAsset(set: selectedOLEDGIFSet, asset: asset) + mode.oled.statusLine = "已选 \(max(frameCount, 1)) 帧 GIF:套图 \(selectedOLEDGIFSet + 1) · \(selectedOLEDTaskState.title)。" } - syncStatusMessage = "已更新 \(selectedMode.title) 的 OLED 预览,连接后可直接上传到设备。" + syncStatusMessage = "已更新 \(selectedMode.title) 套图 \(selectedOLEDGIFSet + 1) 的 \(selectedOLEDTaskState.title) 预览;写入设备请使用底部通用按钮。" } } @@ -1588,7 +2076,7 @@ struct AhaKeyStudioView: View { return } - if bleManager.isConnected { + if bleManager.isConnected && bleManager.commandCharReady { syncAllModesToDevice(returnToKeyboardControlWhenDone: true) } else { syncStatusMessage = "设备连接中,连接成功后将自动同步并返回控制模式…" @@ -1597,17 +2085,18 @@ struct AhaKeyStudioView: View { } } - // 轮询等待 BLE 连接(最多 10 秒),连接后自动同步并返回键盘控制。 + // 轮询等待 BLE 连接且命令通道就绪(最多 10 秒),连接后自动同步并返回键盘控制。 private func waitForConnectionThenSync() { Task { @MainActor in for _ in 0..<20 { try? await Task.sleep(nanoseconds: 500_000_000) - if bleManager.isConnected { + if bleManager.isConnected && bleManager.commandCharReady { syncAllModesToDevice(returnToKeyboardControlWhenDone: true) return } } - syncStatusMessage = "连接超时,请确认键盘已开启并靠近设备,再次点击「保存配置」重试。" + syncStatusMessage = "连接超时,本次未写入键盘;已释放蓝牙给 Agent,可再次进入编辑后重试保存。" + returnToKeyboardControl() } } @@ -1645,86 +2134,280 @@ struct AhaKeyStudioView: View { } private func syncAllModesToDevice(returnToKeyboardControlWhenDone: Bool = false) { - guard bleManager.isConnected else { - syncStatusMessage = "设备未连接,当前只保存本地草稿。" + syncModesToDevice( + AhaKeyModeSlot.allCases, + returnToKeyboardControlWhenDone: returnToKeyboardControlWhenDone + ) + } + + private func resendCurrentModeToDevice() { + syncModesToDevice([selectedMode]) + } + + private func syncModesToDevice( + _ modes: [AhaKeyModeSlot], + returnToKeyboardControlWhenDone: Bool = false + ) { + guard bleManager.isConnected && bleManager.commandCharReady else { + syncStatusMessage = "设备未连接或命令通道未就绪,当前只保存本地草稿。" return } applyCursorRejectMacroSelfHealIfNeeded() - var commands = commandsForModes(AhaKeyModeSlot.allCases) - commands.append((data: AhaKeyCommand.saveConfig(), label: "保存全部配置到设备")) + let legacyGIFBootstrapModes = modes.filter { + studioDraft.draft(for: $0).oled.taskGIFSchemaVersion < 1 + } + let taskChanges = taskGIFChanges(for: modes) + var commands = commandsForModes(modes) + commands.append((data: AhaKeyCommand.saveConfig(), label: "保存设备配置")) + let returnAgent = returnToKeyboardControlWhenDone - let total = commands.count isSyncing = true - syncStatusMessage = "正在写入设备(约 \(total) 条,全部发完后再保存/交还 Agent)…" - let returnAgent = returnToKeyboardControlWhenDone - bleManager.writeCommandsSequentially(commands) { - Task { @MainActor in - // 队列与 50ms 间隔已保证顺序;略等再交还蓝牙,避免固件尚未处理完最后帧。 - try? await Task.sleep(nanoseconds: UInt64(250) * 1_000_000) - self.lastSyncedDraft = self.studioDraft - self.lastSyncDate = Date() - self.isSyncing = false - self.syncStatusMessage = "已全部写入设备并保存。" + let gifWorkCount = taskChanges.count + legacyGIFBootstrapModes.count + syncStatusMessage = gifWorkCount == 0 + ? "正在写入设备(\(commands.count) 条配置)…" + : "正在写入设备(\(gifWorkCount) 个任务动图 + \(commands.count) 条配置)…" + + Task { + do { + try await bootstrapLegacyGIFsIfNeeded(for: legacyGIFBootstrapModes) + try await syncTaskGIFChanges(taskChanges) + bleManager.writeCommandsSequentially(commands) { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(250) * 1_000_000) + self.markModesSynced(modes) + self.lastSyncDate = Date() + self.isSyncing = false + self.syncStatusMessage = "已写入设备并保存。" + if returnAgent { + self.returnToKeyboardControl() + } + } + } + } catch { + isSyncing = false + syncStatusMessage = "任务动图写入失败:\(error.localizedDescription)" if returnAgent { - self.returnToKeyboardControl() + returnToKeyboardControl() } } } } - private func resendCurrentModeToDevice() { - guard bleManager.isConnected else { - syncStatusMessage = "设备未连接,当前只保存本地草稿。" - return + private func markModesSynced(_ modes: [AhaKeyModeSlot]) { + var current = studioDraft + var baseline = lastSyncedDraft + for mode in modes { + var modeDraft = current.draft(for: mode) + modeDraft.oled.taskGIFSchemaVersion = 1 + current.updateMode(modeDraft) + baseline.updateMode(modeDraft) } + studioDraft = current + lastSyncedDraft = baseline + } - applyCursorRejectMacroSelfHealIfNeeded() - var commands = commandsForModes([selectedMode]) - commands.append((data: AhaKeyCommand.saveConfig(), label: "保存 \(selectedMode.title) 当前配置")) + private func bootstrapLegacyGIFsIfNeeded(for modes: [AhaKeyModeSlot]) async throws { + for mode in modes { + let oled = studioDraft.draft(for: mode).oled + guard let assetPath = oled.localAssetPath else { continue } + + let assetURL = URL(fileURLWithPath: assetPath) + try OLEDFrameEncoder.validateGIFSourceFileSize(at: assetURL) + let frames = try OLEDFrameEncoder.frames(fromGIFAt: assetURL) + let startIndex = try await resolveOLEDUploadStartIndex(for: mode, frameCount: frames.count) + try await bleManager.uploadOLEDFrames( + frames, + fps: oled.framesPerSecond, + mode: UInt8(mode.rawValue), + startIndex: UInt16(startIndex) + ) + } + } - isSyncing = true - syncStatusMessage = "正在写入 \(selectedMode.title)…" - bleManager.writeCommandsSequentially(commands) { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(150) * 1_000_000) - self.lastSyncDate = Date() - self.isSyncing = false - self.syncStatusMessage = "已重新发送 \(self.selectedMode.title) 当前模式。" + private func taskGIFChanges(for modes: [AhaKeyModeSlot]) -> [TaskGIFChange] { + var changes: [TaskGIFChange] = [] + for mode in modes { + let currentOLED = studioDraft.draft(for: mode).oled + let baselineOLED = lastSyncedDraft.draft(for: mode).oled + for set in 0 ..< 2 { + for state in AhaKeyTaskDisplayState.allCases { + let current = currentOLED.taskAsset(set: set, state: state) + let baseline = baselineOLED.taskAsset(set: set, state: state) + if current != baseline { + changes.append(TaskGIFChange( + slot: KeyboardTaskPictureSlot( + mode: mode.rawValue, + set: set, + state: state.rawValue + ), + asset: current + )) + } + } } } + return changes } - /// Cursor 档「取消键」若仍为默认 ⌫ 却残留宏,同步会走 0x74 而非单键。清掉误残留宏并与迁移逻辑一致。 - private func applyCursorRejectMacroSelfHealIfNeeded() { - var next = studioDraft - var m1 = next.draft(for: .mode1) - var reject = m1.key(for: .reject) - let defaultR = AhaKeyModeDraft.default(for: .mode1).key(for: .reject) - guard !reject.macro.isEmpty, reject.shortcut == defaultR.shortcut else { return } - reject.macro = [] - m1.updateKey(reject) - next.updateMode(m1) - studioDraft = next + private func syncTaskGIFChanges(_ changes: [TaskGIFChange]) async throws { + guard !changes.isEmpty else { return } + + var deviceStates = try await bleManager.readAllTaskPictureStates() + for change in changes { + let mode = UInt8(change.slot.mode) + let set = UInt8(change.slot.set) + let state = UInt8(change.slot.state) + + guard let path = change.asset.localAssetPath else { + try await bleManager.clearTaskPicture(mode: mode, set: set, state: state) + if let index = deviceStates.firstIndex(where: { + $0.mode == change.slot.mode && $0.set == change.slot.set && $0.state == change.slot.state + }) { + let existing = deviceStates[index] + deviceStates[index] = AhaKeyTaskPictureState( + mode: existing.mode, + set: existing.set, + state: existing.state, + startIndex: 0, + picLength: 0, + frameInterval: 0, + allModeMaxPic: existing.allModeMaxPic, + activeSet: existing.activeSet + ) + } + continue + } + + let assetURL = URL(fileURLWithPath: path) + try OLEDFrameEncoder.validateGIFSourceFileSize(at: assetURL) + let frames = try OLEDFrameEncoder.frames(fromGIFAt: assetURL) + let startIndex = try resolveTaskOLEDUploadStartIndex( + for: change.slot, + frameCount: frames.count, + states: deviceStates + ) + try await bleManager.uploadTaskOLEDFrames( + frames, + fps: change.asset.framesPerSecond, + mode: mode, + set: set, + state: state, + startIndex: UInt16(startIndex) + ) + + let interval = max(1, 1000 / max(1, change.asset.framesPerSecond)) + let newState = AhaKeyTaskPictureState( + mode: change.slot.mode, + set: change.slot.set, + state: change.slot.state, + startIndex: startIndex, + picLength: frames.count, + frameInterval: interval, + allModeMaxPic: deviceStates.first?.allModeMaxPic ?? AhaKeyCommand.oledMaxFrames, + activeSet: bleManager.activeTaskPictureSets[change.slot.mode] ?? 0 + ) + if let index = deviceStates.firstIndex(where: { + $0.mode == change.slot.mode && $0.set == change.slot.set && $0.state == change.slot.state + }) { + deviceStates[index] = newState + } else { + deviceStates.append(newState) + } + } } - private func commandsForModes(_ modes: [AhaKeyModeSlot]) -> [(data: Data, label: String)] { - var commands: [(data: Data, label: String)] = [] + private func resolveTaskOLEDUploadStartIndex( + for target: KeyboardTaskPictureSlot, + frameCount: Int, + states: [AhaKeyTaskPictureState] + ) throws -> Int { + let maxCapacity = states.first?.allModeMaxPic ?? AhaKeyCommand.oledMaxFrames + guard frameCount <= maxCapacity else { + throw OLEDUploadError.noAvailablePictureSlot(needed: frameCount, max: maxCapacity) + } - for mode in modes { - let draft = studioDraft.draft(for: mode) - for role in AhaKeyKeyRole.allCases { - let key = draft.key(for: role) - let keyIndex = UInt8(role.rawValue) - let modeByte = UInt8(mode.rawValue) + let targetState = states.first { + $0.mode == target.mode && $0.set == target.set && $0.state == target.state + } + let occupiedRegions = mergedPictureRegions( + states + .filter { + !($0.mode == target.mode && $0.set == target.set && $0.state == target.state) + && $0.picLength > 0 + } + .map { (start: $0.startIndex, end: $0.startIndex + $0.picLength) } + ) - if key.usesMacro { - // 固件对 0x73 快捷键、0x74 宏是分层存储的;从「快捷键」改「宏」时须先清掉旧快捷键,否则会残留。 - commands.append(( - data: AhaKeyCommand.setKeyMapping( - mode: modeByte, - keyIndex: keyIndex, - hidCodes: [] + if let targetState, + targetState.picLength > 0, + canPlacePictureRange( + start: targetState.startIndex, + count: frameCount, + occupiedRegions: occupiedRegions, + maxCapacity: maxCapacity + ) + { + return targetState.startIndex + } + + if let freeStart = findFreePictureSpace( + occupiedRegions: occupiedRegions, + neededCount: frameCount, + maxCapacity: maxCapacity + ) { + return freeStart + } + + throw OLEDUploadError.noAvailablePictureSlot(needed: frameCount, max: maxCapacity) + } + + private func mergedPictureRegions( + _ regions: [(start: Int, end: Int)] + ) -> [(start: Int, end: Int)] { + let sorted = regions.sorted { $0.start < $1.start } + var merged: [(start: Int, end: Int)] = [] + for region in sorted { + guard region.end > region.start else { continue } + if let last = merged.last, region.start <= last.end { + merged[merged.count - 1] = (start: last.start, end: max(last.end, region.end)) + } else { + merged.append(region) + } + } + return merged + } + + /// Cursor 档「取消键」若仍为默认 ⌫ 却残留宏,同步会走 0x74 而非单键。清掉误残留宏并与迁移逻辑一致。 + private func applyCursorRejectMacroSelfHealIfNeeded() { + var next = studioDraft + var m1 = next.draft(for: .mode1) + var reject = m1.key(for: .reject) + let defaultR = AhaKeyModeDraft.default(for: .mode1).key(for: .reject) + guard !reject.macro.isEmpty, reject.shortcut == defaultR.shortcut else { return } + reject.macro = [] + m1.updateKey(reject) + next.updateMode(m1) + studioDraft = next + } + + private func commandsForModes(_ modes: [AhaKeyModeSlot]) -> [(data: Data, label: String)] { + var commands: [(data: Data, label: String)] = [] + + for mode in modes { + let draft = studioDraft.draft(for: mode) + for role in AhaKeyKeyRole.allCases { + let key = draft.key(for: role) + let keyIndex = UInt8(role.rawValue) + let modeByte = UInt8(mode.rawValue) + + if key.usesMacro { + // 固件对 0x73 快捷键、0x74 宏是分层存储的;从「快捷键」改「宏」时须先清掉旧快捷键,否则会残留。 + commands.append(( + data: AhaKeyCommand.setKeyMapping( + mode: modeByte, + keyIndex: keyIndex, + hidCodes: [] ), label: "清除 \(mode.title) \(key.title) 快捷键层(将写入宏)" )) @@ -1782,44 +2465,42 @@ struct AhaKeyStudioView: View { return commands } - private func uploadCurrentOLEDToDevice() { - guard bleManager.isConnected else { - syncStatusMessage = "设备未连接,先连上键盘再上传 OLED 动图。" - return - } - guard let assetPath = currentModeDraft.oled.localAssetPath else { - syncStatusMessage = "先为 \(selectedMode.title) 选择一个 GIF,再上传到设备。" - return - } - - let targetMode = selectedMode - let targetFPS = currentModeDraft.oled.framesPerSecond - let assetURL = URL(fileURLWithPath: assetPath) + /// 首次连接键盘后自动把 bundle 默认 GIF 推到没有上传过的 mode slot。 + /// 触发时机:bleManager.keyboardPictureStates 三个 mode 都查回来之后 + /// (由 .onChange(of: bleManager.keyboardPictureStates) 调度)。 + /// 守卫: + /// - 只上传 picLength==0(slot 完全空)的 mode;非 0 视为用户已自定义或固件出厂图 + /// - 只在 draft 的 localAssetPath 仍指向 bundle 默认(用户没手动换过)时上传 + /// - 每次连接只跑一次(oledAutoSyncDoneForConnection 标志位由 .onChange(isConnected) 重置) + private func autoSyncDefaultOLEDsIfNeeded() async { + guard bleManager.isConnected else { return } + // 三个 mode 全部 0x83 查询回来才动手,避免半截判断把已上传 slot 当成空 + guard bleManager.keyboardPictureStates.count == AhaKeyModeSlot.allCases.count else { return } - updateMode(targetMode) { mode in - mode.oled.statusLine = "正在上传动图到 \(targetMode.title)…" - } - syncStatusMessage = "开始上传 \(targetMode.title) 的 OLED 动图。" + for mode in AhaKeyModeSlot.allCases { + guard let state = bleManager.keyboardPictureStates[mode.rawValue] else { continue } + guard state.frameCount == 0 else { continue } + guard let bundledPath = DefaultOLEDAssets.bundledAssetPath(for: mode) else { continue } + let draft = studioDraft.draft(for: mode) + guard let drafPath = draft.oled.localAssetPath, + DefaultOLEDAssets.isBundledPath(drafPath) else { continue } - Task { @MainActor in + let assetURL = URL(fileURLWithPath: bundledPath) do { + try OLEDFrameEncoder.validateGIFSourceFileSize(at: assetURL) let frames = try OLEDFrameEncoder.frames(fromGIFAt: assetURL) - let startIndex = try await resolveOLEDUploadStartIndex(for: targetMode, frameCount: frames.count) + let startIndex = try await resolveOLEDUploadStartIndex(for: mode, frameCount: frames.count) try await bleManager.uploadOLEDFrames( frames, - fps: targetFPS, - mode: UInt8(targetMode.rawValue), + fps: draft.oled.framesPerSecond, + mode: UInt8(mode.rawValue), startIndex: UInt16(startIndex) ) - updateMode(targetMode) { mode in - mode.oled.statusLine = "已上传 \(frames.count) 帧到设备,槽位起点 \(startIndex);切换模式时会先显示描述,再回到当前模式动图。" + updateMode(mode) { m in + m.oled.statusLine = "已自动同步默认动图(\(frames.count) 帧)。" } - syncStatusMessage = "\(targetMode.title) OLED 动图已上传完成。" } catch { - updateMode(targetMode) { mode in - mode.oled.statusLine = "上传失败:\(error.localizedDescription)" - } - syncStatusMessage = "\(targetMode.title) OLED 上传失败:\(error.localizedDescription)" + syncStatusMessage = "\(mode.title) 默认动图自动同步失败: \(error.localizedDescription)" } } } @@ -1904,19 +2585,6 @@ struct AhaKeyStudioView: View { return nil } - private func previewCurrentLightEffectOnDevice() { - guard let ideState = currentDevicePreviewIDEState else { - syncStatusMessage = currentLightPreviewHint - return - } - bleManager.updateIDEState(ideState) - if bleManager.workMode == 0 { - syncStatusMessage = "已把 \(currentLightEffect.title) 的可达预览发送到设备(Mode 0)。" - } else { - syncStatusMessage = "已发送 \(ideState.label) 到设备。注意:键盘当前在 Mode \(bleManager.workMode),出厂固件可能没有在此档位映射此 state,把拨杆切到 Mode 0 可看到完整效果。" - } - } - private func infoPill(title: String, subtitle: String, accent: Color) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) @@ -1928,8 +2596,11 @@ struct AhaKeyStudioView: View { .frame(width: 8, height: 8) Text(subtitle) .font(.callout.weight(.medium)) + .lineLimit(1) + .truncationMode(.tail) } } + .frame(width: 86, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 8) .background( @@ -1964,9 +2635,39 @@ struct AhaKeyStudioView: View { private func openNativeSpeechPrivacySettings() { openNativeSpeechPrivacySettingsURL() } + + private var nativeSpeechPermissionsReady: Bool { + nativeSpeech.microphoneGranted && + nativeSpeech.speechRecognitionGranted && + nativeSpeech.siriEnabled && + nativeSpeech.dictationEnabled + } + + private var startupPermissionsReady: Bool { + bleManager.bluetoothPermissionGranted && + bleManager.bluetoothPoweredOn && + voiceRelay.inputMonitoringGranted && + voiceRelay.accessibilityGranted && + nativeSpeech.microphoneGranted && + nativeSpeech.speechRecognitionGranted && + nativeSpeech.siriEnabled && + nativeSpeech.dictationEnabled + } + + private func scheduleStartupPermissionOnboarding() { + voiceRelay.showsPermissionOnboarding = false + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + + private func refreshStartupPermissionOnboarding() { + voiceRelay.showsPermissionOnboarding = false + } } private struct VoicePermissionOnboardingSheet: View { + @ObservedObject var bleManager: AhaKeyBLEManager @ObservedObject var voiceRelay: VoiceRelayService @ObservedObject var nativeSpeech: NativeSpeechTranscriptionService @Environment(\.dismiss) private var dismiss @@ -1979,23 +2680,34 @@ private struct VoicePermissionOnboardingSheet: View { var body: some View { VStack(alignment: .leading, spacing: 18) { - Text("开启语音快捷键权限") + Text("新手权限引导") .font(.system(size: 24, weight: .semibold)) - Text("为了让 AhaKey 的语音键在后台直接接管语音,第一次使用时需要给 AhaKey Studio 打开系统权限。macOS 原生语音还会额外用到苹果自己的麦克风和语音转写能力。") + Text("AhaKey Studio 首次使用需要完成几项系统授权:连接键盘需要蓝牙,后台接管语音键需要输入监控与辅助功能,macOS 原生语音需要麦克风、语音转写、Siri 与听写。") .font(.callout) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 10) { + permissionRow(title: "蓝牙", granted: bleManager.bluetoothPermissionGranted && bleManager.bluetoothPoweredOn, detail: bleManager.bluetoothPermissionGranted ? "打开系统蓝牙,用于发现、连接和同步 AhaKey 键盘。" : "在「隐私与安全性 > 蓝牙」中允许 AhaKey Studio 使用蓝牙。") permissionRow(title: "输入监控", granted: voiceRelay.inputMonitoringGranted, detail: "允许 AhaKey Studio 在后台监听实体语音键。") permissionRow(title: "辅助功能", granted: voiceRelay.accessibilityGranted, detail: "允许 AhaKey Studio 把语音键转换成苹果原生转写或 Fn/Globe。") permissionRow(title: "麦克风", granted: nativeSpeech.microphoneGranted, detail: "允许 AhaKey Studio 使用苹果原生语音采集。") permissionRow(title: "语音转写", granted: nativeSpeech.speechRecognitionGranted, detail: "允许 AhaKey Studio 使用苹果原生语音识别。") + permissionRow(title: "Siri", granted: nativeSpeech.siriEnabled, detail: "在「系统设置 > Siri 与聚焦」里开启 Siri,供 macOS 原生语音能力使用。") + permissionRow(title: "听写", granted: nativeSpeech.dictationEnabled, detail: "在「系统设置 > 键盘 > 听写」里开启听写,保证系统语音组件完整可用。") } - Text("操作建议:先点「现在申请权限」——macOS 上输入监控/辅助功能常常不再弹系统对话框,约半秒后会自动打开「隐私与安全性」,请在列表中勾选 AhaKey Studio;麦克风和语音在之前就拒绝过的话也不会再弹窗,需在设置里手动打开。若你已在系统设置里改好,可点「我已完成,重新检查」。") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 6) { + Text("授权步骤") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text("1. 点「现在申请权限」,按系统弹窗允许蓝牙、麦克风和语音转写。") + Text("2. 自动打开系统设置后,按上方橙色项目依次为 AhaKey Studio 打开蓝牙、输入监控、辅助功能。") + Text("3. 若使用默认 macOS 原生语音,在「Siri 与聚焦」开启 Siri,在「键盘 > 听写」开启听写。") + Text("4. 回到这里点「我已完成,重新检查」;若输入监控或辅助功能刚开启,建议点「退出并重新打开」。") + } + .font(.caption) + .foregroundStyle(.secondary) Text("若系统里已勾选允许,本应用仍显示未开启:请完全退出 AhaKey Studio 并再启动一次。输入监控、辅助功能等常按进程生效,只点「重新检查」或从后台切回,有时读到的仍是旧状态,重启后即可与系统设置一致。") .font(.caption) .foregroundStyle(.secondary) @@ -2004,6 +2716,7 @@ private struct VoicePermissionOnboardingSheet: View { .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 4) { + Text("蓝牙 \(bleManager.bluetoothPermissionGranted ? (bleManager.bluetoothPoweredOn ? "已开启" : "已授权但蓝牙关闭") : "未授权")") Text(voiceRelay.lastPermissionCheckSummary) Text(nativeSpeech.lastPermissionCheckSummary) } @@ -2013,6 +2726,7 @@ private struct VoicePermissionOnboardingSheet: View { HStack(spacing: 12) { Button("现在申请权限") { requestPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: bleManager, voiceRelay: voiceRelay, nativeSpeech: nativeSpeech ) @@ -2020,6 +2734,7 @@ private struct VoicePermissionOnboardingSheet: View { .buttonStyle(.borderedProminent) Button("我已完成,重新检查") { + bleManager.refreshBluetoothAuthorization() voiceRelay.refreshPermissions(deferredTCCRequery: true) nativeSpeech.refreshPermissions(deferredTCCRequery: true) } @@ -2027,9 +2742,9 @@ private struct VoicePermissionOnboardingSheet: View { RestartToApplyPermissionsButton(title: "退出并重新打开") - if !nativeSpeech.microphoneGranted || !nativeSpeech.speechRecognitionGranted { + if !allPermissionsReady { Button("打开系统设置") { - openNativeSpeechPrivacySettingsURL() + openCombinedVoicePrivacySettingsURL() } .buttonStyle(.bordered) } @@ -2053,10 +2768,14 @@ private struct VoicePermissionOnboardingSheet: View { .buttonStyle(.borderless) } - if voiceRelay.inputMonitoringGranted && voiceRelay.accessibilityGranted { - Text("基础权限已经齐了。关闭这个弹窗后,AhaKey Studio 会继续在后台监听语音键;如果你使用 macOS 原生语音,麦克风和语音转写也建议一起打开。") + if allPermissionsReady { + Text("新手权限已经齐了。关闭这个弹窗后,AhaKey Studio 可以连接键盘、后台监听语音键,macOS 原生语音也可以正常使用。") .font(.caption) .foregroundStyle(.green) + } else { + Text("仍有权限未开启。请按上方状态逐项处理,全部变为绿色后再关闭弹窗。") + .font(.caption) + .foregroundStyle(.orange) } } .padding(24) @@ -2067,6 +2786,12 @@ private struct VoicePermissionOnboardingSheet: View { .onChange(of: voiceRelay.accessibilityGranted) { _ in closeIfReady() } + .onChange(of: bleManager.bluetoothPermissionGranted) { _ in + closeIfReady() + } + .onChange(of: bleManager.bluetoothPoweredOn) { _ in + closeIfReady() + } .alert(fixAlertTitle, isPresented: $showFixAlert) { if fixAlertIsSuccess { Button("立即退出 App") { NSApp.terminate(nil) } @@ -2091,11 +2816,22 @@ private struct VoicePermissionOnboardingSheet: View { } private func closeIfReady() { - guard voiceRelay.inputMonitoringGranted && voiceRelay.accessibilityGranted else { return } + guard allPermissionsReady else { return } voiceRelay.dismissPermissionOnboarding() dismiss() } + private var allPermissionsReady: Bool { + bleManager.bluetoothPermissionGranted && + bleManager.bluetoothPoweredOn && + voiceRelay.inputMonitoringGranted && + voiceRelay.accessibilityGranted && + nativeSpeech.microphoneGranted && + nativeSpeech.speechRecognitionGranted && + nativeSpeech.siriEnabled && + nativeSpeech.dictationEnabled + } + private func permissionRow(title: String, granted: Bool, detail: String) -> some View { HStack(alignment: .top, spacing: 12) { Circle() @@ -2129,6 +2865,7 @@ private struct VoicePresetPicker: View { let selectedPreset: VoicePreset let onSelect: (VoicePreset) -> Void + private let visiblePresets = VoicePreset.visibleCases private let columns = [ GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10), @@ -2136,7 +2873,7 @@ private struct VoicePresetPicker: View { var body: some View { LazyVGrid(columns: columns, spacing: 10) { - ForEach(VoicePreset.allCases) { preset in + ForEach(visiblePresets) { preset in Button { if preset.availableInV1 { onSelect(preset) @@ -2258,13 +2995,34 @@ private struct ShortcutBindingEditor: View { } } +private struct CanvasKeyButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1.0) + .animation(.spring(response: 0.12, dampingFraction: 0.65), value: configuration.isPressed) + } +} + private struct AhaKeyKeyboardCanvasView: View { let modeDraft: AhaKeyModeDraft + var oledAssetPath: String? = nil + var oledFramesPerSecond: Int? = nil let selectedPart: AhaKeyStudioPart let lightBarPreview: LightBarPreviewState let switchTitle: String let dirtyParts: Set let onSelect: (AhaKeyStudioPart) -> Void + let onModeSwitch: () -> Void + var onKeySimulate: ((AhaKeyKeyRole) -> Void)? = nil + var onSwitchToggle: (() -> Void)? = nil + var liveLightMode: Int? = nil + var liveIDEStateValue: Int? = nil + var switchState: Int = 1 // 0=auto, 1=manual; firmware uses for color/effect overrides + /// 0x83 查询出的当前 mode flash 帧数:nil=尚未查询/未连接;0=用户没上传;>0=已上传 N 帧 + var keyboardPictureFrameCount: Int? = nil + + @State private var modeSwitchPressed = false + @State private var leverPressed = false private let baseWidth: CGFloat = 109 private let baseHeight: CGFloat = 54 @@ -2307,11 +3065,14 @@ private struct AhaKeyKeyboardCanvasView: View { Spacer() } - ForEach(Array([CGPoint(x: 8, y: 8), CGPoint(x: 101, y: 8), CGPoint(x: 8, y: 46), CGPoint(x: 101, y: 46)].enumerated()), id: \.offset) { _, point in + // 螺丝挪到真正的"边角内侧" + 缩小直径 4.8 → 3.6: + // 旧位置 (8,8)/(8,46) 会被按键灰底矩形和灯条/Key1 边线擦边或交叠。 + // 新位置每颗距离灯条/按键灰底/Key 边都留出 ≥ 3 个基线单位。 + ForEach(Array([CGPoint(x: 5.5, y: 5.5), CGPoint(x: 103.5, y: 5.5), CGPoint(x: 5.5, y: 48.5), CGPoint(x: 103.5, y: 48.5)].enumerated()), id: \.offset) { _, point in Circle() .stroke(Color.black.opacity(0.14), lineWidth: 1.2) .background(Circle().fill(Color.white.opacity(0.4))) - .frame(width: scaled(4.8, in: width), height: scaled(4.8, in: width)) + .frame(width: scaled(3.6, in: width), height: scaled(3.6, in: width)) .position(position(point.x, point.y, width: width, height: height)) } @@ -2320,10 +3081,11 @@ private struct AhaKeyKeyboardCanvasView: View { .frame(width: scaled(4.2, in: width), height: scaled(12, in: width)) .position(position(3.8, 28, width: width, height: height)) - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.black.opacity(0.04)) - .frame(width: scaled(70, in: width), height: scaled(24, in: width)) - .position(position(43.8, 37.3, width: width, height: height)) + // 按键灰底:略收一点尺寸,使它显著低于灯条选中态阴影的影响范围(≥ 5 个基线单位) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.035)) + .frame(width: scaled(67, in: width), height: scaled(21, in: width)) + .position(position(43.8, 38.5, width: width, height: height)) ledBarButton(width: width, height: height) oledButton(width: width, height: height) @@ -2336,9 +3098,82 @@ private struct AhaKeyKeyboardCanvasView: View { } } + // 固件 ws2812_mode_e (psk_ws2812.h) → Swift 灯效样式 + private func lightModeToEffect(_ mode: Int) -> LightEffectStyle { + switch mode { + case 1: return .singleMove + case 2: return .rainbowMove + case 3: return .rainbowWave + case 4: return .rainbowWaveSlow + case 5: return .breathing + case 6: return .middleLight + default: return .off + } + } + + private static let firmwareRed = Color(red: 240 / 255, green: 32 / 255, blue: 41 / 255) + private static let firmwareBlue = Color(red: 32 / 255, green: 80 / 255, blue: 255 / 255) + + /// 1:1 还原固件 update_claude_ws2812() 的灯效/颜色决策(CH582m main.c:458-500)。 + /// 仅 mode_data==0 时固件会按 claude_state 切灯效;其他 mode 固件提前 return, + /// 灯条停在上一次设定的状态,所以我们这里返回 OFF 作为"没有运行时灯效"的真实表达。 + private func firmwareLEDState(ideState: IDEState?, modeData: Int, switchState: Int) -> (LightEffectStyle, Color) { + guard modeData == 0, let s = ideState else { + return (.off, Self.firmwareRed) + } + var effect: LightEffectStyle + var color: Color = Self.firmwareRed + switch s { + case .sessionStart, .stop: + effect = .middleLight + case .postToolUse, .userPromptSubmit: + effect = .singleMove + case .permissionRequest: + effect = .breathing + case .preToolUse: + effect = .singleMove + color = Self.firmwareBlue + case .sessionEnd: + effect = .off + case .notification, .taskCompleted: + // 固件 switch 未处理这两个 state,灯条保持上一次状态(这里以 OFF 表示无新效果) + return (.off, Self.firmwareRed) + } + if switchState == 0 { // auto: 固件覆盖部分 state 为彩虹效果 + switch s { + case .postToolUse, .userPromptSubmit: + effect = .rainbowMove + case .permissionRequest, .preToolUse: + effect = .rainbowWave + default: + break + } + } + return (effect, color) + } + private func ledBarButton(width: CGFloat, height: CGFloat) -> some View { let part = AhaKeyStudioPart.lightBar - let rect = frame(12.3, 5.0, 55.6, 9.8, width: width, height: height) + // 略向上、宽度往里收:让选中态阴影(radius 10pt)跟键盘内描边、按键灰底、OLED 都有 ≥ 5 个基线单位的余量 + let rect = frame(13.0, 4.5, 53.5, 8.6, width: width, height: height) + let modeData = modeDraft.mode.rawValue + let effect: LightEffectStyle + let baseColor: Color + if let live = liveLightMode { + // BLE 连接且 mode tab 与物理 workMode 一致:直接信任固件回报的 ws2812_mode + claude_state + effect = lightModeToEffect(live) + let liveIDE: IDEState? = liveIDEStateValue.flatMap { IDEState(rawValue: UInt8($0)) } + // 颜色:仅 preToolUse + manual 是蓝,其他均红(与固件 ws2812_single_color 设定一致) + if let s = liveIDE, s == .preToolUse, switchState != 0 { + baseColor = Self.firmwareBlue + } else { + baseColor = Self.firmwareRed + } + } else { + // 离线/查看非物理档位:按固件逻辑模拟 update_claude_ws2812() + let previewIDE = lightBarPreview.ideState + (effect, baseColor) = firmwareLEDState(ideState: previewIDE, modeData: modeData, switchState: switchState) + } return Button { onSelect(part) } label: { @@ -2348,20 +3183,24 @@ private struct AhaKeyKeyboardCanvasView: View { .foregroundStyle(Color.black.opacity(0.72)) .frame(maxWidth: .infinity, alignment: .center) - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.black.opacity(0.12)) - HStack(spacing: rect.width * 0.085) { - ForEach(0..<4, id: \.self) { index in - Capsule() - .fill(ledColor(for: index)) - .frame(width: rect.width * 0.12, height: rect.height * 0.22) + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let colors = ledColors(effect: effect, time: context.date.timeIntervalSince1970, count: 10, baseColor: baseColor) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.12)) + HStack(spacing: rect.width * 0.026) { + ForEach(0..<10, id: \.self) { index in + Capsule() + .fill(colors[index]) + .frame(width: rect.width * 0.072, height: rect.height * 0.26) + .shadow(color: colors[index].opacity(0.65), radius: 2.5) + } } + .padding(.horizontal, rect.width * 0.04) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, rect.width * 0.08) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: rect.height * 0.48) } - .frame(height: rect.height * 0.48) } .frame(width: rect.width, height: rect.height) .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) @@ -2376,65 +3215,116 @@ private struct AhaKeyKeyboardCanvasView: View { return Button { onSelect(part) } label: { + ZStack(alignment: .topTrailing) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.92)) + oledInnerContent(rect: rect) + } + // 右上角徽章:反映键盘 flash 真实状态 + pictureStateBadge(rect: rect) + } + .frame(width: rect.width, height: rect.height) + .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) + } + .buttonStyle(.plain) + .position(x: rect.midX, y: rect.midY) + } + + @ViewBuilder + private func pictureStateBadge(rect: CGRect) -> some View { + if let count = keyboardPictureFrameCount { + let isUploaded = count > 0 + let label = isUploaded ? "✓ 已上传 \(count) 帧" : "未上传" + Text(label) + .font(.system(size: max(rect.height * 0.11, 8), weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, rect.width * 0.04) + .padding(.vertical, rect.height * 0.02) + .background( + Capsule() + .fill(isUploaded ? Color.green.opacity(0.85) : Color.gray.opacity(0.85)) + ) + .padding(rect.width * 0.025) + } + } + + /// 真实 OLED 是 160×80(2:1)。在 slot 中央用一个 2:1 的"屏幕区"渲染内容, + /// 周围留键盘黑壳作为外框;图片 / 占位都在屏幕区内 .fit,不会撑出范围、不会被裁切。 + private func screenInnerSize(for rect: CGRect) -> CGSize { + let screenAspect: CGFloat = 2.0 + if rect.width / rect.height >= screenAspect { + let h = rect.height * 0.86 + return CGSize(width: h * screenAspect, height: h) + } else { + let w = rect.width * 0.86 + return CGSize(width: w, height: w / screenAspect) + } + } + + private func oledInnerContent(rect: CGRect) -> some View { + let size = screenInnerSize(for: rect) + return ZStack { + Color.clear + screenBody(screenWidth: size.width, screenHeight: size.height) + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func screenBody(screenWidth: CGFloat, screenHeight: CGFloat) -> some View { + if let gifPath = oledAssetPath ?? modeDraft.oled.localAssetPath { + // .id(gifPath) 强制 SwiftUI 在路径切换时销毁并重建视图, + // 否则旧路径的 @State frames/currentFrame/timer 会与新路径错位, + // 导致 Mode 切换瞬间画布渲染上一档 GIF 的某一帧(claude / cursor 互窜)。 + AnimatedGIFView(path: gifPath, fps: oledFramesPerSecond ?? modeDraft.oled.framesPerSecond) + .id(gifPath) + } else { ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.black.opacity(0.92)) LinearGradient( - colors: [Color.black.opacity(0.2), Color.white.opacity(0.05)], + colors: [Color.black.opacity(0.6), Color.black.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing ) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - VStack(alignment: .center, spacing: 4) { - if modeDraft.oled.localAssetPath == nil { - if modeDraft.mode == .mode0 { - HStack(spacing: 6) { - Image(systemName: "cloud.fill") - .font(.system(size: rect.height * 0.24, weight: .semibold)) - .foregroundStyle(Color.orange.opacity(0.92)) - Text("Mode 0") - .font(.system(size: rect.height * 0.18, weight: .semibold)) - .foregroundStyle(.white.opacity(0.85)) - } - Text("默认动图") - .font(.system(size: rect.height * 0.18)) - .foregroundStyle(.white.opacity(0.55)) - } else { - HStack(spacing: 6) { - Image(systemName: "sparkles.rectangle.stack") - .font(.system(size: rect.height * 0.22, weight: .semibold)) - .foregroundStyle(.white.opacity(0.78)) - Text("未上传") - .font(.system(size: rect.height * 0.18, weight: .semibold)) - .foregroundStyle(.white.opacity(0.85)) - } - Text("等待自定义") - .font(.system(size: rect.height * 0.18)) - .foregroundStyle(.white.opacity(0.55)) + VStack(alignment: .center, spacing: 2) { + if modeDraft.mode == .mode0 { + HStack(spacing: 4) { + Image(systemName: "cloud.fill") + .font(.system(size: screenHeight * 0.24, weight: .semibold)) + .foregroundStyle(Color.orange.opacity(0.92)) + Text("Mode 0") + .font(.system(size: screenHeight * 0.20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) } + Text("默认动图") + .font(.system(size: screenHeight * 0.18)) + .foregroundStyle(.white.opacity(0.55)) } else { - HStack(spacing: 6) { - Image(systemName: "photo") - .font(.system(size: rect.height * 0.22, weight: .semibold)) + HStack(spacing: 4) { + Image(systemName: { + if #available(macOS 13, *) { "sparkles.rectangle.stack" } else { "rectangle.stack" } + }()) + .font(.system(size: screenHeight * 0.22, weight: .semibold)) .foregroundStyle(.white.opacity(0.78)) - Text("已上传") - .font(.system(size: rect.height * 0.2, weight: .semibold)) + Text("未上传") + .font(.system(size: screenHeight * 0.20, weight: .semibold)) .foregroundStyle(.white.opacity(0.85)) } - Text("预览动画中") - .font(.system(size: rect.height * 0.18)) + Text("等待自定义") + .font(.system(size: screenHeight * 0.18)) .foregroundStyle(.white.opacity(0.55)) } } - .padding(rect.width * 0.08) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(screenWidth * 0.04) .multilineTextAlignment(.center) } - .frame(width: rect.width, height: rect.height) - .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) } - .buttonStyle(.plain) - .position(x: rect.midX, y: rect.midY) } private func keyButton(for role: AhaKeyKeyRole, width: CGFloat, height: CGFloat) -> some View { @@ -2454,6 +3344,7 @@ private struct AhaKeyKeyboardCanvasView: View { let rect = frame(specs.x, specs.y, specs.w, specs.h, width: width, height: height) return Button { onSelect(part) + onKeySimulate?(role) } label: { VStack(spacing: rect.height * 0.07) { ZStack { @@ -2482,36 +3373,41 @@ private struct AhaKeyKeyboardCanvasView: View { .frame(width: rect.width, height: rect.height) .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) } - .buttonStyle(.plain) + .buttonStyle(CanvasKeyButtonStyle()) .position(x: rect.midX, y: rect.midY) } private func modeSwitchKey(width: CGFloat, height: CGFloat) -> some View { let rect = frame(78.9, 40.9, 8.0, 10.2, width: width, height: height) - return VStack(spacing: rect.height * 0.08) { - ZStack { - RoundedRectangle(cornerRadius: rect.width * 0.2, style: .continuous) - .fill(Color.white) - .overlay( - RoundedRectangle(cornerRadius: rect.width * 0.2, style: .continuous) - .stroke(Color.black.opacity(0.08), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.12), radius: 10, y: 4) + return Button { + onModeSwitch() + } label: { + VStack(spacing: rect.height * 0.08) { + ZStack { + RoundedRectangle(cornerRadius: rect.width * 0.2, style: .continuous) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: rect.width * 0.2, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 10, y: 4) - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: rect.height * 0.18, weight: .semibold)) - .foregroundStyle(Color.black.opacity(0.42)) - } - .frame(width: rect.width * 0.78, height: rect.height * 0.5) + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: rect.height * 0.18, weight: .semibold)) + .foregroundStyle(Color.accentColor.opacity(0.72)) + } + .frame(width: rect.width * 0.78, height: rect.height * 0.5) - Text("Mode") - .font(.system(size: rect.height * 0.1, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) + Text("Mode") + .font(.system(size: rect.height * 0.1, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(width: rect.width, height: rect.height) } - .frame(width: rect.width, height: rect.height) + .buttonStyle(CanvasKeyButtonStyle()) .position(x: rect.midX, y: rect.midY) - .help("实体模式切换键") + .help("点击切换 Mode(模拟实体键)") } private func switchButton(width: CGFloat, height: CGFloat) -> some View { @@ -2519,6 +3415,10 @@ private struct AhaKeyKeyboardCanvasView: View { let rect = frame(87.8, 35.6, 6.8, 10.6, width: width, height: height) return Button { onSelect(part) + // 物理拨杆损坏的用户靠这个:点击即翻转 auto/manual。 + // - 已 patch 固件:agent 通过 0x91 BLE 命令真改键盘 sw_state,灯效也会跟着变 + // - 老固件:只在 agent 软覆盖层生效(hook 自动批准走新值),键盘灯效不会变 + onSwitchToggle?() } label: { VStack(spacing: 6) { ZStack(alignment: .top) { @@ -2544,7 +3444,7 @@ private struct AhaKeyKeyboardCanvasView: View { .frame(width: rect.width, height: rect.height) .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) } - .buttonStyle(.plain) + .buttonStyle(CanvasKeyButtonStyle()) .position(x: rect.midX, y: rect.midY) } @@ -2565,22 +3465,50 @@ private struct AhaKeyKeyboardCanvasView: View { value / baseWidth * width } - private func ledColor(for index: Int) -> Color { - switch AhaKeyLightBarDraft.hardwareEffect(for: lightBarPreview) { + private func ledColors(effect: LightEffectStyle, time: TimeInterval, count: Int, + baseColor: Color = Self.firmwareRed) -> [Color] { + switch effect { + case .off: + return Array(repeating: Color.gray.opacity(0.15), count: count) case .middleLight: - [Color.red.opacity(0.28), Color.red.opacity(0.86), Color.red.opacity(0.86), Color.red.opacity(0.28)][index] + let center = Double(count - 1) / 2.0 + return (0..= 0, currentFrame < frames.count { + Image(nsImage: frames[currentFrame]) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Color.black + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .onAppear { loadFrames() } + .onDisappear { + gifTimer?.invalidate() + gifTimer = nil + } + } + + private func loadFrames() { + let url = URL(fileURLWithPath: path) + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return } + let count = CGImageSourceGetCount(src) + guard count > 0 else { return } + var images: [NSImage] = [] + for i in 0.. 1 else { return } + let interval = 1.0 / Double(max(fps, 1)) + gifTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + currentFrame = (currentFrame + 1) % max(1, frames.count) + } + } +} + private func openNativeSpeechPrivacySettingsURL() { let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition", + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:com.apple.Keyboard-Settings.extension", "x-apple.systempreferences:com.apple.preference.security?Privacy" ] - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - break - } - } + openFirstAvailableSystemSettingsURL(candidates) } /// 输入监控 / 辅助功能 / 麦克风和语音转写:系统在「已拒绝」或部分版本下不会再弹权限窗。主动申请后打开「隐私与安全性」相关页,保证有可操作反馈。 @@ -2609,18 +3583,16 @@ private func openCombinedVoicePrivacySettingsURL() { // 勿用未文档化的 `x-apple.systemsettings` + `.extension` 等组合;在部分系统上会被当成「文稿」, // 连续弹出「在 App Store 搜索… / 选取应用程序」而非进入设置。 let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition", + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:com.apple.Keyboard-Settings.extension", "x-apple.systempreferences:com.apple.preference.security?Privacy", ] - for candidate in candidates { - guard let url = URL(string: candidate) else { continue } - if NSWorkspace.shared.open(url) { - return - } - } + if openFirstAvailableSystemSettingsURL(candidates) { return } let appPaths = [ "/System/Applications/System Settings.app", "/System/Library/CoreServices/Applications/System Settings.app", @@ -2633,22 +3605,66 @@ private func openCombinedVoicePrivacySettingsURL() { } } +@discardableResult +private func openFirstAvailableSystemSettingsURL(_ candidates: [String]) -> Bool { + for candidate in candidates { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return true + } + } + return false +} + +@MainActor +private func openFirstMissingVoicePermissionSettings( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService +) { + if !bleManager.bluetoothPermissionGranted || !bleManager.bluetoothPoweredOn { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth"]) { return } + } + if !voiceRelay.inputMonitoringGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) { return } + } + if !voiceRelay.accessibilityGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) { return } + } + if !nativeSpeech.microphoneGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"]) { return } + } + if !nativeSpeech.speechRecognitionGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition"]) { return } + } + if !nativeSpeech.siriEnabled { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.Siri-Settings.extension"]) { return } + } + if !nativeSpeech.dictationEnabled { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.Keyboard-Settings.extension"]) { return } + } + openCombinedVoicePrivacySettingsURL() +} + /// 先走系统 API 申请;随后在桌面端打开「隐私与安全性」相关页。输入监控 / 辅助功能在多数 macOS 版本上**不会**像 iOS 那样弹窗,麦克风和语音在「已选择过」后也不再弹窗,因此必须配合系统设置界面。 @MainActor private func requestPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: AhaKeyBLEManager, voiceRelay: VoiceRelayService, nativeSpeech: NativeSpeechTranscriptionService, delay: TimeInterval = 0.45 ) { + bleManager.refreshBluetoothAuthorization() voiceRelay.refreshPermissions(requestIfNeeded: true) nativeSpeech.refreshPermissions(requestIfNeeded: true) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - openCombinedVoicePrivacySettingsURL() + bleManager.refreshBluetoothAuthorization() + openFirstMissingVoicePermissionSettings(bleManager: bleManager, voiceRelay: voiceRelay, nativeSpeech: nativeSpeech) } } /// 先启动一个延迟重开助手,再退出当前进程。不要在旧进程仍存活时 `open -n`: -/// AppDelegate 有单实例保护,新实例会发现旧实例还在并立即退出,造成“新程序闪退、旧程序不关”。 +/// AppDelegate 有单实例保护,新实例会发现旧实例还在并立即退出,造成"新程序闪退、旧程序不关"。 private func relaunchApplicationForPermissionRefresh() { let bundlePath = Bundle.main.bundleURL.path let script = "sleep 0.8; /usr/bin/open \(shellQuoted(bundlePath))" @@ -3058,10 +4074,15 @@ private struct HotspotChrome: ViewModifier { let dirtyParts: Set func body(content: Content) -> some View { + let isSelected = selectedPart == part content .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(selectedPart == part ? Color.accentColor : Color.black.opacity(0.05), lineWidth: selectedPart == part ? 2 : 1) + // 非选中时几乎隐形,避免每个 hotspot 都画一圈灰线和邻近元件视觉打架 + .strokeBorder( + isSelected ? Color.accentColor : Color.black.opacity(0.015), + lineWidth: isSelected ? 2 : 1 + ) ) .overlay(alignment: .topTrailing) { if dirtyParts.contains(part) { @@ -3071,7 +4092,8 @@ private struct HotspotChrome: ViewModifier { .padding(8) } } - .shadow(color: selectedPart == part ? Color.accentColor.opacity(0.18) : .clear, radius: 10) + // 选中态阴影从 10 收到 6,减少向邻近元件溢出的发光半径 + .shadow(color: isSelected ? Color.accentColor.opacity(0.18) : .clear, radius: 6) } } @@ -3106,7 +4128,9 @@ private struct OLEDMotionPreviewSheet: View { .padding(12) } else { VStack(spacing: 10) { - Image(systemName: "film.stack") + Image(systemName: { + if #available(macOS 14, *) { "film.stack" } else { "film" } + }()) .font(.system(size: 34, weight: .regular)) .foregroundStyle(.secondary) Text("还没有选择动图") @@ -3201,3 +4225,691 @@ private struct AnimatedGIFPreview: NSViewRepresentable { nsView.image = NSImage(contentsOfFile: path) } } + +// MARK: - 帮助中心(内嵌弹窗) + +private enum HelpTopic: String, CaseIterable, Identifiable { + case overview = "总览" + case modes = "三个 Mode" + case canvas = "画布与按键" + case toggleSwitch = "虚拟拨杆" + case oled = "OLED 屏幕" + case lightBar = "灯条颜色" + case voice = "语音输入" + case diagnostics = "权限诊断" + case faq = "常见问题" + + var id: String { rawValue } + + var iconName: String { + switch self { + case .overview: return "sparkles" + case .modes: return "square.grid.3x1.below.line.grid.1x2" + case .canvas: return "keyboard" + case .toggleSwitch: return "switch.2" + case .oled: return "play.tv" + case .lightBar: return "rainbow" + case .voice: return "mic.circle" + case .diagnostics: return "stethoscope" + case .faq: return "questionmark.bubble" + } + } +} + +private struct HelpCenterSheet: View { + let studioDraft: AhaKeyStudioDraft + let selectedMode: AhaKeyModeSlot + @ObservedObject var bleManager: AhaKeyBLEManager + @Environment(\.dismiss) private var dismiss + @State private var topic: HelpTopic = .overview + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "book.closed.fill") + .font(.title3) + .foregroundStyle(.tint) + Text("AhaKey Studio 帮助中心") + .font(.title3.weight(.semibold)) + Spacer() + Button("完成") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(.thinMaterial) + + Divider() + + HStack(spacing: 0) { + sidebar + .frame(width: 188) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ScrollView { + contentForTopic + .padding(.horizontal, 28) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .id(topic) + } + } + .frame(width: 880, height: 620) + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(HelpTopic.allCases) { t in + Button { + topic = t + } label: { + HStack(spacing: 10) { + Image(systemName: t.iconName) + .font(.system(size: 13, weight: .medium)) + .frame(width: 18) + Text(t.rawValue) + .font(.callout) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(t == topic ? Color.accentColor.opacity(0.16) : Color.clear) + ) + .foregroundStyle(t == topic ? Color.accentColor : Color.primary) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .padding(12) + } + + @ViewBuilder + private var contentForTopic: some View { + switch topic { + case .overview: OverviewTopicView() + case .modes: ModesTopicView(selectedMode: selectedMode) + case .canvas: CanvasTopicView() + case .toggleSwitch: ToggleSwitchTopicView(bleManager: bleManager) + case .oled: OLEDTopicView(studioDraft: studioDraft, bleManager: bleManager) + case .lightBar: LightBarTopicView() + case .voice: VoiceTopicView() + case .diagnostics: DiagnosticsTopicView() + case .faq: FAQTopicView() + } + } +} + +// MARK: 帮助中心 - 通用排版 + +private struct HelpTitle: View { + let icon: String + let title: String + var subtitle: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.tint) + Text(title).font(.title2.weight(.semibold)) + } + if let subtitle { + Text(subtitle) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(.bottom, 12) + } +} + +private struct HelpSection: View { + let title: String + let text: String + + init(title: String, body text: String) { + self.title = title + self.text = text + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.headline) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 14) + } +} + +private struct HelpNote: View { + let icon: String + let tint: Color + let text: String + + init(_ icon: String, tint: Color = .orange, body text: String) { + self.icon = icon + self.tint = tint + self.text = text + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(tint) + .padding(.top, 2) + Text(text) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(tint.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(tint.opacity(0.25), lineWidth: 1) + ) + ) + .padding(.vertical, 6) + } +} + +private struct HelpSwatch: View { + let color: Color + let label: String + let detail: String + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(color) + .frame(width: 22, height: 22) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 0.5) + ) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.callout.weight(.medium)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } +} + +// MARK: 帮助中心 - 各章节 + +private struct OverviewTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "sparkles", + title: "总览", + subtitle: "AhaKey Studio 是 AhaKey 小键盘的 macOS 配置中心" + ) + + HelpSection( + title: "三件套是怎么协同的", + body: """ + • 主 App(你正在用的)— 看配置、改键位、上传 OLED、查诊断 + • Agent 守护进程 — 后台常驻;监听 IDE 的 Hook(Claude / Cursor / Codex / Kimi),并在 BLE 上向键盘转发当前 AI 状态 + • 键盘固件 — 收到 BLE 状态后驱动灯条颜色、OLED 显示、按键映射 + """ + ) + + HelpSection( + title: "BLE 占用是一道单行道", + body: """ + 同一时刻只有一个进程能持有键盘的 BLE 连接: + • 默认 Agent 占用 → Hook 状态实时上键盘、自动批准链可用 + • 你在画布点「修改」时 → 主 App 临时接管,能上传 OLED、改键位、读图片元信息 + • 点「返回并保存」或「取消编辑」 → 主 App 释放,Agent 自动接回 + """ + ) + + HelpNote("info.circle.fill", tint: .blue, body: "首次连接,可以先打开「权限诊断」过一遍权限项;任何 Hook 不生效的问题大多在权限里。") + } + } +} + +private struct ModesTopicView: View { + let selectedMode: AhaKeyModeSlot + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "square.grid.3x1.below.line.grid.1x2", + title: "三个 Mode", + subtitle: "硬件物理键码 + 软件配置同步切换" + ) + + ForEach(AhaKeyModeSlot.allCases) { mode in + modeCard(mode) + } + + HelpNote("hand.tap.fill", tint: .accentColor, body: "切换方式:键盘上的 Mode 拨杆,或主 App 顶部 Picker,或点画布上的 Mode 按钮。三处任一改动会同步另外两个。") + } + } + + @ViewBuilder + private func modeCard(_ mode: AhaKeyModeSlot) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(mode.title) + .font(.headline) + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(modeChipColor(mode), in: Capsule()) + Text(mode.name).font(.headline) + if mode == selectedMode { + Text("当前").font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.accentColor.opacity(0.18), in: Capsule()) + } + Spacer(minLength: 0) + } + Text(mode.subtitle).font(.callout).foregroundStyle(.secondary) + Text(mode.guidance).font(.callout).fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 1) + ) + .padding(.bottom, 6) + } + + private func modeChipColor(_ mode: AhaKeyModeSlot) -> Color { + switch mode { + case .mode0: return Color.orange + case .mode1: return Color.purple + case .mode2: return Color.green + } + } +} + +private struct CanvasTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "keyboard", + title: "画布与按键", + subtitle: "中间那个像键盘的图就是你的小键盘 1:1 镜像,所有元件可点" + ) + + HelpSection(title: "六大热区", body: "灯条、OLED 屏幕、Key1(语音)、Key2、Key3、Key4、拨杆。点哪个就在右侧 Inspector 看到那个元件的配置。") + + VStack(alignment: .leading, spacing: 10) { + hotspotRow("rainbow", "灯条", "点亮键盘顶端 8 颗 WS2812 LED;颜色和效果跟随 IDE Hook 状态。") + hotspotRow("play.tv", "OLED 屏幕", "0.96\" IPS 显示;可上传 GIF 动图(160×80, RGB565)。") + hotspotRow("mic", "Key 1 / 语音键", "默认 F18,触发苹果原生转写、AhaType、微信按住说话等预设。") + hotspotRow("checkmark.circle", "Key 2 / 通过键", "依 Mode 默认:Y / ↵ / ↵。可改成宏序列。") + hotspotRow("xmark.circle", "Key 3 / 拒绝键", "依 Mode 默认:N / ⌫ / Esc。可改成宏序列。") + hotspotRow("paperplane", "Key 4 / 提交键", "默认 ↵,可改任意短按 / 长按。") + hotspotRow("switch.2", "拨杆", "auto 批准 vs manual 批准;详见「虚拟拨杆」章节。") + } + + HelpNote("hand.point.up.left", tint: .accentColor, body: """ + 点完元件 → Inspector 显示「修改」按钮。点「修改」会接管 BLE 进入编辑态;改完点「返回并保存」立即写入键盘,或点「取消编辑」放弃。 + """) + } + } + + private func hotspotRow(_ icon: String, _ title: String, _ desc: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .frame(width: 22) + .foregroundStyle(.tint) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.medium)) + Text(desc).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(.vertical, 2) + } +} + +private struct ToggleSwitchTopicView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "switch.2", + title: "虚拟拨杆", + subtitle: "物理拨杆坏了?或想软件控制?看这里" + ) + + HelpSection(title: "两档分别管什么", body: """ + • 自动批准(switchState=0):Hook 拦截每次工具调用 / 命令请求时直接放行 + • 手动批准(switchState=1):Hook 把决定交回终端,由你手动按 Key2/Key3 通过或拒绝 + """) + + VStack(alignment: .leading, spacing: 8) { + Text("点画布拨杆触发三件事(不是所有都生效):").font(.subheadline.weight(.medium)) + triggerRow( + num: "1", + title: "乐观更新画布", + desc: "立即翻转画布拨杆位置 + 顶部状态栏;视觉零延迟", + works: true + ) + triggerRow( + num: "2", + title: "通知 Agent 设置 userSwitchOverride", + desc: "Hook 的 auto-approve 立即切换到你选的档位。持久化到 UserDefaults,agent 重启仍生效", + works: true + ) + triggerRow( + num: "3", + title: "BLE 0x91 set_sw_state", + desc: "试图修改键盘真实 sw_state → 灯效颜色逻辑跟着切。**需固件升级支持 0x91**", + works: false, + requiresPatch: true + ) + } + + HelpNote("exclamationmark.triangle.fill", tint: .orange, body: """ + 如果你**没刷新版固件**:点画布拨杆,Hook 行为会按虚拟值跑(这就够大多数 case),但键盘灯条颜色仍由坏掉的物理 GPIO 决定。要让灯效也跟着切,得给固件 command_solve.c 加 0x91 分支再 USB-ISP 烧一次(详见仓库 README 的固件章节)。 + """) + + VStack(alignment: .leading, spacing: 8) { + Text("现状一览").font(.subheadline.weight(.medium)) + stateRow("当前生效值", "\(bleManager.agentSwitchState ?? bleManager.switchState)") + stateRow("Agent 端覆盖", bleManager.agentSwitchState != nil ? "\(bleManager.agentSwitchState!)(覆盖中)" : "未设置(用键盘真实值)") + stateRow("乐观显示中", bleManager.optimisticSwitchOverride != nil ? "是(等待对齐)" : "否") + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + } + } + + private func triggerRow(num: String, title: String, desc: String, works: Bool, requiresPatch: Bool = false) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(num) + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(Circle().fill(works ? Color.green : Color.orange)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(title).font(.callout.weight(.medium)) + if requiresPatch { + Text("需固件支持").font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.orange.opacity(0.18), in: Capsule()) + } + } + Text(desc).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + + private func stateRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.callout).foregroundStyle(.secondary) + Spacer() + Text(value).font(.callout.monospaced()) + } + } +} + +private struct OLEDTopicView: View { + let studioDraft: AhaKeyStudioDraft + @ObservedObject var bleManager: AhaKeyBLEManager + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "play.tv", + title: "OLED 屏幕", + subtitle: "0.96\" IPS · 160×80 · RGB565 · 内置 16 Mbit Flash 存帧" + ) + + HelpSection(title: "默认动图(连接即自动同步)", body: """ + Mode 0 → claude_0.gif(出厂内置) + Mode 1 → cursor.gif + Mode 2 → codex.gif + + 首次连接键盘且发现某个 Mode 的 flash slot 为空时,主 App 会自动把对应 bundle GIF 推到键盘上。 + """) + + HelpSection(title: "替换成自己的 GIF", body: """ + 1. 画布点 OLED 屏幕 → Inspector 显示「修改」 + 2. 点「修改」进入编辑态(接管 BLE) + 3. 选择你的 .gif(推荐 ≤200 帧、≤2MB),可先在虚拟屏幕里预览 + 4. 确认后点「返回并保存」统一写入设备 + """) + + HelpSection(title: "OLED 角标的含义", body: """ + • 绿色「✓ 已上传 N 帧」:键盘 flash 真有 N 帧(你或自动同步推的) + • 灰色「未上传」:键盘 flash 空,正显示固件默认或留空 + • 没有徽章:还没自占 BLE 查到(点过一次「修改」就有了) + """) + + VStack(alignment: .leading, spacing: 6) { + Text("现在键盘 flash 各 Mode 状态").font(.subheadline.weight(.medium)) + ForEach(AhaKeyModeSlot.allCases) { mode in + HStack { + Text(mode.title + " · " + mode.name).font(.callout) + Spacer() + if let s = bleManager.keyboardPictureStates[mode.rawValue] { + if s.frameCount > 0 { + Label("\(s.frameCount) 帧", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + } else { + Label("空", systemImage: "tray").foregroundStyle(.secondary).font(.callout) + } + } else { + Text("尚未查询").font(.callout).foregroundStyle(.tertiary) + } + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + + HelpNote("info.circle.fill", tint: .blue, body: "切换 Mode 时 OLED 会先闪一下当前按键 description 文本(机械感效果),约 1 秒后回到该 Mode 的动图。") + } + } +} + +private struct LightBarTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "rainbow", + title: "灯条颜色", + subtitle: "8 颗 WS2812B,颜色由固件 update_claude_ws2812() 决定,1:1 还原在画布上" + ) + + HelpSection(title: "颜色对照表", body: "下面是 Mode 0(Claude)下,固件按 IDE state 的实际行为:") + + VStack(alignment: .leading, spacing: 8) { + HelpSwatch( + color: Color(red: 240/255, green: 32/255, blue: 41/255), + label: "0xF02029 (红)", + detail: "SessionStart / Stop / PostToolUse / PermissionRequest / UserPromptSubmit" + ) + HelpSwatch( + color: Color(red: 32/255, green: 80/255, blue: 255/255), + label: "0x2050FF (蓝)", + detail: "PreToolUse — 工具开始执行(manual 档专属)" + ) + HelpSwatch( + color: Color.gray.opacity(0.3), + label: "OFF (熄灭)", + detail: "SessionEnd — Claude 会话结束" + ) + } + + HelpSection(title: "Auto 档的彩虹覆盖", body: """ + 当拨杆 = auto (switchState=0) 时,固件把部分 state 强制改成彩虹效果: + • PreToolUse / PermissionRequest → 整条彩虹波浪 + • PostToolUse / UserPromptSubmit → 单点彩虹流水 + 这就是你看到「Cursor 一跑灯条变彩虹」的原因——是 auto 档的视觉提示,不是 Cursor 专属。 + """) + + HelpNote("exclamationmark.triangle.fill", tint: .orange, body: "Mode 1 / Mode 2 时,固件的 update_claude_ws2812() 直接 return,**灯条不再随 IDE state 变**,会停在上一次设定的颜色上。这是固件设计,不是 bug。") + } + } +} + +private struct VoiceTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "mic.circle", + title: "语音输入", + subtitle: "Key 1 默认绑定 F18,按一次开始、按一次结束" + ) + + HelpSection(title: "几种预设的差别", body: """ + • macOS 原生转写:在地化语言识别,识别完 ⌘V 写回光标。适合任何输入框 + • Typeless:调起 Typeless App + • 微信按住说话:按住语音键发语音,松开停 + • 豆包输入法:按住调起豆包长按语音 + • AhaType:先识别再优化提示词(需登录) + """) + + HelpSection(title: "短按 vs 长按", body: """ + • 短按(Toggle):第一次按开始,第二次按结束 — 适合长段话 + • 长按(Hold-to-speak):按住时录音,松开停 — 适合微信、豆包等需要"按住"的输入法 + + 两种模式在 Key 1 Inspector 的「触发方式」Tab 里切换。 + """) + + HelpNote("hand.raised.fill", tint: .red, body: "麦克风 + 输入监控 + 辅助功能三个权限都得给。打开「权限诊断」可以一键跳到系统设置对应页。") + } + } +} + +private struct DiagnosticsTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "stethoscope", + title: "权限诊断", + subtitle: "点底栏的「权限诊断」按钮打开(不是这里的页面)" + ) + + HelpSection(title: "权限清单", body: """ + • 蓝牙:连接键盘必须 + • 麦克风:苹果原生转写、AhaType、按住说话所有语音功能都需要 + • 输入监控:捕获语音键的按下/松开事件 + • 辅助功能:模拟键盘按键(用于 ⌘V 写回文本、注入 F18 等) + • 语音识别:苹果原生转写 + • Siri 与听写(macOS 13+):原生转写依赖项 + """) + + HelpSection(title: "Agent 健康检查", body: """ + 打开「权限诊断」可以看到 Agent 自检结果: + • LaunchAgent 已注册:login item 装好 + • 进程在跑:launchd 拉起了 ahakeyconfig-agent + • Hook 已配置:Claude/Cursor/Codex/Kimi 的 .json / settings 都加好了 ahakey-hook 引用 + """) + + HelpSection(title: "转写测试在哪", body: "权限诊断弹窗里。可以不连键盘就验证 macOS 原生转写是否能识别。如果转写失败,多半是麦克风权限或没装语言模型(系统设置 → Siri 与听写 → 听写语言)。") + } + } +} + +private struct FAQTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "questionmark.bubble", + title: "常见问题", + subtitle: "如果下面没你的问题,可以提 issue 到 GitHub 仓库" + ) + + faq( + q: "Hook 拦不住,AI 还是会停下来问我", + a: """ + 按这顺序排查: + 1. Agent 在跑吗?打开「权限诊断」看 + 2. Agent 是否占着蓝牙?画布顶部应显示已连接,且不在编辑态 + 3. 拨杆在 auto 档?看顶部状态栏;不是的话点画布拨杆切到 auto + 4. IDE 的 Hook 文件配了吗?「权限诊断」会列出 Claude/Cursor/Codex/Kimi 各自的 Hook 安装状态 + 5. 装完后是否重启过 IDE?尤其 Kimi 安装/升级后必须完全关闭再重开 + """ + ) + + faq( + q: "画布上灯条不变色", + a: """ + • 检查右上角是否「已连接」 + • 切到正在用的 Mode(auto 档下只有 Mode 0 灯效活跃) + • 触发一次工具调用让 Hook 真的发 0x90 给键盘 + • 如果是手动批准档 + Mode 0:preToolUse 是蓝、其他状态是红 + """ + ) + + faq( + q: "OLED 自动同步没触发", + a: """ + 自动同步只在主 App 自占 BLE 时才查图片元信息。流程: + 1. 至少点一次「修改」让主 App 接管 BLE + 2. 三个 Mode 的 0x83 查询完成后才会触发 + 3. 只对 flash 为空(picLength=0)的 Mode 生效 + 4. 如果你曾经手动改过 Inspector 里的「上传 GIF」路径,自动同步会跳过那个 Mode(不覆盖你的选择) + """ + ) + + faq( + q: "拨杆我点了,但键盘灯效没切", + a: """ + 灯效颜色是由键盘固件根据 sw_state GPIO 直接决定的。要让灯效跟着虚拟拨杆走,必须刷新版固件(含 0x91 set_sw_state 命令)。Hook 的批准行为不需要刷固件,软件覆盖即可生效。 + """ + ) + + faq( + q: "OTA 升级有吗?", + a: """ + 规划中,下一版本会做。当前所有固件升级都需要 USB-ISP(拆机短 BOOT + wchisp)。详细方案在仓库 docs 里。 + """ + ) + } + } + + private func faq(q: String, a: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "questionmark.circle.fill") + .foregroundStyle(.tint) + .padding(.top, 1) + Text(q).font(.callout.weight(.medium)) + } + Text(a) + .font(.callout) + .foregroundStyle(.primary) + .padding(.leading, 26) + .fixedSize(horizontal: false, vertical: true) + Divider().padding(.leading, 26).padding(.top, 4) + } + .padding(.vertical, 8) + } +} diff --git a/platforms/macos/Sources/Views/CompatLabeledContent.swift b/Sources/Views/CompatLabeledContent.swift similarity index 100% rename from platforms/macos/Sources/Views/CompatLabeledContent.swift rename to Sources/Views/CompatLabeledContent.swift diff --git a/Sources/Views/ContentView.swift b/Sources/Views/ContentView.swift new file mode 100644 index 00000000..ac03b57c --- /dev/null +++ b/Sources/Views/ContentView.swift @@ -0,0 +1,299 @@ +import AppKit +import SwiftUI + +struct ContentView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @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 { + mainWorkspace + .allowsHitTesting(!shouldShowUnifiedOnboarding) + .accessibilityHidden(shouldShowUnifiedOnboarding) + + if shouldShowUnifiedOnboarding { + UnifiedAhaKeyOnboardingView( + permissionState: onboardingPermissionState, + actions: onboardingActions + ) { _, _ in + unifiedOnboardingCompleted = true + dismissedIncompleteOnboardingThisSession = !onboardingPermissionState.allPermissionsGranted + voiceRelay.suppressPermissionOnboarding(for: 60) + } + .transition(.opacity) + .zIndex(20) + } + } + .onAppear { + if shouldShowUnifiedOnboarding { + voiceRelay.suppressPermissionOnboarding(for: 60) + } else { + voiceRelay.showsPermissionOnboarding = false + } + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .onChange(of: onboardingPermissionState.allPermissionsGranted) { allGranted in + if allGranted { + dismissedIncompleteOnboardingThisSession = false + unifiedOnboardingCompleted = true + voiceRelay.showsPermissionOnboarding = false + } else if shouldShowUnifiedOnboarding { + voiceRelay.suppressPermissionOnboarding(for: 60) + } + } + } + + @ViewBuilder + private var mainWorkspace: some View { + if #available(macOS 14.0, *) { + AhaKeyStudioView(bleManager: bleManager) + .focusEffectDisabled() + } else { + AhaKeyStudioView(bleManager: bleManager) + } + } + + private var onboardingPermissionState: AhaKeyOnboardingPermissionState { + AhaKeyOnboardingPermissionState( + bluetoothPermissionGranted: bleManager.bluetoothPermissionGranted, + bluetoothPoweredOn: bleManager.bluetoothPoweredOn, + inputMonitoringGranted: voiceRelay.inputMonitoringGranted, + accessibilityGranted: voiceRelay.accessibilityGranted, + microphoneGranted: nativeSpeech.microphoneGranted, + speechRecognitionGranted: nativeSpeech.speechRecognitionGranted, + siriEnabled: nativeSpeech.siriEnabled, + dictationEnabled: nativeSpeech.dictationEnabled, + voiceSummary: voiceRelay.statusMessage, + speechSummary: nativeSpeech.statusMessage, + isRecording: nativeSpeech.isRecording, + transcriptPreview: nativeSpeech.transcriptPreview, + lastCommittedText: nativeSpeech.lastCommittedText, + speechStatusMessage: nativeSpeech.statusMessage + ) + } + + private var shouldShowUnifiedOnboarding: Bool { + !unifiedOnboardingCompleted || + (!onboardingPermissionState.allPermissionsGranted && !dismissedIncompleteOnboardingThisSession) + } + + private var onboardingActions: AhaKeyOnboardingActions { + AhaKeyOnboardingActions( + requestPermissions: { + voiceRelay.suppressPermissionOnboarding() + requestOnboardingPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + }, + requestPermission: { kind in + voiceRelay.suppressPermissionOnboarding() + requestSingleOnboardingPermission( + kind, + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + }, + recheckPermissions: { + voiceRelay.suppressPermissionOnboarding() + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + }, + openSystemSettings: { + voiceRelay.suppressPermissionOnboarding() + openFirstMissingOnboardingPermissionSettings( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + }, + toggleTryExperience: { + voiceRelay.suppressPermissionOnboarding() + nativeSpeech.toggleRecordingFromVoiceKey() + } + ) + } +} + +@MainActor +private func requestSingleOnboardingPermission( + _ kind: AhaKeyOnboardingPermissionKind, + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService +) { + voiceRelay.suppressPermissionOnboarding(for: 60) + + switch kind { + case .bluetooth: + bleManager.refreshBluetoothAuthorization() + bleManager.userInitiatedConnect() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + bleManager.refreshBluetoothAuthorization() + if bleManager.bluetoothAuthorizationDeniedOrRestricted || !bleManager.bluetoothPoweredOn { + openOnboardingPermissionSettings(.bluetooth) + } + } + + case .inputMonitoring: + voiceRelay.requestInputMonitoringPermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + if !voiceRelay.inputMonitoringGranted { + openOnboardingPermissionSettings(.inputMonitoring) + } + } + + case .accessibility: + voiceRelay.requestAccessibilityPermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + if !voiceRelay.accessibilityGranted { + openOnboardingPermissionSettings(.accessibility) + } + } + + case .microphone: + nativeSpeech.requestMicrophonePermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + if !nativeSpeech.microphoneGranted { + openOnboardingPermissionSettings(.microphone) + } + } + + case .speechRecognition: + nativeSpeech.requestSpeechRecognitionPermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + if !nativeSpeech.speechRecognitionGranted { + openOnboardingPermissionSettings(.speechRecognition) + } + } + + case .siri, .dictation: + openOnboardingPermissionSettings(kind) + } +} + +@MainActor +private func requestOnboardingPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService, + delay: TimeInterval = 0.45 +) { + bleManager.refreshBluetoothAuthorization() + voiceRelay.suppressPermissionOnboarding() + voiceRelay.refreshPermissions(requestIfNeeded: true) + nativeSpeech.refreshPermissions(requestIfNeeded: true) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + voiceRelay.suppressPermissionOnboarding() + bleManager.refreshBluetoothAuthorization() + openFirstMissingOnboardingPermissionSettings( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + } +} + +@MainActor +private func openFirstMissingOnboardingPermissionSettings( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService +) { + if !bleManager.bluetoothPermissionGranted || !bleManager.bluetoothPoweredOn { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth"]) { return } + } + if !voiceRelay.inputMonitoringGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) { return } + } + if !voiceRelay.accessibilityGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) { return } + } + if !nativeSpeech.microphoneGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"]) { return } + } + if !nativeSpeech.speechRecognitionGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition"]) { return } + } + if !nativeSpeech.siriEnabled { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.Siri-Settings.extension"]) { return } + } + if !nativeSpeech.dictationEnabled { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.Keyboard-Settings.extension"]) { return } + } + openCombinedOnboardingPrivacySettingsURL() +} + +@MainActor +private func openOnboardingPermissionSettings(_ kind: AhaKeyOnboardingPermissionKind) { + let candidates: [String] + switch kind { + case .bluetooth: + candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", + "x-apple.systempreferences:com.apple.BluetoothSettings", + ] + case .inputMonitoring: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"] + case .accessibility: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"] + case .microphone: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"] + case .speechRecognition: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition"] + case .siri: + candidates = ["x-apple.systempreferences:com.apple.Siri-Settings.extension"] + case .dictation: + candidates = ["x-apple.systempreferences:com.apple.Keyboard-Settings.extension"] + } + if openFirstAvailableOnboardingSystemSettingsURL(candidates) { return } + openCombinedOnboardingPrivacySettingsURL() +} + +@MainActor +private func openCombinedOnboardingPrivacySettingsURL() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition", + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:com.apple.Keyboard-Settings.extension", + "x-apple.systempreferences:com.apple.preference.security?Privacy", + ] + if openFirstAvailableOnboardingSystemSettingsURL(candidates) { return } + let appPaths = [ + "/System/Applications/System Settings.app", + "/System/Library/CoreServices/Applications/System Settings.app", + "/System/Applications/System Preferences.app", + ] + for path in appPaths where FileManager.default.fileExists(atPath: path) { + if NSWorkspace.shared.open(URL(fileURLWithPath: path)) { + return + } + } +} + +@discardableResult +private func openFirstAvailableOnboardingSystemSettingsURL(_ candidates: [String]) -> Bool { + for candidate in candidates { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return true + } + } + return false +} diff --git a/platforms/macos/Sources/Views/DeviceInfoView.swift b/Sources/Views/DeviceInfoView.swift similarity index 86% rename from platforms/macos/Sources/Views/DeviceInfoView.swift rename to Sources/Views/DeviceInfoView.swift index 4a929868..86a069c7 100644 --- a/platforms/macos/Sources/Views/DeviceInfoView.swift +++ b/Sources/Views/DeviceInfoView.swift @@ -38,7 +38,7 @@ struct DeviceInfoView: View { // MARK: - 蓝牙连接(App 与 Agent 二选一) Section { VStack(alignment: .leading, spacing: 12) { - Text("主程序与 `ahakeyconfig-agent` 是两个独立进程;CoreBluetooth 同时只能有一个连接键盘。请在此显式切换由谁占用蓝牙。") + Text("同一时间只能由本 App 或 Agent 其中之一连接键盘,请在此切换。") .font(.callout) .foregroundStyle(.secondary) HStack(spacing: 10) { @@ -56,8 +56,8 @@ struct DeviceInfoView: View { Text(owner.title) .fontWeight(selected ? .semibold : .regular) Text(owner == .ahaKeyStudio - ? "改键、OLED、同步、本机灯效测试" - : "Claude/Cursor/Codex Hook、灯条状态、拨杆查询") + ? "改键、LCD、同步、本机灯效测试" + : "Claude/Cursor/Codex/Kimi Hook、灯条状态、拨杆查询") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.leading) @@ -85,8 +85,6 @@ struct DeviceInfoView: View { } } header: { Text("蓝牙连接") - } footer: { - Text("「Agent 已连接蓝牙」表示守护进程正在运行且已与键盘建立 BLE 连接(通过查询 socket switchState 确认)。「BLE 未连接」表示进程在跑但键盘尚未连上。选「AhaKey Studio」会改为由本 App 连接;选「Agent」会断开本 App 并启动守护进程。") } .alert("需要先安装 Agent", isPresented: $showAgentRequiredForAgentBLE) { Button("好", role: .cancel) {} @@ -107,21 +105,8 @@ struct DeviceInfoView: View { Text(switchStateLabel(bleManager.switchState)) } } - Text(switchStateDescription( - bleManager.switchState, - agentRunning: agentManager.isRunning, - agentInstalled: agentManager.isInstalled, - hooksReady: agentManager.hooksInstalled - )) - .font(.caption) - .foregroundStyle(.secondary) } header: { Text("拨杆档位") - } footer: { - VStack(alignment: .leading, spacing: 6) { - Text("拨杆是物理档位。0=自动批准、非0=交回 IDE 自己确认;Claude 用 PermissionRequest,Cursor 用 preToolUse/beforeShell 等,Codex 用 inline TOML Hooks。都需 Agent + Hooks 且蓝牙由 agent 持有。") - Text("若 Cursor 仍弹出「运行 Shell」:与 Aha hook 是两套。打开本页「查看日志」→ 选「cli-config」可查看/一键合并白名单与 approvalMode。") - } } // MARK: - LED 状态同步 @@ -140,6 +125,7 @@ struct DeviceInfoView: View { hookBadge("Claude", installed: agentManager.claudeHooksInstalled) hookBadge("Cursor", installed: agentManager.cursorHooksInstalled) hookBadge("Codex", installed: agentManager.codexHooksInstalled) + hookBadge("Kimi", installed: agentManager.kimiHooksInstalled) } .font(.caption) } @@ -217,20 +203,26 @@ struct DeviceInfoView: View { .buttonStyle(.borderless) .font(.caption) } + if agentManager.kimiHooksInstalled { + Button("移除 Kimi Hooks") { agentManager.removeKimiHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } else { + Button("安装 Kimi Hooks") { agentManager.installKimiHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } } } } header: { Text("LED 状态同步 · Hook 联动") } footer: { - VStack(alignment: .leading, spacing: 6) { - if !agentManager.isAgentBinaryPresentInBundle { - Text("发版包内未包含 ahakeyconfig-agent 可执行文件时,无法使用守护进程。请用完整「AhaKey Studio.app」或联系开发者。") - .foregroundStyle(.orange) - } else if agentManager.isInstalled, agentManager.bluetoothConnectionOwner == .ahaKeyStudio, !agentManager.isRunning { - Text("已安装 LaunchAgent 且当前由本 App 占蓝牙,因此 Agent 未加载:请先在上文将「蓝牙连接」选为「ahakeyconfig-agent」再观察运行状态,或点「安装并启用」时阅读弹窗说明。") - .foregroundStyle(.secondary) - } - Text("1) 键盘 LED 可随 IDE 状态变。2) 工具「自动批准」:Claude 走 PermissionRequest;Cursor 走 preToolUse;Codex 走 `~/.codex/config.toml` 的 inline `[[hooks.*]]`。3) 诊断见 AhaKeyConfig/diagnostics/permission-request.log 与 codex-hook.log。") + if !agentManager.isAgentBinaryPresentInBundle { + Text("发版包内未包含 ahakeyconfig-agent,无法使用守护进程。请用完整「AhaKey Studio.app」或联系开发者。") + .foregroundStyle(.orange) + } else if agentManager.isInstalled, agentManager.bluetoothConnectionOwner == .ahaKeyStudio, !agentManager.isRunning { + Text("已由本 App 占用蓝牙:要让 Agent 接管,请将「蓝牙连接」选为 ahakeyconfig-agent。") + .foregroundStyle(.secondary) } } .sheet(isPresented: $showAgentLog) { @@ -248,6 +240,7 @@ struct DeviceInfoView: View { Text("~/.cursor/cli-config.json").tag(3) Text("~/.codex/config.toml").tag(4) Text("Codex Hook(codex-hook.log)").tag(5) + Text("~/.kimi/config.toml").tag(6) } .labelsHidden() .pickerStyle(.menu) @@ -449,6 +442,17 @@ struct DeviceInfoView: View { Text("通信日志") } } + // 「设备信息」在 sheet 中展示时,父视图的 `.alert` 往往不会置顶显示,导致 Hooks 安装/报错像「无反应」。在此重复绑定以确保可见。 + .alert("Agent", isPresented: Binding( + get: { agentManager.agentUserAlert != nil }, + set: { if !$0 { agentManager.agentUserAlert = nil } } + )) { + Button("好", role: .cancel) { + agentManager.agentUserAlert = nil + } + } message: { + Text(agentManager.agentUserAlert ?? "") + } } @@ -490,22 +494,6 @@ struct DeviceInfoView: View { state == 0 ? "自动批准" : "手动批准" } - private func switchStateDescription(_ state: Int, agentRunning: Bool, agentInstalled: Bool, hooksReady: Bool) -> String { - let pieces: [String] - if state == 0 { - pieces = [ - "自动批准:Claude/Codex 为 PermissionRequest;Cursor 为 preToolUse 等。需 Agent、Hooks、蓝牙交给 Agent。", - agentBluetoothStatusTextForDescription(agentRunning: agentRunning, agentInstalled: agentInstalled), - hooksReady ? "Hooks 已配置" : "Hooks 未配置", - ] - } else { - pieces = [ - "手动:Claude/Cursor/Codex 批准链会交回 IDE 自己确认。", - ] - } - return pieces.joined(separator: " · ") - } - private func agentBluetoothStatusText() -> String { if agentManager.isRunning && agentManager.isAgentBLEConnected { return "Agent 已连接蓝牙" } if agentManager.isRunning { return "Agent 运行中(BLE 未连接)" } @@ -513,10 +501,6 @@ struct DeviceInfoView: View { return "Agent 未安装" } - private func agentBluetoothStatusTextForDescription(agentRunning: Bool, agentInstalled: Bool) -> String { - agentBluetoothStatusText() - } - private func agentBluetoothShortLabel() -> String { if agentManager.isRunning && agentManager.isAgentBLEConnected { return "已连蓝牙" } if agentManager.isRunning { return "BLE 未连接" } @@ -557,6 +541,8 @@ struct DeviceInfoView: View { Text(agentManager.readUserCodexConfigForDisplay()) case 5: Text(agentManager.readCodexHookLog()) + case 6: + Text(agentManager.readUserKimiConfigForDisplay()) default: Text("") } diff --git a/platforms/macos/Sources/Views/KeyMappingView.swift b/Sources/Views/KeyMappingView.swift similarity index 98% rename from platforms/macos/Sources/Views/KeyMappingView.swift rename to Sources/Views/KeyMappingView.swift index be8fcfbc..b050fba9 100644 --- a/platforms/macos/Sources/Views/KeyMappingView.swift +++ b/Sources/Views/KeyMappingView.swift @@ -91,7 +91,7 @@ struct KeyMappingView: View { } CompatLabeledContent("描述") { - TextField("显示在键盘 OLED 上", text: $keys[selectedKey].description) + TextField("显示在键盘 LCD 上", text: $keys[selectedKey].description) .textFieldStyle(.roundedBorder) .frame(width: 200) } diff --git a/platforms/macos/Sources/Views/OLEDManagerView.swift b/Sources/Views/OLEDManagerView.swift similarity index 100% rename from platforms/macos/Sources/Views/OLEDManagerView.swift rename to Sources/Views/OLEDManagerView.swift diff --git a/Sources/Views/UnifiedAhaKeyOnboardingView.swift b/Sources/Views/UnifiedAhaKeyOnboardingView.swift new file mode 100644 index 00000000..40bac128 --- /dev/null +++ b/Sources/Views/UnifiedAhaKeyOnboardingView.swift @@ -0,0 +1,697 @@ +import SwiftUI + +enum UnifiedOnboardingStorage { + static let completedKey = "AhaKey.UnifiedOnboarding.v2.completed" + static let micGrantedKey = "AhaKey.UnifiedOnboarding.v2.micPreGranted" + static let pasteGrantedKey = "AhaKey.UnifiedOnboarding.v2.pastePreGranted" +} + +struct AhaKeyOnboardingPermissionState: Equatable { + var bluetoothPermissionGranted: Bool + var bluetoothPoweredOn: Bool + var inputMonitoringGranted: Bool + var accessibilityGranted: Bool + var microphoneGranted: Bool + var speechRecognitionGranted: Bool + var siriEnabled: Bool + var dictationEnabled: Bool + var voiceSummary: String + var speechSummary: String + var isRecording: Bool + var transcriptPreview: String + var lastCommittedText: String + var speechStatusMessage: String + + var bluetoothReady: Bool { + bluetoothPermissionGranted && bluetoothPoweredOn + } + + var backgroundPermissionsGranted: Bool { + inputMonitoringGranted && accessibilityGranted + } + + var nativeSpeechPermissionsGranted: Bool { + microphoneGranted && speechRecognitionGranted && siriEnabled && dictationEnabled + } + + var allPermissionsGranted: Bool { + bluetoothReady && backgroundPermissionsGranted && nativeSpeechPermissionsGranted + } + + var canTrySpeechInput: Bool { + microphoneGranted && speechRecognitionGranted + } +} + +struct AhaKeyOnboardingActions { + var requestPermissions: () -> Void + var requestPermission: (AhaKeyOnboardingPermissionKind) -> Void + var recheckPermissions: () -> Void + var openSystemSettings: () -> Void + var toggleTryExperience: () -> Void +} + +enum AhaKeyOnboardingPermissionKind { + case bluetooth + case inputMonitoring + case accessibility + case microphone + case speechRecognition + case siri + case dictation +} + +struct UnifiedAhaKeyOnboardingView: View { + var permissionState: AhaKeyOnboardingPermissionState + var actions: AhaKeyOnboardingActions + var onCompleted: (_ micGranted: Bool, _ pasteGranted: Bool) -> Void + + @State private var step: AhaKeyOnboardingStep = .welcome + @State private var didRunTryExperience = false + + var body: some View { + GeometryReader { geometry in + let compact = geometry.size.width < 980 + let contentMinHeight = max(420, geometry.size.height - 116) + VStack(spacing: 0) { + topBar + Divider().opacity(0.45) + if compact { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + mainPanel + guidePanel + } + .padding(24) + .padding(.bottom, 12) + } + } else { + ScrollView { + HStack(alignment: .top, spacing: 0) { + mainPanel + .frame(width: max(500, geometry.size.width * 0.48), alignment: .topLeading) + .padding(.horizontal, 48) + .padding(.vertical, 34) + .background(Color(nsColor: .textBackgroundColor)) + + Divider().opacity(0.45) + + guidePanel + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, 46) + .padding(.vertical, 34) + .background(Color(nsColor: .windowBackgroundColor)) + } + .frame(maxWidth: .infinity, minHeight: contentMinHeight, alignment: .topLeading) + } + } + Divider().opacity(0.45) + bottomNavigationBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .onChange(of: permissionState.transcriptPreview) { newValue in + if !newValue.isEmpty { + didRunTryExperience = true + } + } + .onChange(of: permissionState.lastCommittedText) { newValue in + if !newValue.isEmpty { + didRunTryExperience = true + } + } + } + + // MARK: - Top Bar + + private var topBar: some View { + HStack(spacing: 18) { + Spacer(minLength: 0) + stepper + Spacer(minLength: 0) + Button("跳过") { + finish() + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .padding(.trailing, 22) + } + .padding(.vertical, 12) + .background(Color(nsColor: .textBackgroundColor)) + } + + // 顶部步骤导航,所有步骤均可点击跳转 + private var stepper: some View { + HStack(spacing: 12) { + ForEach(AhaKeyOnboardingStep.allCases) { item in + HStack(spacing: 12) { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + step = item + } + } label: { + Text(item.title) + .font(.system(size: 15, weight: step == item ? .semibold : .medium)) + .foregroundStyle(step == item ? Color.primary : Color.secondary) + .frame(width: 78, height: 34) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(step == item ? Color.primary : Color.clear) + .frame(height: 2) + } + } + .buttonStyle(.plain) + + if item != AhaKeyOnboardingStep.allCases.last { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + } + } + } + } + + // MARK: - Main Panel + + @ViewBuilder + private var mainPanel: some View { + VStack(alignment: .leading, spacing: 0) { + switch step { + case .welcome: + welcomePanel + case .systemPermissions: + systemPermissionsPanel + case .voicePermissions: + voicePermissionsPanel + case .tryInput: + tryInputPanel + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var welcomePanel: some View { + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 12) { + Text("在这台 Mac 上设置 AhaKey") + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(.primary) + Text("完成键盘连接、后台语音键接管、macOS 原生语音和一次真实输入体验。") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 14) { + onboardingCard(systemImage: "keyboard", title: "连接与控制", detail: "开启蓝牙后,AhaKey Studio 会接管出厂语音键并同步当前 Mode。") + onboardingCard(systemImage: "lock.shield", title: "分步授权", detail: "先设置蓝牙、输入监控和辅助功能,再设置麦克风、语音转写、Siri 与听写。") + onboardingCard(systemImage: "mic", title: "体验输入", detail: "最后可以直接口述一句话,确认识别和写入链路都已准备好。") + } + } + } + + private var systemPermissionsPanel: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader( + title: "第一步:系统控制授权", + detail: "这些权限用于连接键盘,以及让语音键在后台稳定地触发 AhaKey Studio。" + ) + + VStack(spacing: 12) { + PermissionStatusRow( + title: "蓝牙", + detail: bluetoothDetail, + granted: permissionState.bluetoothReady, + actionTitle: permissionState.bluetoothReady ? nil : "申请", + action: { actions.requestPermission(.bluetooth) } + ) + PermissionStatusRow( + title: "输入监控", + detail: "允许 AhaKey Studio 在后台监听实体语音键。", + granted: permissionState.inputMonitoringGranted, + actionTitle: permissionState.inputMonitoringGranted ? nil : "申请", + action: { actions.requestPermission(.inputMonitoring) } + ) + PermissionStatusRow( + title: "辅助功能", + detail: "允许 AhaKey Studio 把语音键转换成 macOS 原生转写或 Fn/Globe。", + granted: permissionState.accessibilityGranted, + actionTitle: permissionState.accessibilityGranted ? nil : "申请", + action: { actions.requestPermission(.accessibility) } + ) + } + + HStack(spacing: 10) { + Button("重新检查") { + actions.recheckPermissions() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + } + + } + } + + private var voicePermissionsPanel: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader( + title: "第二步:macOS 原生语音授权", + detail: "默认使用 macOS 原生语音时,需要麦克风、语音转写、Siri 与听写都可用。" + ) + + VStack(spacing: 12) { + PermissionStatusRow( + title: "麦克风", + detail: "允许 AhaKey Studio 使用苹果原生语音采集。", + granted: permissionState.microphoneGranted, + actionTitle: permissionState.microphoneGranted ? nil : "申请", + action: { actions.requestPermission(.microphone) } + ) + PermissionStatusRow( + title: "语音转写", + detail: "允许 AhaKey Studio 使用苹果原生语音识别。", + granted: permissionState.speechRecognitionGranted, + actionTitle: permissionState.speechRecognitionGranted ? nil : "申请", + action: { actions.requestPermission(.speechRecognition) } + ) + PermissionStatusRow( + title: "Siri", + detail: "在系统设置 > Siri 与聚焦里开启 Siri。", + granted: permissionState.siriEnabled, + actionTitle: permissionState.siriEnabled ? nil : "打开设置", + action: { actions.requestPermission(.siri) } + ) + PermissionStatusRow( + title: "听写", + detail: "在系统设置 > 键盘 > 听写里开启听写。", + granted: permissionState.dictationEnabled, + actionTitle: permissionState.dictationEnabled ? nil : "打开设置", + action: { actions.requestPermission(.dictation) } + ) + } + + HStack(spacing: 10) { + Button("重新检查") { + actions.recheckPermissions() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + } + + } + } + + private var tryInputPanel: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader( + title: "第三步:体验输入", + detail: "按下方按钮或键盘上的语音键,说一句话,再结束录音,确认文字可以写入当前光标。" + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Circle() + .fill(permissionState.isRecording ? Color.red : (permissionState.canTrySpeechInput ? Color.green : Color.orange)) + .frame(width: 10, height: 10) + Text(permissionState.isRecording ? "录音中" : (permissionState.canTrySpeechInput ? "语音已准备" : "仍缺语音权限")) + .font(.system(size: 15, weight: .semibold)) + } + + Text(tryPreviewText) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, minHeight: 110, alignment: .topLeading) + .padding(18) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + + Text(permissionState.speechStatusMessage) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 10) { + Button(permissionState.isRecording ? "结束并写入" : "开始试说") { + didRunTryExperience = true + actions.toggleTryExperience() + } + .buttonStyle(OnboardingPrimaryButtonStyle()) + .disabled(!permissionState.canTrySpeechInput) + + Button("重新检查") { + actions.recheckPermissions() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + } + + } + } + + // MARK: - Guide Panel(右侧,分组高亮) + + private var guidePanel: some View { + VStack(alignment: .leading, spacing: 22) { + Text(step.guideTitle) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.primary) + Text(step.guideDetail) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 10) { + PermissionGroupSection( + groupLabel: "系统授权", + isHighlighted: step == .systemPermissions, + items: [ + ("蓝牙", permissionState.bluetoothReady), + ("输入监控", permissionState.inputMonitoringGranted), + ("辅助功能", permissionState.accessibilityGranted), + ] + ) + + PermissionGroupSection( + groupLabel: "语音授权", + isHighlighted: step == .voicePermissions || step == .tryInput, + items: [ + ("麦克风", permissionState.microphoneGranted), + ("语音转写", permissionState.speechRecognitionGranted), + ("Siri", permissionState.siriEnabled), + ("听写", permissionState.dictationEnabled), + ] + ) + } + + Divider().opacity(0.45) + + VStack(alignment: .leading, spacing: 8) { + Text("当前状态") + .font(.system(size: 15, weight: .semibold)) + Text(permissionState.voiceSummary) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Text(permissionState.speechSummary) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + } + + // MARK: - Bottom Navigation(文字次级按钮) + + private var bottomNavigationBar: some View { + HStack(spacing: 0) { + Spacer() + + if step != .welcome { + Button("上一步") { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + step = step.previous + } + } + .buttonStyle(OnboardingTextButtonStyle()) + } + + Button(bottomNextTitle) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + goForward() + } + } + .buttonStyle(OnboardingPrimaryButtonStyle()) + .padding(.leading, step == .welcome ? 0 : 8) + + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + .padding(.bottom, 20) + .background(Color(nsColor: .textBackgroundColor)) + } + + private var bottomNextTitle: String { + switch step { + case .welcome: return "开始设置" + case .tryInput: return "进入工作台" + default: return "下一步" + } + } + + // MARK: - Helpers + + private var bluetoothDetail: String { + if !permissionState.bluetoothPermissionGranted { + return "允许 AhaKey Studio 扫描并连接 AhaKey 键盘。" + } + if !permissionState.bluetoothPoweredOn { + return "已授权,但系统蓝牙当前关闭,请在控制中心或系统设置中打开。" + } + return "蓝牙可用,可以扫描并连接键盘。" + } + + private var tryPreviewText: String { + if !permissionState.transcriptPreview.isEmpty { + return permissionState.transcriptPreview + } + if !permissionState.lastCommittedText.isEmpty { + return permissionState.lastCommittedText + } + return "这里会显示实时识别或最近写入的内容。" + } + + private func sectionHeader(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(.primary) + Text(detail) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func onboardingCard(systemImage: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 32, height: 32) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + Text(detail) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(16) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } + + private func goForward() { + if step == .tryInput { + finish() + return + } + step = AhaKeyOnboardingStep(rawValue: min(AhaKeyOnboardingStep.tryInput.rawValue, step.rawValue + 1)) ?? .tryInput + } + + private func finish() { + UserDefaults.standard.set(permissionState.microphoneGranted, forKey: UnifiedOnboardingStorage.micGrantedKey) + UserDefaults.standard.set(permissionState.backgroundPermissionsGranted, forKey: UnifiedOnboardingStorage.pasteGrantedKey) + onCompleted(permissionState.microphoneGranted, permissionState.backgroundPermissionsGranted) + } +} + +// MARK: - Permission Group Section(右侧分组视窗) + +private struct PermissionGroupSection: View { + var groupLabel: String + var isHighlighted: Bool + var items: [(String, Bool)] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(groupLabel) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isHighlighted ? Color.accentColor : Color.secondary) + .textCase(.uppercase) + .padding(.horizontal, 4) + + VStack(alignment: .leading, spacing: 6) { + ForEach(items, id: \.0) { title, granted in + summaryRow(title: title, granted: granted) + } + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isHighlighted ? Color.accentColor.opacity(0.06) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isHighlighted ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1.5) + ) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isHighlighted) + } + + private func summaryRow(title: String, granted: Bool) -> some View { + HStack(spacing: 9) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 9, height: 9) + Text(title) + .font(.system(size: 14, weight: .medium)) + Spacer() + Text(granted ? "已开启" : "待开启") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(granted ? Color.green : Color.orange) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Permission Status Row + +private struct PermissionStatusRow: View { + var title: String + var detail: String + var granted: Bool + var actionTitle: String? = nil + var action: (() -> Void)? = nil + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 10, height: 10) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + Text(granted ? "已开启" : "待开启") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(granted ? Color.green : Color.orange) + } + Text(detail) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + if let actionTitle, let action { + if granted { + Button(actionTitle) { + action() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + .disabled(true) + .padding(.top, 1) + } else { + Button(actionTitle) { + action() + } + .buttonStyle(OnboardingPrimaryButtonStyle()) + .padding(.top, 1) + } + } + } + .padding(16) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Onboarding Steps + +private enum AhaKeyOnboardingStep: Int, CaseIterable, Identifiable { + case welcome + case systemPermissions + case voicePermissions + case tryInput + + var id: Int { rawValue } + + var title: String { + switch self { + case .welcome: return "欢迎" + case .systemPermissions: return "系统授权" + case .voicePermissions: return "语音授权" + case .tryInput: return "开始体验" + } + } + + var previous: AhaKeyOnboardingStep { + AhaKeyOnboardingStep(rawValue: max(0, rawValue - 1)) ?? .welcome + } + + var guideTitle: String { + switch self { + case .welcome: return "设置路线" + case .systemPermissions: return "先让键盘和后台控制可用" + case .voicePermissions: return "再打开 macOS 原生语音" + case .tryInput: return "最后试一次真实输入" + } + } + + var guideDetail: String { + switch self { + case .welcome: + return "引导会按实际功能分为系统授权、语音授权和体验输入,完成后进入主工作台。" + case .systemPermissions: + return "蓝牙负责设备连接;输入监控和辅助功能负责后台接管语音键。" + case .voicePermissions: + return "麦克风和语音转写负责识别;Siri 与听写是 macOS 原生语音链路的系统开关。" + case .tryInput: + return "这里使用软件内同一套语音链路测试,不再只是展示授权状态。" + } + } +} + +// MARK: - Button Styles + +private struct OnboardingPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .frame(height: 34) + .background(Color.accentColor.opacity(configuration.isPressed ? 0.82 : 1.0), in: RoundedRectangle(cornerRadius: 7)) + } +} + +private struct OnboardingSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .frame(height: 34) + .background(Color(nsColor: .controlBackgroundColor).opacity(configuration.isPressed ? 0.7 : 1.0), in: RoundedRectangle(cornerRadius: 7)) + } +} + +// 次级文字按钮(底部导航"上一步"使用) +private struct OnboardingTextButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(configuration.isPressed ? Color.secondary : Color.primary) + .padding(.horizontal, 16) + .frame(height: 34) + .contentShape(Rectangle()) + } +} diff --git a/platforms/macos/VibeCodeKeyboard.ico b/VibeCodeKeyboard.ico similarity index 100% rename from platforms/macos/VibeCodeKeyboard.ico rename to VibeCodeKeyboard.ico diff --git a/ahakeyconfig-mac/Package.swift b/ahakeyconfig-mac/Package.swift new file mode 100644 index 00000000..8cadb087 --- /dev/null +++ b/ahakeyconfig-mac/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 + +import PackageDescription +let package = Package( + name: "AhaKeyConfig", + platforms: [ + .macOS("12.0") + ], + products: [ + .executable(name: "AhaKeyConfig", targets: ["AhaKeyConfig"]), + .executable(name: "ahakeyconfig-agent", targets: ["AhaKeyConfigAgent"]), + ], + + targets: [ + .executableTarget( + name: "AhaKeyConfig", + path: "Sources", + exclude: ["Agent"], + // 与 scripts/build.sh 中 Info.plist 一致。嵌入 __info_plist 段后 TCC 可识别。 + // Debug 使用单独 plist:系统在「隐私与安全性」列表中显示为「AhaKey Studio(调试)」,与正式包区分。 + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist", + ], .when(platforms: [.macOS], configuration: .debug)), + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "Packaging/AhaKeyConfig-EmbeddedInfo.plist", + ], .when(platforms: [.macOS], configuration: .release)), + ] + ), + .executableTarget( + name: "AhaKeyConfigAgent", + path: "Sources/Agent" + ), + ] +) diff --git a/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist new file mode 100644 index 00000000..95f64516 --- /dev/null +++ b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist @@ -0,0 +1,18 @@ + + + + + CFBundleIdentifier + lab.jawa.ahakeyconfig + CFBundleDisplayName + AhaKey Studio(调试) + CFBundleName + AhaKey Studio(调试) + NSBluetoothAlwaysUsageDescription + AhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescription + AhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescription + AhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + diff --git a/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist new file mode 100644 index 00000000..0729f1e9 --- /dev/null +++ b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist @@ -0,0 +1,16 @@ + + + + + CFBundleIdentifier + lab.jawa.ahakeyconfig + CFBundleDisplayName + AhaKey Studio + NSBluetoothAlwaysUsageDescription + AhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescription + AhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescription + AhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + diff --git a/ahakeyconfig-mac/Resources/AppIcon/AhaKeyStudioIcon.png b/ahakeyconfig-mac/Resources/AppIcon/AhaKeyStudioIcon.png new file mode 100644 index 00000000..43f3bd26 Binary files /dev/null and b/ahakeyconfig-mac/Resources/AppIcon/AhaKeyStudioIcon.png differ diff --git a/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif b/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif new file mode 100644 index 00000000..4e21caf2 Binary files /dev/null and b/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif differ diff --git a/ahakeyconfig-mac/Resources/DefaultOLED/codex.gif b/ahakeyconfig-mac/Resources/DefaultOLED/codex.gif new file mode 100644 index 00000000..7558413c Binary files /dev/null and b/ahakeyconfig-mac/Resources/DefaultOLED/codex.gif differ diff --git a/ahakeyconfig-mac/Resources/DefaultOLED/cursor.gif b/ahakeyconfig-mac/Resources/DefaultOLED/cursor.gif new file mode 100644 index 00000000..53bb3758 Binary files /dev/null and b/ahakeyconfig-mac/Resources/DefaultOLED/cursor.gif differ diff --git a/ahakeyconfig-mac/Resources/Help/AhaKeyStudioHelp.html b/ahakeyconfig-mac/Resources/Help/AhaKeyStudioHelp.html new file mode 100644 index 00000000..41337bab --- /dev/null +++ b/ahakeyconfig-mac/Resources/Help/AhaKeyStudioHelp.html @@ -0,0 +1,162 @@ + + + + + + AhaKey Studio 帮助中心 + + + +
+

AhaKey Studio 帮助中心

+

这份本地说明对应当前客户端界面和代码逻辑,适用于首次设置、权限排查、AhaType 云端整理和键盘配置。

+ +

首次启动流程

+

首次打开 AhaKey Studio 会进入新手引导。引导顶部按步骤显示,底部使用“上一步”和“下一步”推进。

+
+
+ 欢迎 + 介绍连接键盘、后台语音键接管、macOS 原生语音和体验输入。 +
+
+ 系统授权 + 检查蓝牙、输入监控和辅助功能。蓝牙用于发现和连接键盘,输入监控与辅助功能用于后台接管实体语音键。 +
+
+ 语音授权 + 检查麦克风、语音转写、Siri 与听写。默认 macOS 原生语音需要这些能力都可用。 +
+
+ 开始体验 + 使用客户端内同一套语音链路试说一句话,确认识别和写入链路正常。 +
+
+ +

需要开启的系统权限

+
    +
  1. 蓝牙:在“隐私与安全性 > 蓝牙”允许 AhaKey Studio,并确保系统蓝牙已打开。
  2. +
  3. 输入监控:在“隐私与安全性 > 输入监控”允许 AhaKey Studio,用于后台监听实体语音键。
  4. +
  5. 辅助功能:在“隐私与安全性 > 辅助功能”允许 AhaKey Studio,用于把语音键转换成 macOS 原生转写或 Fn/Globe 行为。
  6. +
  7. 麦克风:在“隐私与安全性 > 麦克风”允许 AhaKey Studio,用于苹果原生语音采集。
  8. +
  9. 语音转写:在“隐私与安全性 > 语音识别”允许 AhaKey Studio,用于系统语音识别。
  10. +
  11. Siri:在“系统设置 > Siri 与聚焦”开启 Siri。
  12. +
  13. 听写:在“系统设置 > 键盘 > 听写”开启听写。
  14. +
+
如果系统设置里已经打开权限,但客户端仍显示未开启,请完全退出 AhaKey Studio 后重新打开。输入监控和辅助功能常按进程生效,重启后状态会与系统设置一致。
+ +

主界面区域

+

顶部状态栏

+

顶部显示设备连接状态、电量、拨杆状态、AhaType 开关和键盘控制状态。“更多”菜单提供重新连接设备、清空 LCD 预览、设备信息、云端账号和退出等入口。

+ +

Keyboard Mode

+

Mode 标签对应键盘不同工作模式。Claude Code、Cursor、Codex 三个预设会影响实体按键写入内容和语音键路由。

+ +

配置区

+

点击灯条、屏幕、四个按键或拨杆可以进入对应配置。修改会先保存到本地,连接设备并进入编辑配置后再同步到键盘。

+ +

右侧检查面板

+

“后台监听中”展示输入监控和辅助功能状态;“苹果原生转写”展示麦克风、语音转写、Siri 与听写状态;“AhaType 云端整理”展示云端整理开关、账号状态和额度信息。

+ +

AhaType 云端整理

+

开启 AhaType 后,语音识别结果会先发送到云端整理,再写入当前光标。若未登录、登录过期或额度不足,客户端会退回写入原始转写文本。

+

可在“更多 > 云端账号 · AhaType”登录、注册、刷新额度、兑换或购买额度。

+ +

语音键体验

+
    +
  1. 保持 AhaKey Studio 在后台运行。
  2. +
  3. 确认权限检查里输入监控、辅助功能、麦克风、语音转写、Siri 与听写均已开启。
  4. +
  5. 确认设备当前在 Mode 0,或在目标 Mode 中已配置语音键。
  6. +
  7. 按一次实体语音键开始录音,再按一次结束并写入当前光标。
  8. +
+ +

底部状态栏

+

底部会显示当前选中的部件和模式、未同步改动数量、同步提示和最近同步时间。右下角的“新手引导”可重新进入分步引导,“帮助中心”会打开本文件。

+ +

常见问题

+

为什么麦克风已打开仍提示未开启?

+

请完全退出并重新打开 AhaKey Studio。若应用路径或签名发生变化,macOS 可能把新版本视为另一个应用,需要对当前正式包重新授权。

+ +

为什么系统设置没有自动跳到准确页面?

+

不同 macOS 版本的系统设置 URL 支持不完全一致。客户端会优先打开最接近的权限页;如果没有定位到目标项,请在“隐私与安全性”中手动选择对应权限。

+ +

为什么连接状态显示由 Agent 控制?

+

日常使用由后台 Agent 持有蓝牙连接;需要改键、LCD、同步或本机灯效测试时,点击“编辑配置”由 AhaKey Studio 临时接管蓝牙。

+
+ + diff --git a/platforms/macos/Sources/Agent/AhaKeyAgent.swift b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift similarity index 58% rename from platforms/macos/Sources/Agent/AhaKeyAgent.swift rename to ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift index a42d6d55..861155a7 100644 --- a/platforms/macos/Sources/Agent/AhaKeyAgent.swift +++ b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift @@ -28,7 +28,7 @@ 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 = "vibe code" + private let deviceNamePrefix = "AhaKey" private let socketPath: String private let header: [UInt8] = [0xAA, 0xBB] @@ -39,21 +39,68 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat private(set) var cachedSwitchState: UInt8? /// 最新 lightMode private(set) var cachedLightMode: UInt8? + /// 用户刚点击画布虚拟拨杆时的短暂模拟值。它只用于等待设备下一次真实状态回包, + /// 真实硬件状态一到就立即清除,不能覆盖物理拨杆。 + private(set) var userSwitchOverride: UInt8? + + private static let switchOverrideDefaultsKey = "lab.jawa.ahakeyconfig.agent.userSwitchOverride" /// 等待下一次 status 回包的回调队列(用于 querySwitchState) private var statusWaiters: [(AgentDeviceStatus?) -> Void] = [] + /// 工具完成 / 用户提交等短暂态的自动回落。 + private var pendingStateReset: DispatchWorkItem? + /// 固件不会在拨杆变动时主动通知,因此 Agent 占用蓝牙时也必须轮询真实状态。 + private var statusPollTimer: DispatchSourceTimer? + + // MARK: 看门狗(Claude Code 手动停止任务时 Stop hook 不触发,超时后自动归位) + /// 最近一次 hook 发来状态命令的时间(nil = 尚未收到) + private var lastHookStateAt: Date? + /// 最近一次我们主动发给键盘的 LED 状态 + private var lastSentState: UInt8 = 0 + private var watchdogTimer: DispatchSourceTimer? + + /// 各活跃态超时时长(秒): + /// 1=PermissionRequest / 7=UserPromptSubmit → 30s(等待阶段,手动停止后无 hook 跟进) + /// 其余工具执行态 → 60s(工具可能运行较久,避免误触发) + private func watchdogTimeout(for state: UInt8) -> Double { + switch state { + case 1, 7: return 30 // PermissionRequest / UserPromptSubmit:短超时 + default: return 60 // PreToolUse / PostToolUse / SessionStart / TaskCompleted + } + } var onLog: ((String) -> Void)? init(socketPath: String = "/tmp/ahakey.sock") { self.socketPath = socketPath + // 旧版会持久化虚拟拨杆覆盖,导致真实硬件档位永远无法回写。迁移时清除它。 + UserDefaults.standard.removeObject(forKey: Self.switchOverrideDefaultsKey) super.init() central = CBCentralManager(delegate: self, queue: nil) + Self.clearLiveSwitchState() + } + + /// 虚拟拨杆只在等待真实回包的短暂窗口内生效;随后一律使用键盘 GPIO 状态。 + var effectiveSwitchState: UInt8? { + userSwitchOverride ?? cachedSwitchState + } + + func setSwitchOverride(_ value: UInt8?) { + userSwitchOverride = value + // 最新固件中 0x91 已用于灯效预览;拨杆只保留 hook 软件覆盖,不再向键盘发送旧 0x91。 + if let v = value { + emit("拨杆 \(v) 仅作临时软件模拟,下一次真实状态回包会接管。") + } + // 把覆盖值写进共享文件,主 App 立刻看到画布拨杆位置更新 + Self.writeLiveState(switchState: effectiveSwitchState) + emit("拨杆覆盖 = \(value.map { String($0) } ?? "清除")(effective=\(effectiveSwitchState.map { String($0) } ?? "未知"))") } // MARK: - Public func sendState(_ state: UInt8) { + pendingStateReset?.cancel() + pendingStateReset = nil guard let commandChar, let peripheral else { emit("LED 状态 \(state): 未连接") return @@ -62,7 +109,82 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat let wt: CBCharacteristicWriteType = commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse peripheral.writeValue(data, for: commandChar, type: wt) + lastSentState = state emit("→ LED 状态 \(state): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))") + Self.writeLiveState(stateValue: state) + } + + /// 把 agent 当前对键盘的认知(最近一次 hook 发送的 stateValue + BLE 上报的 lightMode/switchState/workMode) + /// merge-write 到共享文件,供主 App 在 agent 拥有 BLE 时读取实时状态。 + /// 任意调用方只传自己负责更新的字段;未传的字段保留文件中的旧值。 + static func writeLiveState(stateValue: UInt8? = nil, + lightMode: UInt8? = nil, + switchState: UInt8? = nil, + workMode: UInt8? = nil) { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("current-ide-state.json") + var obj: [String: Any] = [:] + if let existing = try? Data(contentsOf: url), + let parsed = (try? JSONSerialization.jsonObject(with: existing)) as? [String: Any] { + obj = parsed + } + let now = Date().timeIntervalSince1970 + if let s = stateValue { + obj["stateValue"] = Int(s) + obj["stateTs"] = now + } + if let lm = lightMode { + obj["lightMode"] = Int(lm) + obj["lightModeTs"] = now + } + if let sw = switchState { + obj["switchState"] = Int(sw) + } + if let wm = workMode { + obj["workMode"] = Int(wm) + } + obj["ts"] = now + if let data = try? JSONSerialization.data(withJSONObject: obj) { + try? data.write(to: url, options: .atomic) + } + } + + private static func clearLiveSwitchState() { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/current-ide-state.json") + guard let data = try? Data(contentsOf: url), + var obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return } + obj.removeValue(forKey: "switchState") + obj["ts"] = Date().timeIntervalSince1970 + guard let encoded = try? JSONSerialization.data(withJSONObject: obj) else { return } + try? encoded.write(to: url, options: .atomic) + } + + private func requestDeviceStatus() { + guard let commandChar, let peripheral else { return } + let query = Data(header + [0x00] + trailer) + let wt: CBCharacteristicWriteType = + commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(query, for: commandChar, type: wt) + } + + private func startStatusPolling() { + statusPollTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 1.0, repeating: 1.5) + timer.setEventHandler { [weak self] in + guard let self, self.statusWaiters.isEmpty else { return } + self.requestDeviceStatus() + } + statusPollTimer = timer + timer.resume() + } + + private func stopStatusPolling() { + statusPollTimer?.cancel() + statusPollTimer = nil } /// 主动查询一次设备状态,等待下一个 notify 回包 (timeout 秒内)。 @@ -74,10 +196,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat return } // 发设备状态查询命令 AA BB 00 CC DD - let query = Data(header + [0x00] + trailer) - let wt: CBCharacteristicWriteType = - commandChar.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse - peripheral.writeValue(query, for: commandChar, type: wt) + requestDeviceStatus() statusWaiters.append(completion) DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in @@ -100,6 +219,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } func startSocketListener() { + startWatchdog() // 清理旧 socket unlink(socketPath) @@ -134,6 +254,30 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } } + // MARK: - 看门狗 + + private func startWatchdog() { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 30, repeating: 10) + timer.setEventHandler { [weak self] in + self?.checkWatchdog() + } + timer.resume() + watchdogTimer = timer + } + + private func checkWatchdog() { + guard let lastAt = lastHookStateAt else { return } + let activeStates: [UInt8] = [1, 2, 3, 4, 6, 7] + guard activeStates.contains(lastSentState) else { return } + let elapsed = Date().timeIntervalSince(lastAt) + let threshold = watchdogTimeout(for: lastSentState) + guard elapsed >= threshold else { return } + emit("⏰ 看门狗:\(Int(elapsed))s 无 hook 活动(上次 LED=\(lastSentState),阈值 \(Int(threshold))s),自动发 Stop(5)") + sendState(5) + lastHookStateAt = nil // 重置,避免重复触发 + } + // MARK: - Socket handling /// 单个客户端的处理:读一包请求,按 JSON 或旧版纯数字分发。 @@ -171,16 +315,30 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat switch cmd { case "state": if let v = obj["value"] as? Int { + lastHookStateAt = Date() sendState(UInt8(clamping: v)) } Self.replyAndClose(clientFd, ["ok": true]) + case "state_with_reset": + let stateValue = obj["value"] as? Int ?? 0 + let resetValue = obj["resetValue"] as? Int ?? 4 + let delayMs = max(0, obj["delayMs"] as? Int ?? 1200) + sendState(UInt8(clamping: stateValue)) + scheduleStateReset( + to: UInt8(clamping: resetValue), + afterMs: delayMs, + reason: "temporary state \(stateValue) -> reset \(resetValue)" + ) + Self.replyAndClose(clientFd, ["ok": true]) + case "permission": // 发 PermissionRequest 对应的 state(默认 1),同时主动查询拨杆 let stateValue = obj["value"] as? Int ?? 1 + lastHookStateAt = Date() sendState(UInt8(clamping: stateValue)) querySwitchState(timeout: 1.5) { status in - let body = Self.statusReply(status, cachedSwitch: self.cachedSwitchState, cachedLight: self.cachedLightMode) + let body = Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode) self.emit("← permission 回包 switchState=\(String(describing: body["switchState"]))") if let s = body["switchState"] as? Int, s != 0 { self.emit("(拨杆非 0:PermissionRequest 将交回终端手动确认)") @@ -191,16 +349,31 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } case "status": - if cachedSwitchState != nil { - Self.replyAndClose(clientFd, [ - "switchState": cachedSwitchState.map { Int($0) } ?? NSNull(), - "lightMode": cachedLightMode.map { Int($0) } ?? NSNull(), - ]) - } else { - querySwitchState(timeout: 1.5) { status in - Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.cachedSwitchState, cachedLight: self.cachedLightMode)) - } + // 每次都请求真实 GPIO 状态,不能因旧缓存或虚拟模拟而返回过期档位。 + querySwitchState(timeout: 1.5) { status in + Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode)) + } + + case "approval_status": + // 给 Kimi CLI 的实时批准判断用:每次都主动向设备要当前拨杆,避免会话内沿用旧的 yolo/state。 + querySwitchState(timeout: 1.5) { status in + Self.replyAndClose(clientFd, Self.statusReply(status, cachedSwitch: self.effectiveSwitchState, cachedLight: self.cachedLightMode)) + } + + case "set_switch_override": + // 主 App 画布虚拟拨杆点击 → 设置 / 清除覆盖 + // value=null / 缺省 → 清除(恢复用真实 BLE 上报) + // value=0/1/2 → 设置短暂模拟,真实状态回包会自动清除 + if obj["value"] is NSNull || obj["value"] == nil { + setSwitchOverride(nil) + } else if let v = obj["value"] as? Int { + setSwitchOverride(UInt8(clamping: v)) } + Self.replyAndClose(clientFd, [ + "ok": true, + "switchState": effectiveSwitchState.map { Int($0) } ?? NSNull(), + "override": userSwitchOverride.map { Int($0) } ?? NSNull(), + ]) default: Self.replyAndClose(clientFd, ["error": "unknown cmd: \(cmd)"]) @@ -219,6 +392,17 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat ] } + private func scheduleStateReset(to state: UInt8, afterMs: Int, reason: String) { + pendingStateReset?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.sendState(state) + self.emit("自动回落灯态:\(reason)") + } + pendingStateReset = work + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(afterMs), execute: work) + } + private static func replyAndClose(_ fd: Int32, _ dict: [String: Any]) { DispatchQueue.global(qos: .utility).async { if let data = try? JSONSerialization.data(withJSONObject: dict, options: []) { @@ -250,7 +434,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) }) { + if let p = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(deviceNamePrefix.lowercased()) }) { emit("系统已连接: \(p.name ?? "?")") peripheral = p p.delegate = self @@ -280,7 +464,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) else { return } + guard name.lowercased().hasPrefix(deviceNamePrefix.lowercased()) else { return } central.stopScan() emit("发现: \(name)") self.peripheral = peripheral @@ -295,6 +479,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + stopStatusPolling() commandChar = nil notifyChar = nil self.peripheral = nil @@ -332,10 +517,8 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } // 两个特征都就绪后发一次初始状态查询 if commandChar != nil, notifyChar != nil { - let query = Data(header + [0x00] + trailer) - let wt: CBCharacteristicWriteType = - commandChar!.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse - peripheral.writeValue(query, for: commandChar!, type: wt) + requestDeviceStatus() + startStatusPolling() } } @@ -344,9 +527,20 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat let data = characteristic.value else { return } guard let status = Self.parseDeviceStatus(data) else { return } - cachedSwitchState = UInt8(clamping: status.switchState) + let hardwareSwitchState = UInt8(clamping: status.switchState) + cachedSwitchState = hardwareSwitchState cachedLightMode = UInt8(clamping: status.lightMode) + if userSwitchOverride != nil { + userSwitchOverride = nil + emit("← 收到真实拨杆状态 \(hardwareSwitchState),已清除虚拟拨杆覆盖") + } emit("← status battery=\(status.battery) light=\(status.lightMode) switch=\(status.switchState)") + // 一律写入键盘真实 GPIO 状态,避免旧虚拟模拟把 UI / hook 锁在错误档位。 + Self.writeLiveState( + lightMode: UInt8(clamping: status.lightMode), + switchState: hardwareSwitchState, + workMode: UInt8(clamping: max(0, status.workMode)) + ) guard !statusWaiters.isEmpty else { return } let waiters = statusWaiters diff --git a/ahakeyconfig-mac/Sources/Agent/ClaudeHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/ClaudeHookHandler.swift new file mode 100644 index 00000000..8a4485b3 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/ClaudeHookHandler.swift @@ -0,0 +1,49 @@ +import Foundation + +enum ClaudeHookHandler { + static func handlePermissionRequest() { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Claude") + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + let behavior: String + + if isAuto { + behavior = "allow" + } else { + HookSupport.emitPermissionStderr( + ide: "Claude", + hookName: "PermissionRequest", + reply: reply, + switchState: switchState + ) + behavior = "ask" + } + + let out: [String: Any] = [ + "hookSpecificOutput": [ + "hookEventName": "PermissionRequest", + "decision": ["behavior": behavior], + ], + ] + if let data = try? JSONSerialization.data(withJSONObject: out, options: []), + let str = String(data: data, encoding: .utf8) { + print(str) + } + + HookSupport.appendDiagnostic( + ide: "claude", + hookEvent: "PermissionRequest", + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: behavior, + cursorPermission: nil, + cursorDebug: nil, + kimiPreToolDecision: nil + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/CodexConfigLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CodexConfigLeverSync.swift new file mode 100644 index 00000000..2c1a86c0 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/CodexConfigLeverSync.swift @@ -0,0 +1,70 @@ +import Foundation + +/// 将键盘拨杆与 Codex `~/.codex/config.toml` 顶层的 **`approval_policy`** 对齐: +/// **自动档 → `"never"`**(不弹审批,直接执行),**手动档 → `"untrusted"`**(除了 +/// 纯只读的"已知安全"命令,其余一律暂停询问用户)。 +/// +/// 注意:项目级 `[projects.""].trust_level` 只控制是否加载该项目本地的 `.codex/` +/// 配置层(config / hooks / rules),**不**决定是否弹出审批确认——那是 `approval_policy` +/// 的职责(官方文档:参见 https://developers.openai.com/codex/config-reference)。 +/// 早期版本曾误以为改 `trust_level` 就能让拨杆接管 Codex,经实测无效,已改为 +/// `approval_policy`。 +/// +/// `approval_policy` 的取值语义已通过 Codex 开源仓库源码核实 +/// (codex-rs/protocol/src/protocol.rs 中 `enum AskForApproval` 的文档注释): +/// - `untrusted`(UnlessTrusted):只有 `is_safe_command()` 判定的"已知安全且仅读文件" +/// 的命令会自动放行,其余一律向用户确认——这才是"手动档每条都问"对应的值; +/// - `on-request`(OnRequest,默认值):由模型自己决定何时询问用户,普通沙箱内命令 +/// 不会触发询问——实测证明这个值不满足"手动档需每次确认"的诉求(早期版本误用过); +/// - `never`:从不询问,失败也不上报用户——对应自动档。 +/// Codex 的 `PermissionRequest` hook 协议本身不支持 `ask`/`deny`,必须像 +/// `KimiConfigLeverSync` 改写 `default_yolo` 一样,直接改写 Codex 自身的审批策略 +/// 开关,才能让拨杆真正接管。 +enum CodexConfigLeverSync { + private static var configURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/config.toml", isDirectory: false) + } + + static func apply(switchStateAuto: Bool) { + let desired = switchStateAuto ? "never" : "untrusted" + let desiredLine = "approval_policy = \"\(desired)\"" + + let fm = FileManager.default + guard fm.fileExists(atPath: configURL.path), + let data = fm.contents(atPath: configURL.path), + let raw = String(data: data, encoding: .utf8) else { return } + + var lines = raw.components(separatedBy: .newlines) + + // approval_policy 是顶层键,必须出现在第一个 `[section]` 之前。 + var firstSectionIdx = lines.count + for (idx, line) in lines.enumerated() { + if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { + firstSectionIdx = idx + break + } + } + + let pattern = #"^\s*approval_policy\s*="# + let regex = try? NSRegularExpression(pattern: pattern) + for idx in 0.. String { + switch stateValue { + case 2: return "CodexPostToolUse" + case 3: return "CodexPreToolUse" + case 4: return "CodexSessionStart" + case 5: return "CodexStop" + case 7: return "CodexUserPromptSubmit" + default: return "CodexState\(stateValue)" + } + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift new file mode 100644 index 00000000..b34f9f9b --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift @@ -0,0 +1,153 @@ +import Foundation + +/// 拨杆为 0 时,临时放宽 `~/.cursor/cli-config.json` 的 `permissions`(等效于不拦常见白名单项); +/// 非 0 时从首帧快照恢复,与 HookClient 的 `permission` 输出配合使用。 +/// +/// 说明:仅影响 Cursor 自行读取的全局/配置层;`switchState` 未读到(nil)时不会改文件。 +enum CursorCliLeverSync { + private static let cliName = "cli-config.json" + private static var cliURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + .appendingPathComponent(cliName, isDirectory: false) + } + + private static var snapshotURL: URL { cliURL.appendingPathExtension("ahakey.lever0.bak") } + private static var hadNoPriorCliMarker: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + .appendingPathComponent(".ahakey_had_no_cli_config", isDirectory: false) + } + + /// 通配符与 Cursor 文档一致。另含显式 `Shell(cd)` / `Shell(swift)` 等:部分版本 Agent 的 TUI 在报 + /// 「Not in allowlist: cd /path, swift …」时未把 `Shell(*)` 与复合命令行对齐,需首词/工具链单独写。 + private static let relaxedAllow: [String] = [ + "Shell(*)", "Shell(cd)", "Shell(swift)", "Shell(bash)", "Shell(zsh)", "Shell(sh)", + "Read(**/*)", "Write(**/*)", "WebFetch(*)", "Mcp(*:*)", + ] + + /// 在回写 `permission: allow|ask` 的 **之前** 调用,保证 Cursor 随后读配置时已是宽松/已恢复。 + static func apply(switchStateAuto: Bool) { + if switchStateAuto { + applyRelaxed() + } else { + restoreIfneeded() + } + } + + private static func applyRelaxed() { + let cursorDir = cliURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: cursorDir, withIntermediateDirectories: true) + + let fm = FileManager.default + if !fm.fileExists(atPath: snapshotURL.path) && !fm.fileExists(atPath: hadNoPriorCliMarker.path) { + if fm.fileExists(atPath: cliURL.path) { + do { + if fm.fileExists(atPath: snapshotURL.path) { try? fm.removeItem(at: snapshotURL) } + try fm.copyItem(at: cliURL, to: snapshotURL) + } catch { + fprintStderr("CursorCliLeverSync: 无法备份原 cli-config: \(error.localizedDescription)\n") + } + } else { + do { + try Data().write(to: hadNoPriorCliMarker, options: .atomic) + } catch { /* 忽略 */ } + } + } + + var root = readJson(cliURL) ?? [:] + if root["version"] == nil { root["version"] = 1 } + var perms = root["permissions"] as? [String: Any] ?? [:] + var allow = stringArray(from: perms["allow"]) + for token in relaxedAllow { + if !allow.contains(token) { allow.append(token) } + } + perms["allow"] = allow + if perms["deny"] == nil { perms["deny"] = [String]() } + root["permissions"] = perms + root["approvalMode"] = "auto" + if !writeJson(root, to: cliURL) { + fprintStderr("CursorCliLeverSync: 无法写回 \(cliURL.path)(仍返回 hook 的 permission)\n") + } + } + + private static func restoreIfneeded() { + let fm = FileManager.default + if fm.fileExists(atPath: hadNoPriorCliMarker.path) { + try? fm.removeItem(at: hadNoPriorCliMarker) + if fm.fileExists(atPath: cliURL.path) { + try? fm.removeItem(at: cliURL) + } + if fm.fileExists(atPath: snapshotURL.path) { + try? fm.removeItem(at: snapshotURL) + } + return + } + guard fm.fileExists(atPath: snapshotURL.path) else { return } + do { + if fm.fileExists(atPath: cliURL.path) { try fm.removeItem(at: cliURL) } + try fm.copyItem(at: snapshotURL, to: cliURL) + try fm.removeItem(at: snapshotURL) + } catch { + fprintStderr("CursorCliLeverSync: 无法从快照恢复 cli-config: \(error.localizedDescription)\n") + } + } + + private static func readJson(_ url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url), + let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return o + } + + @discardableResult + private static func writeJson(_ root: [String: Any], to url: URL) -> Bool { + guard let data = try? JSONSerialization.data( + withJSONObject: root, options: [.prettyPrinted, .sortedKeys] + ) else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } + + private static func stringArray(from v: Any?) -> [String] { + if let a = v as? [String] { return a } + if let a = v as? [Any] { return a.compactMap { $0 as? String } } + return [] + } + + private static func fprintStderr(_ s: String) { + FileHandle.standardError.write(Data(s.utf8)) + } + + // MARK: - 诊断(permission-request.log) + + /// 写回/恢复后的 `~/.cursor/cli-config.json` 与拨杆同步相关旁路文件状态,供 `HookClient` 记日志。 + static func diagnosticSnapshotForLog() -> [String: Any] { + let fm = FileManager.default + var d: [String: Any] = [ + "userCliConfigPath": cliURL.path, + "userCliConfigExists": fm.fileExists(atPath: cliURL.path), + "lever0SnapshotBakExists": fm.fileExists(atPath: snapshotURL.path), + "hadNoPriorCliMarkerExists": fm.fileExists(atPath: hadNoPriorCliMarker.path), + ] + guard let root = readJson(cliURL) else { return d } + d["cliConfigVersion"] = root["version"] as Any + d["approvalMode"] = root["approvalMode"] as Any + if let sb = root["sandbox"] { + d["cliConfigSandboxPreview"] = String(String(describing: sb).prefix(400)) + } + if let perms = root["permissions"] as? [String: Any] { + let allow = stringArray(from: perms["allow"]) + let deny = stringArray(from: perms["deny"]) + d["permissionsAllowCount"] = allow.count + d["permissionsDenyCount"] = deny.count + d["permissionsHasShellStar"] = allow.contains("Shell(*)") + d["permissionsAllowHasReadStar"] = allow.contains { $0.hasPrefix("Read(") && $0.contains("*") } + d["permissionsAllowHasWriteStar"] = allow.contains { $0.hasPrefix("Write(") && $0.contains("*") } + } + return d + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/CursorHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/CursorHookHandler.swift new file mode 100644 index 00000000..eb93bae8 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/CursorHookHandler.swift @@ -0,0 +1,57 @@ +import Foundation + +enum CursorHookHandler { + static func handleToolPermission(hookEvent: String) { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Cursor") + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + + if isAuto { + // 自动模式:返回 allow,让操作直接执行 + let out: [String: Any] = ["permission": "allow"] + if let data = try? JSONSerialization.data(withJSONObject: out, options: []), + let str = String(data: data, encoding: .utf8) { + print(str) + } + } else { + // 手动模式:返回 deny,阻止操作(Cursor 不支持询问模式) + let out: [String: Any] = ["permission": "deny"] + if let data = try? JSONSerialization.data(withJSONObject: out, options: []), + let str = String(data: data, encoding: .utf8) { + print(str) + } + HookSupport.emitPermissionStderr( + ide: "Cursor", + hookName: hookEvent, + reply: reply, + switchState: switchState + ) + } + + if let s = switchState { + let auto = s == 0 + CursorCliLeverSync.apply(switchStateAuto: auto) + CursorPermissionsJsonLeverSync.apply(switchStateAuto: auto) + } + + let cursorDebug = HookSupport.buildCursorHookDebug( + stdinData: stdinData, + commandPreview: ctx["commandPreview"] as? String + ) + HookSupport.appendDiagnostic( + ide: "cursor", + hookEvent: hookEvent, + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: nil, + cursorPermission: isAuto ? "allow" : "deny", + cursorDebug: cursorDebug, + kimiPreToolDecision: nil + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift new file mode 100644 index 00000000..6815a193 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift @@ -0,0 +1,128 @@ +import Foundation + +/// IDE 内 Agent 终端 TUI 的 **「Not in allowlist」** 与 `~/.cursor/permissions.json` 里的 **`terminalAllowlist`** +/// 有关(见 Cursor 文档「permissions.json reference」),与 **`~/.cursor/cli-config.json`(CLI 权限)是两套**。 +/// 只改 cli-config 不会作用到该 TUI;拨杆为 0 时在此文件合并 `terminalAllowlist` 的前缀项。 +enum CursorPermissionsJsonLeverSync { + private static var permURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + .appendingPathComponent("permissions.json", isDirectory: false) + } + + private static var snapshotURL: URL { permURL.appendingPathExtension("ahakey.lever0.bak") } + private static var hadNoPriorMarker: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + .appendingPathComponent(".ahakey_had_no_permissions_json", isDirectory: false) + } + + /// 与文档「Terminal allowlist format」一致:前缀匹配,如 `cd` 可匹配以 `cd ` 开头的整行;另含 `swift` 等以覆盖 `cd … && swift …` 被拆检的情况。 + private static let relaxedTerminalPrefixes: [String] = [ + "cd", "swift", "swift build", "xcodebuild", "git", "npm", "yarn", "pnpm", "bun", "deno", "node", + "make", "cargo", "go", "python3", "python", "ruby", "bash", "zsh", "sh", "curl", "ls", + ] + + static func apply(switchStateAuto: Bool) { + if switchStateAuto { + applyRelaxed() + } else { + restoreIfNeeded() + } + } + + private static func applyRelaxed() { + let dir = permURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let fm = FileManager.default + if !fm.fileExists(atPath: snapshotURL.path) && !fm.fileExists(atPath: hadNoPriorMarker.path) { + if fm.fileExists(atPath: permURL.path) { + do { + if fm.fileExists(atPath: snapshotURL.path) { try? fm.removeItem(at: snapshotURL) } + try fm.copyItem(at: permURL, to: snapshotURL) + } catch { + fprintStderr("CursorPermissionsJsonLeverSync: 无法备份原 permissions.json: \(error.localizedDescription)\n") + } + } else { + try? Data().write(to: hadNoPriorMarker, options: .atomic) + } + } + + var root = readJson(permURL) ?? [:] + var list = stringArray(root["terminalAllowlist"]) + for t in relaxedTerminalPrefixes where !list.contains(t) { + list.append(t) + } + root["terminalAllowlist"] = list + if !writeJson(root, to: permURL) { + fprintStderr("CursorPermissionsJsonLeverSync: 无法写回 \(permURL.path)\n") + } + } + + private static func restoreIfNeeded() { + let fm = FileManager.default + if fm.fileExists(atPath: hadNoPriorMarker.path) { + try? fm.removeItem(at: hadNoPriorMarker) + if fm.fileExists(atPath: permURL.path) { try? fm.removeItem(at: permURL) } + if fm.fileExists(atPath: snapshotURL.path) { try? fm.removeItem(at: snapshotURL) } + return + } + guard fm.fileExists(atPath: snapshotURL.path) else { return } + do { + if fm.fileExists(atPath: permURL.path) { try fm.removeItem(at: permURL) } + try fm.copyItem(at: snapshotURL, to: permURL) + try fm.removeItem(at: snapshotURL) + } catch { + fprintStderr("CursorPermissionsJsonLeverSync: 无法从快照恢复 permissions.json: \(error.localizedDescription)\n") + } + } + + private static func readJson(_ url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url), + let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return o + } + + @discardableResult + private static func writeJson(_ root: [String: Any], to url: URL) -> Bool { + guard let data = try? JSONSerialization.data( + withJSONObject: root, options: [.prettyPrinted, .sortedKeys] + ) else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } + + private static func stringArray(_ v: Any?) -> [String] { + if let a = v as? [String] { return a } + if let a = v as? [Any] { return a.compactMap { $0 as? String } } + return [] + } + + private static func fprintStderr(_ s: String) { + FileHandle.standardError.write(Data(s.utf8)) + } + + // MARK: - 诊断 + + static func diagnosticSnapshotForLog() -> [String: Any] { + let fm = FileManager.default + var d: [String: Any] = [ + "permissionsJsonPath": permURL.path, + "permissionsJsonExists": fm.fileExists(atPath: permURL.path), + "lever0SnapshotBakExists": fm.fileExists(atPath: snapshotURL.path), + "hadNoPriorMarkerExists": fm.fileExists(atPath: hadNoPriorMarker.path), + ] + guard let root = readJson(permURL) else { return d } + let t = stringArray(root["terminalAllowlist"]) + d["terminalAllowlistCount"] = t.count + d["terminalAllowlistHasPrefix_cd"] = t.contains { $0 == "cd" || $0.hasPrefix("cd:") } + d["terminalAllowlistHasPrefix_swift"] = t.contains { $0 == "swift" || $0.hasPrefix("swift ") } + d["mcpAllowlistDefined"] = (root["mcpAllowlist"] != nil) + return d + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/HookClient.swift b/ahakeyconfig-mac/Sources/Agent/HookClient.swift new file mode 100644 index 00000000..95e51e17 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/HookClient.swift @@ -0,0 +1,79 @@ +import Foundation + +/// 仅负责事件路由;各 IDE 的具体逻辑已拆到独立 handler,避免互相影响。 +enum HookClient { + private enum EventRoute { + case sharedState(UInt8) + case claudePermissionRequest + case cursorToolPermission + case codexState(UInt8) + case codexPermissionRequest + case kimiPreToolPermission + } + + private static let eventMap: [String: EventRoute] = [ + "Notification": .sharedState(0), + "PermissionRequest": .claudePermissionRequest, + "PostToolUse": .sharedState(2), + "PreToolUse": .sharedState(3), + "SessionStart": .sharedState(4), + "Stop": .sharedState(5), + // SubagentStop:Claude Code 将原 Stop 拆分后,手动终止任务时触发此事件而非 Stop + "SubagentStop": .sharedState(5), + "TaskCompleted": .sharedState(6), + "UserPromptSubmit": .sharedState(7), + "SessionEnd": .sharedState(8), + "PreCompact": .sharedState(0), + + "sessionStart": .sharedState(4), + "sessionEnd": .sharedState(8), + "postToolUse": .sharedState(2), + "stop": .sharedState(5), + "preToolUse": .cursorToolPermission, + "beforeShellExecution": .cursorToolPermission, + "beforeMCPExecution": .cursorToolPermission, + + "CodexSessionStart": .codexState(4), + "CodexPostToolUse": .codexState(2), + "CodexPreToolUse": .codexState(3), + "CodexUserPromptSubmit": .codexState(7), + "CodexStop": .codexState(5), + "CodexPermissionRequest": .codexPermissionRequest, + + "KimiNotification": .sharedState(0), + "KimiSessionStart": .sharedState(4), + "KimiSessionEnd": .sharedState(8), + "KimiPreToolUse": .kimiPreToolPermission, + "KimiPostToolUse": .sharedState(2), + "KimiUserPromptSubmit": .sharedState(7), + "KimiStop": .sharedState(5), + ] + + static func run(event: String) -> Int32 { + signal(SIGPIPE, SIG_IGN) + + // 调试日志:记录所有收到的事件 + FileHandle.standardError.write(Data("[ahakey-hook] DEBUG: received event: '\(event)'\n".utf8)) + + guard let route = eventMap[event] else { + FileHandle.standardError.write(Data("[ahakey-hook] unknown event: \(event)\n".utf8)) + return 0 + } + + switch route { + case .sharedState(let value): + HookSupport.handleFireAndForgetState(stateValue: value) + case .claudePermissionRequest: + ClaudeHookHandler.handlePermissionRequest() + case .cursorToolPermission: + CursorHookHandler.handleToolPermission(hookEvent: event) + case .codexState(let value): + CodexHookHandler.handleState(stateValue: value) + case .codexPermissionRequest: + CodexHookHandler.handlePermissionRequest() + case .kimiPreToolPermission: + KimiHookHandler.handlePreToolPermission() + } + return 0 + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/HookSupport.swift b/ahakeyconfig-mac/Sources/Agent/HookSupport.swift new file mode 100644 index 00000000..fdbeadc5 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/HookSupport.swift @@ -0,0 +1,312 @@ +import Foundation + +enum HookSupport { + static let permissionLedValue: UInt8 = 1 + static let socketPath = "/tmp/ahakey.sock" + static let stateRequestTimeout: Double = 2.0 + static let permissionRequestTimeout: Double = 5.0 + + static let diagnosticTimestampFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f + }() + + static func handleFireAndForgetState(stateValue: UInt8) { + _ = sendUnifiedLightState(stateValue: stateValue) + } + + @discardableResult + static func sendUnifiedLightState(stateValue: UInt8) -> [String: Any]? { + let request: [String: Any] = ["cmd": "state", "value": Int(stateValue)] + return sendJsonRequest(request, timeout: stateRequestTimeout) + } + + @discardableResult + static func readAllStdinSilently() -> Data { + let handle = FileHandle.standardInput + return (try? handle.readToEnd()) ?? Data() + } + + static func sendJsonRequest(_ dict: [String: Any], timeout: Double) -> [String: Any]? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var tv = timeval( + tv_sec: __darwin_time_t(timeout), + tv_usec: suseconds_t((timeout - Double(Int(timeout))) * 1_000_000) + ) + 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) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + _ = strcpy(dst, src) + } + } + let addrLen = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, addrLen) + } + } + guard connected == 0 else { return nil } + + guard var payload = try? JSONSerialization.data(withJSONObject: dict, options: []) else { + return nil + } + payload.append(0x0A) + let wrote = payload.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return -1 } + return write(fd, base, ptr.count) + } + guard wrote >= 0 else { return nil } + + var buf = [UInt8](repeating: 0, count: 1024 * 4) + let n = read(fd, &buf, buf.count) + guard n > 0 else { return nil } + let slice = Data(buf[0 ..< Int(n)]) + return (try? JSONSerialization.jsonObject(with: slice)) as? [String: Any] + } + + static func intValue(_ v: Any?) -> Int? { + switch v { + case let i as Int: + return i + case let n as NSNumber: + return n.intValue + case let d as Double: + return Int(d) + case let s as String: + return Int(s) + default: + return nil + } + } + + static func emitPermissionStderr( + ide: String, + hookName: String, + reply: [String: Any]?, + switchState: Int? + ) { + if switchState == nil, reply == nil { + let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(/tmp/ahakey.sock 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。" + + "请确认 LaunchAgent 里 ahakeyconfig-agent 在跑、且蓝牙已选「由 Agent 占用」并连上键盘。\n" + FileHandle.standardError.write(Data(msg.utf8)) + } else if switchState == nil, reply != nil { + let msg = "[ahakey-hook] \(ide) \(hookName): 回包无有效 switchState(需 BLE 已连且能读到拨杆 0=自动批准),将按交回用户/终端处理。\n" + FileHandle.standardError.write(Data(msg.utf8)) + } else if let s = switchState, s != 0 { + let msg = "[ahakey-hook] \(ide) \(hookName): 拨杆 switchState=\(s)(非 0),不自动批准。\n" + FileHandle.standardError.write(Data(msg.utf8)) + } + } + + static func parseStdinContext(_ data: Data, label: String) -> [String: Any] { + var out: [String: Any] = [ + "stdinBytes": data.count, + "parser": label, + ] + guard !data.isEmpty, + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + return out + } + if let t = obj["tool_name"] as? String { + out["tool_name"] = t + } + if let c = obj["command"] as? String { + out["commandPreview"] = String(c.prefix(120)) + } + if out["tool_name"] == nil, let t = obj["name"] as? String { + out["name"] = t + } + if let he = obj["hook_event_name"] as? String { + out["hook_event_name"] = he + } + if let sid = obj["session_id"] as? String { + out["session_id_short"] = String(sid.prefix(16)) + } + if let cw = obj["cwd"] as? String { + out["cwdPreview"] = String(cw.prefix(120)) + } + return out + } + + static func appendDiagnostic( + ide: String, + hookEvent: String, + toolContext: [String: Any], + reply: [String: Any]?, + switchState: Int?, + isAuto: Bool, + claudeBehavior: String?, + cursorPermission: String?, + cursorDebug: [String: Any]? = nil, + kimiPreToolDecision: String? = nil, + kimiLeverDebug: [String: Any]? = nil + ) { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + let fileURL = dir.appendingPathComponent("permission-request.log") + + let diagnostic: String + if reply == nil { + diagnostic = "no_agent_reply" + } else if switchState == nil { + diagnostic = "no_switch_in_reply" + } else if isAuto { + diagnostic = "allow" + } else { + diagnostic = "ask" + } + + var lineObj: [String: Any] = [ + "ts": diagnosticTimestampFormatter.string(from: Date()), + "ide": ide, + "hookEvent": hookEvent, + "switchState": switchState.map { $0 } ?? NSNull(), + "isAuto": isAuto, + "agentReply": reply == nil ? false : true, + "diagnostic": diagnostic, + "tool": toolContext, + ] + if let b = claudeBehavior { lineObj["claudeBehavior"] = b } + if let p = cursorPermission { lineObj["cursorPermission"] = p } + if let c = cursorDebug { lineObj["cursorDebug"] = c } + if let k = kimiPreToolDecision { lineObj["kimiPreToolDecision"] = k } + if let kd = kimiLeverDebug { lineObj["kimiLeverDebug"] = kd } + + guard let data = try? JSONSerialization.data(withJSONObject: lineObj, options: []), + var line = String(data: data, encoding: .utf8) else { return } + line += "\n" + appendLine(line, to: fileURL) + } + + static func appendCodexHookLog( + hookEvent: String?, + agentEvent: String, + stateValue: UInt8, + toolContext: [String: Any], + reply: [String: Any]?, + switchState: Int?, + decision: String? + ) { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + let fileURL = dir.appendingPathComponent("codex-hook.log") + + var lineObj: [String: Any] = [ + "ts": diagnosticTimestampFormatter.string(from: Date()), + "agentEvent": agentEvent, + "hookEvent": hookEvent ?? NSNull(), + "stateValue": Int(stateValue), + "agentReply": reply == nil ? false : true, + "switchState": switchState.map { $0 } ?? NSNull(), + "tool": toolContext, + ] + if let decision { lineObj["decision"] = decision } + + guard let data = try? JSONSerialization.data(withJSONObject: lineObj, options: []), + var line = String(data: data, encoding: .utf8) else { return } + line += "\n" + appendLine(line, to: fileURL) + } + + static func appendLine(_ line: String, to fileURL: URL) { + guard let out = line.data(using: .utf8) else { return } + if !FileManager.default.fileExists(atPath: fileURL.path) { + try? out.write(to: fileURL, options: .atomic) + return + } + if let h = try? FileHandle(forWritingTo: fileURL) { + defer { try? h.close() } + h.seekToEndOfFile() + h.write(out) + } + } + + static let cursorStdinLogKeys: [String] = [ + "cursorVersion", "cursor_version", "appVersion", "app_version", "version", + "workspacePath", "workspace_path", "workspaceFolders", "workspace_folders", "workspaceRoot", + "cwd", "root", "shell", "shell_type", "sessionId", "conversation_id", + ] + + static func buildCursorHookDebug(stdinData: Data, commandPreview: String?) -> [String: Any] { + var out: [String: Any] = [ + "userCliConfig": CursorCliLeverSync.diagnosticSnapshotForLog(), + "userPermissionsJson": CursorPermissionsJsonLeverSync.diagnosticSnapshotForLog(), + "note": "IDE「Not in allowlist」用 ~/.cursor/permissions.json 的 terminalAllowlist,与 userCliConfig(cli-config=CLI)分离;见 cursor.com/docs/reference/permissions", + "stdinFields": cursorStdinDebugFields(stdinData), + "processEnvCursorVscode": cursorRelatedEnvForLog(), + ] + if let cd = inferredCdPathFromShellCommand(commandPreview) { + out["inferredCdPath"] = cd + let proj = (cd as NSString).appendingPathComponent(".cursor/cli.json") + out["projectCliJsonPath"] = proj + out["projectCliJsonExists"] = FileManager.default.fileExists(atPath: proj) + } + return out + } + + static func cursorStdinDebugFields(_ data: Data) -> [String: Any] { + guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + return ["parse": "empty_or_invalid_json"] + } + var o: [String: Any] = [:] + for k in cursorStdinLogKeys { + guard let v = obj[k] else { continue } + o[k] = stringifyDebugValue(v, maxLen: 220) + } + if o.isEmpty { o["note"] = "no_whitelisted_keys_in_stdin" } + return o + } + + static func stringifyDebugValue(_ v: Any, maxLen: Int) -> String { + if let s = v as? String { return String(s.prefix(maxLen)) } + if let n = v as? NSNumber { return n.stringValue } + if let a = v as? [String] { + return String(a.prefix(12).joined(separator: ", ").prefix(maxLen)) + } + return String(String(describing: v).prefix(maxLen)) + } + + static func cursorRelatedEnvForLog() -> [String: Any] { + var o: [String: Any] = [:] + for (k, v) in ProcessInfo.processInfo.environment.sorted(by: { $0.key < $1.key }) { + let ku = k.uppercased() + guard ku.contains("CURSOR") || ku.hasPrefix("VSCODE_") || ku == "TERM" else { continue } + o[k] = String(v.prefix(200)) + if o.count >= 32 { break } + } + return o + } + + static func inferredCdPathFromShellCommand(_ command: String?) -> String? { + guard let raw = command?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.hasPrefix("cd ") else { return nil } + var rest = String(raw.dropFirst(3)).trimmingCharacters(in: .whitespaces) + if let r = rest.range(of: " && ") { rest = String(rest[.. [String: Any] { + let fm = FileManager.default + let path = configURL.path + var d: [String: Any] = [ + "kimiConfigPath": path, + "kimiConfigExists": fm.fileExists(atPath: path), + "hookApprovalPolicy": "dial_writes_default_yolo_on_PreToolUse_kimi_reload_to_apply_upstream_deny_only", + ] + guard let data = fm.contents(atPath: path), + let raw = String(data: data, encoding: .utf8) else { return d } + if let regex = try? NSRegularExpression(pattern: #"(?m)^\s*default_yolo\s*=\s*(\S+)"#, options: []), + let m = regex.firstMatch(in: raw, options: [], range: NSRange(location: 0, length: (raw as NSString).length)), + m.numberOfRanges > 1 { + let ns = raw as NSString + d["default_yolo_lineno_hint"] = "matched" + d["default_yolo_valueSnippet"] = String(ns.substring(with: m.range(at: 1)).prefix(32)) + } else { + d["default_yolo_valueSnippet"] = NSNull() + } + return d + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/KimiConfigLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/KimiConfigLeverSync.swift new file mode 100644 index 00000000..fc8f9869 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/KimiConfigLeverSync.swift @@ -0,0 +1,113 @@ +import Foundation + +/// 将键盘拨杆与 Kimi **`~/.kimi/config.toml`** 的根级 **`default_yolo`** 对齐:**自动档 `true`,手动档 `false`**。 +/// +/// 不使用「整文件恢复快照」来代表手动档:快照往往在用户原本就开启过 YOLO 时记下 `default_yolo=true`,一还原无论怎样 `/reload` 都会回到 YOLO。自动档仍可在首次写入前备份一份 **`.ahakey.lever0.bak`** 供你以后自行比对;切到手动档时会删掉该快照以免再次误还原。 +enum KimiConfigLeverSync { + private static var configURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".kimi/config.toml", isDirectory: false) + } + + private static var snapshotURL: URL { configURL.appendingPathExtension("ahakey.lever0.bak") } + + static func apply(switchStateAuto: Bool) { + if switchStateAuto { + enableYoloInConfig() + } else { + disableDefaultYoloForManualDial() + } + } + + private static func enableYoloInConfig() { + let fm = FileManager.default + let path = configURL.path + guard fm.fileExists(atPath: path) else { + fprintStderr("KimiConfigLeverSync: 未找到 \(path),跳过。\n") + KimiHookDebugLog.append(event: "kimi_lever_sync_missing_config", details: ["path": path]) + return + } + + if !fm.fileExists(atPath: snapshotURL.path) { + do { + try fm.copyItem(at: configURL, to: snapshotURL) + KimiHookDebugLog.append(event: "kimi_lever_sync_backup_created", details: ["to": snapshotURL.path]) + } catch { + fprintStderr("KimiConfigLeverSync: 备份失败 \(error.localizedDescription)\n") + KimiHookDebugLog.append(event: "kimi_lever_sync_backup_failed", details: ["error": error.localizedDescription]) + } + } + + guard let data = fm.contents(atPath: path), + var raw = String(data: data, encoding: .utf8) else { + fprintStderr("KimiConfigLeverSync: 无法读取 \(path)\n") + return + } + raw = replaceOrInsertDefaultYolo(raw, template: #"default_yolo = true # AhaKey: dial auto; run /reload in Kimi after changing dial"#) + writeRaw(raw, label: "enable_auto") + } + + private static func disableDefaultYoloForManualDial() { + let fm = FileManager.default + let path = configURL.path + // 删掉旧逻辑用的快照:手动档若用它「整文件还原」会把原先的 default_yolo=true 整块带回来。 + if fm.fileExists(atPath: snapshotURL.path) { + do { + try fm.removeItem(at: snapshotURL) + KimiHookDebugLog.append(event: "kimi_lever_snapshot_removed_for_manual_clear", details: [:]) + KimiHookDebugLog.stderrLine("removed config.toml.ahakey.lever0.bak (avoid restoring old default_yolo=true)") + } catch { + fprintStderr("KimiConfigLeverSync: 无法删除快照 \(error.localizedDescription)\n") + } + } + guard fm.fileExists(atPath: path) else { return } + guard let data = fm.contents(atPath: path), + var raw = String(data: data, encoding: .utf8) else { return } + raw = replaceOrInsertDefaultYolo(raw, template: #"default_yolo = false # AhaKey: dial manual; run /reload in Kimi after changing dial"#) + writeRaw(raw, label: "disable_manual") + KimiHookDebugLog.stderrLine("default_yolo=false on dial manual; run /reload in Kimi.") + // kimi-cli: effective_yolo = load_config.default_yolo OR session persist state.json approval.yolo + KimiHookDebugLog.stderrLine( + "note: kimi ORs persisted session YOLO (e.g. after /yolo) with config — if reload still shows yolo, toggle /yolo off once or use a fresh session." + ) + } + + private static func replaceOrInsertDefaultYolo(_ text: String, template: String) -> String { + let pattern = #"(?m)^\s*default_yolo\s*=\s*[^\r\n]*\s*$"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return template + "\n\n" + text + } + let nsFull = text as NSString + let fullRange = NSRange(location: 0, length: nsFull.length) + if regex.firstMatch(in: text, options: [], range: fullRange) != nil { + return regex.stringByReplacingMatches(in: text, options: [], range: fullRange, withTemplate: template) + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return template + "\n" } + return template + "\n\n" + text + } + + private static func writeRaw(_ raw: String, label: String) { + guard let out = raw.data(using: .utf8) else { return } + do { + try out.write(to: configURL, options: .atomic) + KimiHookDebugLog.append(event: "kimi_lever_sync_\(label)", details: [ + "bytes": out.count, + "reloadHint": "kimi_reload_slash_same_session_ok", + ]) + } catch { + fprintStderr("KimiConfigLeverSync: 写回失败 \(error.localizedDescription)\n") + KimiHookDebugLog.append(event: "kimi_lever_sync_write_failed", details: ["error": error.localizedDescription]) + } + } + + private static func fprintStderr(_ s: String) { + FileHandle.standardError.write(Data(s.utf8)) + } + + /// 供 `permission-request.log`:`kimiLeverDebug`。 + static func diagnosticSnapshotForLog() -> [String: Any] { + var d = KimiConfigDiagnostic.snapshotForLog() + d["leverSnapshotBakExists"] = FileManager.default.fileExists(atPath: snapshotURL.path) + return d + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/KimiHookDebugLog.swift b/ahakeyconfig-mac/Sources/Agent/KimiHookDebugLog.swift new file mode 100644 index 00000000..40d3a79c --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/KimiHookDebugLog.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Kimi PreToolUse 等设备侧调试日志:stderr(终端里 kimi 会看到)+ 单行 JSON 写入 **当前工作目录** 下的 `kimi-hook-debug.log`。 +enum KimiHookDebugLog { + private static let logFileName = "kimi-hook-debug.log" + + /// 供 Hook stderr 告知用户到哪看完整 JSON 行日志(绝对路径)。 + static var logPath: String { debugFileURL.path } + + private static var debugFileURL: URL { + let cwd = FileManager.default.currentDirectoryPath + let base = cwd.isEmpty + ? FileManager.default.homeDirectoryForCurrentUser.path + : cwd + return URL(fileURLWithPath: base, isDirectory: true).appendingPathComponent(logFileName, isDirectory: false) + } + + private static let isoTs: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// stderr 单行,前缀统一便于 `grep`,kimi-cli 将把 hook 工具的 stderr 打出来(视版本而定)。 + static func stderrLine(_ msg: String) { + let line = "[aha-kimi] \(msg)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + + /// 结构化追加到磁盘(与 `permission-request.log` 等 App diagnostics 分离,避免路径含空格时难找)。 + static func append(event: String, details: [String: Any]) { + let cwd = FileManager.default.currentDirectoryPath + var payload: [String: Any] = [ + "ts": isoTs.string(from: Date()), + "event": event, + "pid": ProcessInfo.processInfo.processIdentifier, + "cwd": cwd.isEmpty ? NSNull() : cwd, + ] + for (k, v) in details { payload[k] = v } + + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload, options: []), + var line = String(data: data, encoding: .utf8) else { + stderrLine("debug log JSON encode failed event=\(event)") + return + } + line += "\n" + + let url = debugFileURL + let fm = FileManager.default + do { + // 日志在 cwd 下,目录应已存在;若为空回退到 home,则保证父目录存在 + if FileManager.default.currentDirectoryPath.isEmpty { + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + if !fm.fileExists(atPath: url.path) { + try line.data(using: .utf8)?.write(to: url, options: .atomic) + } else if let handle = try? FileHandle(forWritingTo: url) { + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(line.utf8)) + } + } catch { + let fallback = "[aha-kimi] append log failed \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(fallback.utf8)) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/KimiHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/KimiHookHandler.swift new file mode 100644 index 00000000..b39f6423 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/KimiHookHandler.swift @@ -0,0 +1,109 @@ +import Foundation + +enum KimiHookHandler { + static func handlePreToolPermission() { + let stdinData = HookSupport.readAllStdinSilently() + let ctx = HookSupport.parseStdinContext(stdinData, label: "Kimi") + KimiHookDebugLog.append(event: "kimi_PreToolUse_start", details: [ + "stdinBytes": stdinData.count, + "toolPreview": ctx, + "hintLogFile": KimiHookDebugLog.logPath, + ]) + KimiHookDebugLog.stderrLine("PreToolUse hook start bytes=\(stdinData.count); full log=\(KimiHookDebugLog.logPath)") + + let request: [String: Any] = ["cmd": "permission", "value": Int(HookSupport.permissionLedValue)] + let reply = HookSupport.sendJsonRequest(request, timeout: HookSupport.permissionRequestTimeout) + let switchState = HookSupport.intValue(reply?["switchState"]) + let isAuto = switchState == 0 + + KimiHookDebugLog.append(event: "kimi_PreToolUse_agent_reply", details: [ + "agentReplyNil": reply == nil, + "switchState": switchState.map { $0 } ?? NSNull(), + "computedIsAuto": isAuto, + "replyPreview": kimiReplyPreview(reply), + ]) + KimiHookDebugLog.stderrLine("agent socket reply: switchState=\(switchState.map { String($0) } ?? "nil") isAuto=\(isAuto)") + + let stdoutPayload: String + let kimiPreToolDecision: String + if isAuto { + stdoutPayload = "" + kimiPreToolDecision = "auto_no_stdout_no_veto" + } else { + stdoutPayload = "" + kimiPreToolDecision = "manual_lever_no_stdout" + HookSupport.emitPermissionStderr( + ide: "Kimi", + hookName: "PreToolUse", + reply: reply, + switchState: switchState + ) + } + + let kimiProbeInitial = KimiConfigDiagnostic.snapshotForLog() + KimiHookDebugLog.append(event: "kimi_PreToolUse_probe_before_lever_sync", details: [ + "kimiLeverDebug_preview": kimiProbeInitial, + "switchStateNil": switchState == nil, + "kimiRunnerNote": "upstream_kimi_hooks_runner_only_handles_permissionDecision_deny", + ]) + var leverSnapshotForLog = kimiProbeInitial + if switchState == nil { + KimiHookDebugLog.stderrLine("no switchState from agent → skipped default_yolo sync; kimiPreTool=\(kimiPreToolDecision); check BLE/agent") + } else { + KimiConfigLeverSync.apply(switchStateAuto: isAuto) + leverSnapshotForLog = KimiConfigLeverSync.diagnosticSnapshotForLog() + KimiHookDebugLog.append(event: "kimi_PreToolUse_after_default_yolo_disk_sync", details: leverSnapshotForLog) + KimiHookDebugLog.stderrLine( + "default_yolo synced on disk (\(isAuto ? "true" : "false")); if kimi is already running: enter /reload in that session — no quit needed." + ) + let yoloSnippet: String + if let v = leverSnapshotForLog["default_yolo_valueSnippet"], !(v is NSNull) { + yoloSnippet = String(describing: v) + } else { + yoloSnippet = "(unset/absent)" + } + KimiHookDebugLog.stderrLine("on-disk default_yolo now reads \(yoloSnippet); /reload picks it up.") + } + + if !stdoutPayload.isEmpty { + FileHandle.standardOutput.write(Data(stdoutPayload.utf8)) + } + KimiHookDebugLog.append(event: "kimi_PreToolUse_stdout", details: [ + "kimiDecision": kimiPreToolDecision, + "stdoutChars": stdoutPayload.count, + "hint": "kimi-cli 读 default_yolo 在 KimiCLI.create/load_config;已开着的会话请输入 /reload 即可(不必退出 kimi)。PreToolUse 仍仅 deny 会拦工具;default_yolo 管交互批准层。", + ]) + KimiHookDebugLog.stderrLine("stdout chars=\(stdoutPayload.count) decision=\(kimiPreToolDecision)") + + HookSupport.appendDiagnostic( + ide: "kimi", + hookEvent: "PreToolUse", + toolContext: ctx, + reply: reply, + switchState: switchState, + isAuto: isAuto, + claudeBehavior: nil, + cursorPermission: nil, + cursorDebug: nil, + kimiPreToolDecision: kimiPreToolDecision, + kimiLeverDebug: KimiConfigLeverSync.diagnosticSnapshotForLog() + ) + } + + private static func kimiReplyPreview(_ reply: [String: Any]?) -> [String: Any] { + guard let reply else { + return ["nil": true] + } + var o: [String: Any] = [:] + if let sv = reply["switchState"] { + o["switchState"] = String(String(describing: sv).prefix(48)) + } + if let ok = reply["ok"] { + o["ok"] = ok + } + if let err = reply["error"] as? String { + o["error"] = String(err.prefix(160)) + } + return o + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/main.swift b/ahakeyconfig-mac/Sources/Agent/main.swift new file mode 100644 index 00000000..97e36bbe --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/main.swift @@ -0,0 +1,35 @@ +import Foundation + +// ahakeyconfig-agent +// +// 两种运行模式(由首个参数决定): +// 1. Daemon(无参数 / 只传 --socket):常驻 LaunchAgent,维持 BLE 连接 + 监听 Unix socket +// ahakeyconfig-agent [--socket /tmp/ahakey.sock] +// 2. Hook 子命令(首个参数为 hook):Claude Code / Cursor / Codex / Kimi Code CLI 会 exec 本进程 +// ahakeyconfig-agent hook +// 内部通过 Unix socket 联系常驻 daemon,并按需向 stdout 输出 Claude 决策 JSON。 + +let args = CommandLine.arguments + +if args.count >= 3, args[1] == "hook" { + let event = args[2] + exit(HookClient.run(event: event)) +} + +// Daemon 模式 +let socketPath: String +if let idx = args.firstIndex(of: "--socket"), idx + 1 < args.count { + socketPath = args[idx + 1] +} else { + socketPath = "/tmp/ahakey.sock" +} + +let agent = AhaKeyAgent(socketPath: socketPath) +agent.onLog = { msg in + let ts = ISO8601DateFormatter().string(from: Date()) + print("[\(ts)] \(msg)") +} +agent.startSocketListener() + +// 保持运行 +RunLoop.main.run() diff --git a/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift new file mode 100644 index 00000000..78e7c350 --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift @@ -0,0 +1,110 @@ +import AppKit +import SwiftUI +import AVFoundation +import Speech +import UserNotifications + +@main +struct AhaKeyConfigApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject private var bleManager = AhaKeyBLEManager() + + var body: some Scene { + WindowGroup("AhaKey Studio") { + ContentView(bleManager: bleManager) + .frame(minWidth: 1180, minHeight: 680) + } + .windowStyle(.titleBar) + + if #available(macOS 13.0, *) { + MenuBarExtra("AhaKey", systemImage: "keyboard") { + Button("打开主窗口") { + appDelegate.reopenMainWindow() + } + + Divider() + + Button("退出 AhaKey Studio") { + NSApp.terminate(nil) + } + } + } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + func applicationDidFinishLaunching(_ notification: Notification) { + // 单实例:检查是否已有实例在运行 + let bundleID = Bundle.main.bundleIdentifier ?? "lab.jawa.ahakeyconfig" + let currentBundlePath = Bundle.main.bundlePath + let running = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + if running.count > 1 { + let otherInstances = running.filter { $0 != NSRunningApplication.current } + let sameBundleInstance = otherInstances.first { app in + app.bundleURL?.path == currentBundlePath + } + + if let existing = sameBundleInstance { + existing.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSApp.terminate(nil) + return + } + + for stale in otherInstances { + stale.terminate() + } + } + + // 检查签名是否变化,如果变化则自动重置麦克风权限 + PermissionSignatureChecker.checkAndResetOnSignatureChange { success in + DispatchQueue.main.async { + if success { + let alert = NSAlert() + alert.messageText = "检测到应用签名变化" + alert.informativeText = "麦克风权限已自动重置,下次点击「申请」按钮时会弹出系统授权对话框。" + alert.addButton(withTitle: "确定") + alert.runModal() + } + } + } + + UNUserNotificationCenter.current().delegate = self + + VoiceRelayService.shared.start() + NativeSpeechTranscriptionService.shared.start() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + func applicationDidBecomeActive(_ notification: Notification) { + VoiceRelayService.shared.refreshPermissions() + NativeSpeechTranscriptionService.shared.refreshPermissions() + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + reopenMainWindow() + } + return true + } + + func reopenMainWindow() { + NSApp.activate(ignoringOtherApps: true) + if let mainWindow = NSApp.windows.first(where: { $0.canBecomeMain && !$0.isMiniaturized }) { + mainWindow.makeKeyAndOrderFront(nil) + } else if let mainWindow = NSApp.windows.first(where: { $0.canBecomeMain }) { + mainWindow.deminiaturize(nil) + mainWindow.makeKeyAndOrderFront(nil) + } + } +} diff --git a/platforms/macos/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift similarity index 78% rename from platforms/macos/Sources/BLE/AhaKeyBLEManager.swift rename to ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift index c7868f0e..7799f3e7 100644 --- a/platforms/macos/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -7,6 +7,12 @@ import UserNotifications private let log = Logger(subsystem: "lab.jawa.ahakeyconfig", category: "BLE") +/// 0x83 查询回来的某个 mode 的图片元信息(用 Equatable struct 方便 SwiftUI .onChange 监听) +struct KeyboardPictureState: Equatable { + let frameCount: Int + let frameIntervalMs: Int +} + /// 通信日志条目 struct BLELogEntry: Identifiable { let id = UUID() @@ -52,10 +58,23 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { @Published private(set) var workMode: Int = 0 @Published private(set) var lightMode: Int = 0 @Published private(set) var switchState: Int = 0 + @Published private(set) var brightness: Int = 35 @Published private(set) var bleConnectionStatus: String = "未连接" @Published private(set) var bleDeviceUUID: String = "—" + @Published private(set) var bluetoothPermissionGranted = true + @Published private(set) var bluetoothPoweredOn = false @Published private(set) var oledUploadProgress: OLEDUploadProgress? @Published private(set) var isUploadingOLED = false + /// 由 ahakeyconfig-agent 写入的当前 IDE hook 状态值(IDEState.rawValue),用于画布 LED 颜色实时还原 + @Published private(set) var liveIDEStateValue: Int? = nil + /// Agent 端 BLE 通知缓存的 lightMode/switchState/workMode(agent 占用蓝牙时主 App 自己 BLE 未连,靠这些读到键盘实时状态) + @Published private(set) var agentLightMode: Int? = nil + @Published private(set) var agentSwitchState: Int? = nil + @Published private(set) var agentWorkMode: Int? = nil + /// 各 mode flash 里的真实图片元信息。 + /// 主 App 自占 BLE 后通过 0x83 查询填充;frameCount == 0 表示用户没自定义上传, + /// 键盘显示固件出厂动图(与 bundle/DefaultOLED 同源)。 + @Published private(set) var keyboardPictureStates: [Int: KeyboardPictureState] = [:] /// 通信日志(最近 200 条) @Published private(set) var commLog: [BLELogEntry] = [] @@ -84,11 +103,11 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { static let firmwareRevisionCharUUID = CBUUID(string: "2A26") static let modelNumberCharUUID = CBUUID(string: "2A24") - nonisolated static let deviceNamePrefix = "vibe code" + nonisolated static let deviceNamePrefix = "AhaKey" // MARK: - Private - private var central: CBCentralManager! + private var central: CBCentralManager? private var peripheral: CBPeripheral? private var dataChar: CBCharacteristic? private var commandChar: CBCharacteristic? @@ -98,6 +117,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { private var rssiTimer: Timer? private var autoReconnectTimer: Timer? private var statusPollTimer: Timer? + private var ideStatePollTimer: Timer? /// 记住上次连接的 UUID,用于快速重连 private var lastPeripheralUUID: UUID? /// 为 true 时,本 App 不扫描、不连接、不响应掉线/轮询重连(物理键盘由 `ahakeyconfig-agent` 占用时由 AgentManager 置位) @@ -121,21 +141,69 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { override init() { super.init() - // 与 AgentManager 启动顺序无关:若上次退出前选择「Agent 占蓝牙」, - // 在蓝牙栈 poweredOn 回调里会立刻重连,必须在 init 就挡住。 let storedOwner = UserDefaults.standard.string(forKey: "lab.jawa.ahakeyconfig.bluetoothConnectionOwner") if storedOwner == nil || storedOwner == BluetoothConnectionOwner.agentDaemon.rawValue { suppressAutomaticConnection = true } - central = CBCentralManager(delegate: nil, queue: nil) - central.delegate = self + // 只有蓝牙权限已授予时才创建 CBCentralManager(创建即触发系统弹窗)。 + // 权限未决时延迟到用户点「申请」后调用 ensureCentralManager()。 + if CBCentralManager.authorization == .allowedAlways { + central = CBCentralManager(delegate: self, queue: nil) + } + refreshBluetoothAuthorization() startAutoReconnectPolling() + startIDEStatePolling() + } + + /// 确保 CBCentralManager 已创建。用户显式申请蓝牙权限时调用。 + func ensureCentralManager() { + guard central == nil else { return } + central = CBCentralManager(delegate: self, queue: nil) } // MARK: - Public API + func refreshBluetoothAuthorization() { + bluetoothPermissionGranted = Self.currentBluetoothAuthorizationGranted() + bluetoothPoweredOn = central?.state == .poweredOn + if !bluetoothPermissionGranted { + bleConnectionStatus = "蓝牙权限未开启" + } else if central?.state == .poweredOff { + bleConnectionStatus = "蓝牙关闭" + } + } + + var bluetoothAuthorizationCanPrompt: Bool { + CBCentralManager.authorization == .notDetermined + } + + var bluetoothAuthorizationDeniedOrRestricted: Bool { + switch CBCentralManager.authorization { + case .restricted, .denied: + return true + case .allowedAlways, .notDetermined: + return false + @unknown default: + return false + } + } + + private static func currentBluetoothAuthorizationGranted() -> Bool { + switch CBCentralManager.authorization { + case .allowedAlways: + return true + case .notDetermined: + return true + case .restricted, .denied: + return false + @unknown default: + return true + } + } + /// 由「设备信息 / 顶栏」等**用户显式**发起连接时调用:取消「交给 Agent」时的抑制并尝试连接。 func userInitiatedConnect() { + ensureCentralManager() suppressAutomaticConnection = false connectAutomatically() } @@ -147,31 +215,31 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { func connectAutomatically() { guard !suppressAutomaticConnection else { return } - guard central.state == .poweredOn else { + guard central?.state == .poweredOn else { pendingConnect = true return } // 1. 用已知 UUID 直连(最快) if let uuid = lastPeripheralUUID { - let known = central.retrievePeripherals(withIdentifiers: [uuid]) + let known = central?.retrievePeripherals(withIdentifiers: [uuid]) ?? [] if let p = known.first { appendLog("用已知 UUID 直连: \(p.name ?? uuid.uuidString)") self.peripheral = p p.delegate = self - central.connect(p, options: nil) + central?.connect(p, options: nil) bleConnectionStatus = "连接中…" return } } // 2. 查找系统已连接设备 - let connected = central.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) - if let existing = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(Self.deviceNamePrefix) }) { + let connected = central?.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) ?? [] + if let existing = connected.first(where: { ($0.name ?? "").lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) }) { appendLog("发现系统已连接设备: \(existing.name ?? "?")") self.peripheral = existing existing.delegate = self - central.connect(existing, options: nil) + central?.connect(existing, options: nil) bleConnectionStatus = "连接中…" return } @@ -181,14 +249,14 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { } func startScan() { - guard central.state == .poweredOn else { + guard central?.state == .poweredOn else { pendingConnect = true return } isScanning = true bleConnectionStatus = "扫描中…" appendLog("开始扫描 AhaKey 设备…") - central.scanForPeripherals( + central?.scanForPeripherals( withServices: [Self.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) @@ -196,7 +264,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(Double(10) * 1_000_000_000)) if self.isScanning { - self.central.stopScan() + self.central?.stopScan() self.isScanning = false self.bleConnectionStatus = "等待设备" self.appendLog("扫描超时,继续后台轮询设备") @@ -206,7 +274,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { func disconnect() { guard let peripheral else { return } - central.cancelPeripheralConnection(peripheral) + central?.cancelPeripheralConnection(peripheral) appendLog("用户主动断开") } @@ -242,7 +310,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { completedFrames: 0, totalFrames: frames.count ) - appendLog("开始上传 OLED 数据: \(frames.count) 帧, FPS=\(fps), mode=\(mode), startIndex=\(startIndex), frameSlotSize=\(AhaKeyCommand.oledFrameSlotSize)") + appendLog("开始上传 LCD 数据: \(frames.count) 帧, FPS=\(fps), mode=\(mode), startIndex=\(startIndex), frameSlotSize=\(AhaKeyCommand.oledFrameSlotSize)") defer { isUploadingOLED = false @@ -293,7 +361,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { ) appendLog("→ updatePicture mode=\(mode) startIndex=\(startIndex) frameCount=\(frames.count) delayMs=\(delay) hex=\(updateCommand.hexString)") _ = try await sendCommandAwaitingResponse(updateCommand, expectedCommand: AhaKeyCommand.cmdUpdatePic) - appendLog("OLED 上传完成: \(frames.count) 帧, start=\(startIndex)") + appendLog("LCD 上传完成: \(frames.count) 帧, start=\(startIndex)") _ = commandChar } @@ -355,7 +423,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { writeCommand(cmd) } - /// 设置按键描述(显示在 OLED 上) + /// 设置按键描述(显示在 LCD 上) func setKeyDescription(mode: UInt8 = 0, keyIndex: UInt8, text: String) { let cmd = AhaKeyCommand.setKeyDescription(mode: mode, keyIndex: keyIndex, text: text) appendLog("写入 Mode\(mode) Key\(keyIndex + 1) 描述: \(text)") @@ -388,6 +456,36 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { writeCommand(cmd) } + /// 最新固件中 0x91 已改为灯效预览;虚拟拨杆只保留软件覆盖,不再向键盘发送旧 0x91。 + /// value: 0=auto/up, 1=manual/down, 2=mid + func setSwitchStateViaBLE(_ value: UInt8) { + appendLog("虚拟拨杆 sw_state=\(value) 仅作为软件覆盖;最新固件 0x91 用于灯效预览。") + } + + func setLightMapping(mode: UInt8, stateEffects: [UInt8]) { + guard commandChar != nil else { return } + writeCommand(AhaKeyCommand.setLightMapping(mode: mode, stateEffects: stateEffects)) + appendLog("→ 灯效映射 mode=\(mode) effects=\(stateEffects)") + } + + func setBrightness(_ value: UInt8) { + guard commandChar != nil else { return } + writeCommand(AhaKeyCommand.setBrightness(value)) + appendLog("→ 亮度 \(value)") + } + + func previewLightEffect(_ effect: UInt8) { + guard commandChar != nil else { return } + writeCommand(AhaKeyCommand.previewLightEffect(effect)) + appendLog("→ 预览灯效 \(effect)") + } + + func setWorkMode(_ mode: UInt8) { + guard commandChar != nil else { return } + writeCommand(AhaKeyCommand.setWorkMode(mode)) + appendLog("→ 工作模式 \(mode)") + } + /// 修改设备蓝牙名称 func changeDeviceName(_ name: String) { let cmd = AhaKeyCommand.changeName(name) @@ -457,7 +555,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { autoReconnectTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in Task { @MainActor in guard let self else { return } - guard self.central.state == .poweredOn else { return } + guard self.central?.state == .poweredOn else { return } guard !self.isConnected, !self.isScanning else { return } guard self.bleConnectionStatus != "连接中…" else { return } self.appendLog("后台轮询中,尝试寻找设备…") @@ -479,7 +577,7 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { Task { @MainActor in guard let self else { return } guard self.isConnected else { return } - // 正在上传 OLED 时避免占用命令通道 + // 正在上传 LCD 时避免占用命令通道 guard !self.isUploadingOLED else { return } // 有 protocol 响应在等(如 readPictureState / saveConfig)时也跳过 guard self.protocolResponseWaiters.isEmpty else { return } @@ -493,12 +591,98 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { statusPollTimer = nil } + private func startIDEStatePolling() { + ideStatePollTimer?.invalidate() + ideStatePollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.pollIDEStateFile() + } + } + } + + /// 主动触发一次共享文件读取(用户点击虚拟拨杆后立即调用,避免等下一次定时 poll) + func refreshAgentStateFromFileNow() { + pollIDEStateFile() + } + + /// 点击虚拟拨杆瞬间的乐观更新值。在文件 poll 把 agentSwitchState 刷新到目标值前先顶住, + /// 之后 polling 把真实值刷过来时再清掉,保证按一下立刻看到拨杆切档。 + @Published private(set) var optimisticSwitchOverride: Int? = nil + + func applyOptimisticSwitchOverride(_ value: UInt8) { + optimisticSwitchOverride = Int(value) + } + + private func clearOptimisticSwitchOverrideIfMatched() { + guard let opt = optimisticSwitchOverride else { return } + if agentSwitchState == opt || (isConnected && switchState == opt) { + optimisticSwitchOverride = nil + } + } + + private func pollIDEStateFile() { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/current-ide-state.json") + guard let data = try? Data(contentsOf: url), + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + if liveIDEStateValue != nil { liveIDEStateValue = nil } + if agentLightMode != nil { agentLightMode = nil } + if agentSwitchState != nil { agentSwitchState = nil } + if agentWorkMode != nil { agentWorkMode = nil } + return + } + let now = Date().timeIntervalSince1970 + // stateValue 是瞬时态(hook 触发),30s 过期;超时则置空,固件 LED 也会回到无 state 默认 + if let v = obj["stateValue"] as? Int, + let stateTs = (obj["stateTs"] as? Double) ?? (obj["ts"] as? Double), + now - stateTs <= 30 { + if liveIDEStateValue != v { liveIDEStateValue = v } + } else { + if liveIDEStateValue != nil { liveIDEStateValue = nil } + } + // lightMode/switchState/workMode 来自 BLE 通知,2 分钟没新数据视为 agent 已断连 + if let topTs = obj["ts"] as? Double, now - topTs <= 120 { + let lm = obj["lightMode"] as? Int + let sw = obj["switchState"] as? Int + let wm = obj["workMode"] as? Int + if agentLightMode != lm { agentLightMode = lm } + if agentSwitchState != sw { agentSwitchState = sw } + if agentWorkMode != wm { agentWorkMode = wm } + } else { + if agentLightMode != nil { agentLightMode = nil } + if agentSwitchState != nil { agentSwitchState = nil } + if agentWorkMode != nil { agentWorkMode = nil } + } + clearOptimisticSwitchOverrideIfMatched() + } + /// 所有 AhaKey 主服务特征就绪后触发(仅一次) private func onAllCharacteristicsReady() { guard !didQueryAfterConnect else { return } didQueryAfterConnect = true appendLog("所有特征就绪,查询设备状态") queryDeviceStatus() + queryAllPictureStates() + } + + /// 顺序查询每个 mode 的 0x83 图片元信息,结果累积到 keyboardPictureStates + private func queryAllPictureStates() { + Task { [weak self] in + guard let self else { return } + for slot in 0..<4 { + do { + let state = try await self.readPictureState(mode: UInt8(slot)) + self.keyboardPictureStates[slot] = KeyboardPictureState( + frameCount: state.picLength, + frameIntervalMs: state.frameInterval + ) + self.appendLog(" mode\(slot) flash: 帧数=\(state.picLength) 间隔=\(state.frameInterval)ms") + } catch { + self.appendLog(" mode\(slot) 图片状态查询失败: \(error)", isError: true) + } + } + } } private func sendCommandAwaitingResponse(_ data: Data, expectedCommand: UInt8, timeoutSeconds: Double = 5.0) async throws -> CommandResponse { @@ -574,7 +758,8 @@ final class SwitchStateNotifier: ObservableObject { static let shared = SwitchStateNotifier() private weak var bleManager: AhaKeyBLEManager? - private var cancellable: AnyCancellable? + private var switchStateCancellable: AnyCancellable? + private var agentSwitchStateCancellable: AnyCancellable? private var lastObservedState: Int? private var lastNotificationAt: Date? private var hasInitialState = false @@ -583,12 +768,19 @@ final class SwitchStateNotifier: ObservableObject { private init() {} func bind(to manager: AhaKeyBLEManager) { - if bleManager === manager, cancellable != nil { return } + if bleManager === manager, switchStateCancellable != nil, agentSwitchStateCancellable != nil { return } bleManager = manager lastObservedState = nil hasInitialState = false - cancellable = manager.$switchState + switchStateCancellable = manager.$switchState + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] newState in + self?.handleStateChange(newState) + } + agentSwitchStateCancellable = manager.$agentSwitchState + .compactMap { $0 } .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] newState in @@ -617,14 +809,14 @@ final class SwitchStateNotifier: ObservableObject { if switchedToAuto { postNotification( title: "拨杆 → 自动批准", - body: "Claude / Cursor 的工具调用将无条件放行(含 shell、删文件等高危操作)。需要确认时请把拨杆切回手动档。", + body: "Kimi:若已安装 AhaKey Kimi Hooks,自动档会直接接管当前会话批准;若刚装完或刚升级 kimi-cli,请先重开一次 kimi。Claude/Cursor/Codex 仍走各自钩子。", identifier: "lab.jawa.ahakey.switch.auto", isCritical: true ) } else if switchedToManual { postNotification( title: "拨杆 → 手动批准", - body: "Claude / Cursor 将按默认弹窗逐条确认权限。", + body: "Claude / Cursor / Codex:按各自确认链。Kimi:若已安装 AhaKey Kimi Hooks,手动档会直接把当前会话拉回手动批准。", identifier: "lab.jawa.ahakey.switch.manual", isCritical: false ) @@ -642,7 +834,7 @@ final class SwitchStateNotifier: ObservableObject { content: content, trigger: nil) center.add(request) { error in - if error != nil, isCritical { + if error != nil { Task { @MainActor in self?.fallbackAlert(title: title, body: body) } @@ -658,7 +850,7 @@ final class SwitchStateNotifier: ObservableObject { center.requestAuthorization(options: [.alert, .sound]) { granted, _ in if granted { deliver() - } else if isCritical { + } else { Task { @MainActor in self.fallbackAlert(title: title, body: body) } @@ -712,12 +904,19 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { Task { @MainActor in switch central.state { case .poweredOn: + self.refreshBluetoothAuthorization() self.appendLog("蓝牙已开启") self.connectAutomatically() case .poweredOff: + self.refreshBluetoothAuthorization() self.appendLog("蓝牙已关闭", isError: true) self.bleConnectionStatus = "蓝牙关闭" + case .unauthorized: + self.refreshBluetoothAuthorization() + self.appendLog("蓝牙权限未开启", isError: true) + self.bleConnectionStatus = "蓝牙权限未开启" default: + self.refreshBluetoothAuthorization() break } } @@ -730,15 +929,15 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { rssi RSSI: NSNumber ) { let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" - guard name.lowercased().hasPrefix(Self.deviceNamePrefix) else { return } + guard name.lowercased().hasPrefix(Self.deviceNamePrefix.lowercased()) else { return } Task { @MainActor in self.appendLog("发现设备: \(name) RSSI=\(RSSI)") - self.central.stopScan() + self.central?.stopScan() self.isScanning = false self.peripheral = peripheral peripheral.delegate = self - self.central.connect(peripheral, options: nil) + self.central?.connect(peripheral, options: nil) self.bleConnectionStatus = "连接中…" } } @@ -804,6 +1003,7 @@ extension AhaKeyBLEManager: CBCentralManagerDelegate { self.isWriting = false self.writeBatches.removeAll() self.didQueryAfterConnect = false + self.keyboardPictureStates.removeAll() self.stopRSSIPolling() self.stopStatusPolling() self.startAutoReconnectPolling() @@ -961,7 +1161,8 @@ extension AhaKeyBLEManager: CBPeripheralDelegate { ) lightMode = status.lightMode switchState = status.switchState - appendLog(" 状态: 电量=\(status.battery) 固件=\(status.firmwareMain).\(status.firmwareSub) 模式=\(status.workMode) 灯=\(status.lightMode) 开关=\(status.switchState)") + brightness = status.brightness + appendLog(" 状态: 电量=\(status.battery) 固件=\(status.firmwareMain).\(status.firmwareSub) 模式=\(status.workMode) 灯=\(status.lightMode) 开关=\(status.switchState) 亮度=\(status.brightness)") } else if AhaKeyResponseParser.isProtocolFrame(data) { if let response = AhaKeyResponseParser.parseCommandResponse(data) { protocolResponseWaiters.removeValue(forKey: response.cmd)?.resume(returning: (response.status, response.payload)) diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift new file mode 100644 index 00000000..cce8f5c3 --- /dev/null +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift @@ -0,0 +1,566 @@ +import Foundation + +/// AhaKey-X1 BLE 协议编解码 +/// +/// 帧格式: AA BB [cmd:1] [data:N] CC DD +/// 原厂代码: build_device_frame(cmd, data) = FRAME_HEAD + bytes([cmd]) + data + FRAME_TAIL +enum AhaKeyCommand { + static let header: [UInt8] = [0xAA, 0xBB] + static let trailer: [UInt8] = [0xCC, 0xDD] + static let oledWidth = 160 + static let oledHeight = 80 + static let oledFrameSlotSize = 28_672 + static let oledFactoryReservedSlots = 10 + static let oledModeCount = 4 + static let oledMaxFramesPerMode = 70 + static let oledMaxFrames = oledMaxFramesPerMode + /// 用户选择的 GIF 源文件大小上限(避免过大文件拖慢解码与 BLE 上传)。 + static let oledMaxSourceFileBytes = 2 * 1024 * 1024 // 2 MB + /// 固件端要求每个 prepareWrite 的 address 必须 4096 字节对齐(flash 扇区大小)。 + /// 原厂 Python 客户端也用 4096 作为写入分块大小,一次 prepareWrite 刚好擦写一个扇区。 + static let oledChunkSize = 4096 + /// BLE data 特征单次 writeValue 的软上限(与固件接收 FIFO 匹配,不走协商 MTU)。 + static let oledPacketSize = 180 + + // 设备命令 (DeviceCmd) + static let cmdChangeName: UInt8 = 0x01 + static let cmdChangeAppearance: UInt8 = 0x02 + static let cmdSaveConfig: UInt8 = 0x04 + static let cmdUpdateCustomKey: UInt8 = 0x73 + static let cmdPrepareWrite: UInt8 = 0x80 + static let cmdWriteResult: UInt8 = 0x81 + static let cmdUpdatePic: UInt8 = 0x82 + static let cmdReadPicState: UInt8 = 0x83 + static let cmdUpdateState: UInt8 = 0x90 // IDE 状态 → LED 变色 + static let cmdPreviewLightEffect: UInt8 = 0x91 // 直接预览灯效,不保存配置 + static let cmdSetLightMapping: UInt8 = 0x84 // per-mode per-state LED 映射 + static let cmdSetBrightness: UInt8 = 0x85 // 全局 WS2812 亮度 1-100 + static let cmdSetWorkMode: UInt8 = 0x92 // 远程切换工作模式 0-3 + + static func oledStartIndex(forMode mode: UInt8) -> UInt16 { + UInt16(oledFactoryReservedSlots + Int(min(3, mode)) * oledMaxFramesPerMode) + } + + // 按键子类型 (KeySubType) + static let subShortcut: UInt8 = 0x73 + static let subMacro: UInt8 = 0x74 + static let subDescription: UInt8 = 0x75 + + /// 设备状态查询 → AA BB 00 CC DD + static func queryDeviceStatus() -> Data { + Data(header + [0x00] + trailer) + } + + /// 保存配置到设备 Flash → AA BB 04 CC DD + static func saveConfig() -> Data { + Data(header + [cmdSaveConfig] + trailer) + } + + /// 键码写入 → AA BB 73 73 [mode] [key_index] [hid_codes...] CC DD + /// - Parameters: + /// - mode: 工作模式 0-3 + /// - keyIndex: 0=Key1, 1=Key2, 2=Key3, 3=Key4 + /// - hidCodes: HID Usage ID 数组(修饰键在前,普通键在后,最多 98 字节) + static func setKeyMapping(mode: UInt8 = 0, keyIndex: UInt8, hidCodes: [UInt8]) -> Data { + let payload: [UInt8] = [subShortcut, mode, keyIndex] + hidCodes + return Data(header + [cmdUpdateCustomKey] + payload + trailer) + } + + /// 描述写入 → AA BB 73 75 [mode] [key_index] [utf8...] CC DD + /// - Parameters: + /// - mode: 工作模式 0-3 + /// - keyIndex: 0=Key1, 1=Key2, 2=Key3, 3=Key4 + /// - text: 显示在 LCD 上的按键描述(最多 20 字节 ASCII) + static func setKeyDescription(mode: UInt8 = 0, keyIndex: UInt8, text: String) -> Data { + let textBytes = Array(text.sanitizedASCII(maxLength: 20).utf8) + let payload: [UInt8] = [subDescription, mode, keyIndex] + textBytes + return Data(header + [cmdUpdateCustomKey] + payload + trailer) + } + + /// 宏写入 → AA BB 73 74 [mode] [key_index] [action, param, ...] CC DD + static func setKeyMacro(mode: UInt8 = 0, keyIndex: UInt8, macroData: [UInt8]) -> Data { + let payload: [UInt8] = [subMacro, mode, keyIndex] + macroData + return Data(header + [cmdUpdateCustomKey] + payload + trailer) + } + + /// 修改设备名称 → AA BB 01 [utf8...] CC DD + static func changeName(_ name: String) -> Data { + let nameBytes = Array(name.utf8.prefix(21)) + return Data(header + [cmdChangeName] + nameBytes + trailer) + } + + /// 修改 BLE Appearance → AA BB 02 [appearance] CC DD + static func changeAppearance(_ value: UInt8) -> Data { + Data(header + [cmdChangeAppearance, value] + trailer) + } + + /// 读取图片状态 → AA BB 83 [mode] CC DD + static func readPicState(mode: UInt8) -> Data { + Data(header + [cmdReadPicState, mode] + trailer) + } + + /// 预备写入大块数据 → AA BB 80 [flag:1] [chunk_len:2 LE] [address:4 LE] CC DD + static func prepareWrite(flag: UInt8 = 0x00, chunkLength: Int, address: UInt32) -> Data { + let payload: [UInt8] = [ + flag, + UInt8(chunkLength & 0xFF), + UInt8((chunkLength >> 8) & 0xFF), + UInt8(address & 0xFF), + UInt8((address >> 8) & 0xFF), + UInt8((address >> 16) & 0xFF), + UInt8((address >> 24) & 0xFF), + ] + return Data(header + [cmdPrepareWrite] + payload + trailer) + } + + /// 更新 LCD 动画参数 → AA BB 82 [mode] [start_index:2 LE] [frame_count:2 LE] [time_delay:2 LE] CC DD + static func updatePicture(mode: UInt8, startIndex: UInt16, frameCount: UInt16, timeDelayMs: UInt16) -> Data { + let payload: [UInt8] = [ + mode, + UInt8(startIndex & 0xFF), + UInt8((startIndex >> 8) & 0xFF), + UInt8(frameCount & 0xFF), + UInt8((frameCount >> 8) & 0xFF), + UInt8(timeDelayMs & 0xFF), + UInt8((timeDelayMs >> 8) & 0xFF), + ] + return Data(header + [cmdUpdatePic] + payload + trailer) + } + + /// IDE 状态同步 → AA BB 90 [state] CC DD + /// 驱动键盘 LED 变色,反映 Claude/Cursor 当前状态 + static func updateState(_ state: IDEState) -> Data { + Data(header + [cmdUpdateState, state.rawValue] + trailer) + } + + /// per-mode per-state LED 灯效映射 → AA BB 84 [mode] [state0_light]...[state8_light] CC DD + static func setLightMapping(mode: UInt8, stateEffects: [UInt8]) -> Data { + var effects = Array(stateEffects.prefix(9)) + while effects.count < 9 { effects.append(0) } + return Data(header + [cmdSetLightMapping, mode] + effects + trailer) + } + + /// 全局 WS2812 亮度 → AA BB 85 [brightness] CC DD + static func setBrightness(_ value: UInt8) -> Data { + let clamped = max(1, min(100, value)) + return Data(header + [cmdSetBrightness, clamped] + trailer) + } + + /// 直接预览某个灯效 → AA BB 91 [effect] CC DD + static func previewLightEffect(_ effect: UInt8) -> Data { + Data(header + [cmdPreviewLightEffect, effect] + trailer) + } + + /// 切换工作模式 → AA BB 92 [mode] CC DD + static func setWorkMode(_ mode: UInt8) -> Data { + Data(header + [cmdSetWorkMode, min(3, mode)] + trailer) + } +} + +/// IDE 状态枚举(原厂 ClaudeState) +/// 发送到键盘后驱动 LED 颜色变化 +enum IDEState: UInt8, CaseIterable, Codable, Identifiable { + case notification = 0 // 通知 + case permissionRequest = 1 // 等待授权 + case postToolUse = 2 // 工具执行完毕 + case preToolUse = 3 // 工具执行中 + case sessionStart = 4 // 会话开始 + case stop = 5 // 已停止 + case taskCompleted = 6 // 任务完成 + case userPromptSubmit = 7 // 用户提交 + case sessionEnd = 8 // 会话结束 + + var label: String { + switch self { + case .notification: return "0 通知" + case .permissionRequest: return "1 等待授权" + case .postToolUse: return "2 工具完毕" + case .preToolUse: return "3 工具执行" + case .sessionStart: return "4 会话开始" + case .stop: return "5 停止" + case .taskCompleted: return "6 任务完成" + case .userPromptSubmit: return "7 用户提交" + case .sessionEnd: return "8 会话结束" + } + } + + var id: UInt8 { rawValue } + + static let workflowOrder: [IDEState] = [ + .sessionStart, + .userPromptSubmit, + .preToolUse, + .permissionRequest, + .postToolUse, + .notification, + .taskCompleted, + .stop, + .sessionEnd, + ] + + var shortLabel: String { + switch self { + case .notification: return "通知" + case .permissionRequest: return "等待授权" + case .postToolUse: return "工具完毕" + case .preToolUse: return "工具执行" + case .sessionStart: return "会话开始" + case .stop: return "停止" + case .taskCompleted: return "任务完成" + case .userPromptSubmit: return "用户提交" + case .sessionEnd: return "会话结束" + } + } +} + +/// 设备状态响应解析结果 +struct AhaKeyDeviceStatus { + let battery: Int + let signal: Int + let firmwareMain: Int + let firmwareSub: Int + let workMode: Int + let lightMode: Int + let switchState: Int + let brightness: Int +} + +struct AhaKeyPictureState { + let mode: Int + let startIndex: Int + let picLength: Int + let frameInterval: Int + let allModeMaxPic: Int +} + +/// AhaKey 协议响应解析器 +enum AhaKeyResponseParser { + static func parseCommandResponse(_ data: Data) -> (cmd: UInt8, status: UInt8, payload: Data)? { + guard isProtocolFrame(data), data.count >= 6 else { return nil } + let cmd = data[2] + let status = data[3] + let payload = data.count > 6 ? Data(data[4 ..< data.count - 2]) : Data() + return (cmd, status, payload) + } + + /// 尝试从 notify 数据中解析设备状态 + /// 实际格式: AA BB [cmd_echo] [battery] [signal] [fw_main] [fw_sub] [work] [light] [switch] ... CC DD + /// 第一个 payload 字节是命令回显(0x00),真实数据从第二字节开始 + static func parseDeviceStatus(_ data: Data) -> AhaKeyDeviceStatus? { + // header(2) + cmd_echo(1) + 7 bytes status + trailer(2) = 12 bytes minimum + guard data.count >= 12, + data[0] == 0xAA, data[1] == 0xBB, + data[data.count - 2] == 0xCC, data[data.count - 1] == 0xDD else { + return nil + } + + let payload = data[2 ..< data.count - 2] + // payload[0] = command echo (0x00), skip it + guard payload.count >= 8, payload[payload.startIndex] == 0x00 else { return nil } + + let base = payload.startIndex + 1 // skip cmd echo + let brightness = payload.count >= 9 ? Int(payload[base + 7]) : 35 + return AhaKeyDeviceStatus( + battery: Int(payload[base]), + signal: Int(Int8(bitPattern: payload[base + 1])), + firmwareMain: Int(payload[base + 2]), + firmwareSub: Int(payload[base + 3]), + workMode: Int(payload[base + 4]), + lightMode: Int(payload[base + 5]), + switchState: Int(payload[base + 6]), + brightness: brightness + ) + } + + static func parsePictureStateResponse(_ payload: Data) -> AhaKeyPictureState? { + guard payload.count >= 9 else { return nil } + + let mode = Int(payload[0]) + let startIndex = Int(UInt16(payload[1]) | (UInt16(payload[2]) << 8)) + let picLength = Int(UInt16(payload[3]) | (UInt16(payload[4]) << 8)) + let frameInterval = Int(UInt16(payload[5]) | (UInt16(payload[6]) << 8)) + let allModeMaxPic = Int(UInt16(payload[7]) | (UInt16(payload[8]) << 8)) + + return AhaKeyPictureState( + mode: mode, + startIndex: startIndex, + picLength: picLength, + frameInterval: frameInterval, + allModeMaxPic: allModeMaxPic + ) + } + + /// 检查是否是 AhaKey 协议帧 + static func isProtocolFrame(_ data: Data) -> Bool { + data.count >= 4 + && data[0] == 0xAA && data[1] == 0xBB + && data[data.count - 2] == 0xCC && data[data.count - 1] == 0xDD + } +} + +/// 常用 HID Usage ID +enum HIDUsage { + // 修饰键 + static let leftControl: UInt8 = 0xE0 + static let leftShift: UInt8 = 0xE1 + static let leftAlt: UInt8 = 0xE2 + static let leftGUI: UInt8 = 0xE3 + static let rightControl: UInt8 = 0xE4 + static let rightShift: UInt8 = 0xE5 + static let rightAlt: UInt8 = 0xE6 + static let rightGUI: UInt8 = 0xE7 + + // 功能键 + static let f1: UInt8 = 0x3A + static let f2: UInt8 = 0x3B + static let f3: UInt8 = 0x3C + static let f4: UInt8 = 0x3D + static let f5: UInt8 = 0x3E + static let f6: UInt8 = 0x3F + static let f7: UInt8 = 0x40 + static let f8: UInt8 = 0x41 + static let f9: UInt8 = 0x42 + static let f10: UInt8 = 0x43 + static let f11: UInt8 = 0x44 + static let f12: UInt8 = 0x45 + static let f13: UInt8 = 0x68 + static let f14: UInt8 = 0x69 + static let f15: UInt8 = 0x6A + static let f16: UInt8 = 0x6B + static let f17: UInt8 = 0x6C + static let f18: UInt8 = 0x6D + static let f19: UInt8 = 0x6E + static let f20: UInt8 = 0x6F + + // 基础键 + static let enter: UInt8 = 0x28 + static let escape: UInt8 = 0x29 + static let backspace: UInt8 = 0x2A + static let tab: UInt8 = 0x2B + static let space: UInt8 = 0x2C + static let capsLock: UInt8 = 0x39 + static let deleteForward: UInt8 = 0x4C + static let insert: UInt8 = 0x49 + static let home: UInt8 = 0x4A + static let pageUp: UInt8 = 0x4B + static let end: UInt8 = 0x4D + static let pageDown: UInt8 = 0x4E + static let minus: UInt8 = 0x2D + static let equal: UInt8 = 0x2E + static let leftBracket: UInt8 = 0x2F + static let rightBracket: UInt8 = 0x30 + static let backslash: UInt8 = 0x31 + static let semicolon: UInt8 = 0x33 + static let quote: UInt8 = 0x34 + static let grave: UInt8 = 0x35 + static let comma: UInt8 = 0x36 + static let period: UInt8 = 0x37 + static let slash: UInt8 = 0x38 + static let keypadSlash: UInt8 = 0x54 + static let keypadAsterisk: UInt8 = 0x55 + static let keypadMinus: UInt8 = 0x56 + static let keypadPlus: UInt8 = 0x57 + static let keypadEnter: UInt8 = 0x58 + static let keypad1: UInt8 = 0x59 + static let keypad2: UInt8 = 0x5A + static let keypad3: UInt8 = 0x5B + static let keypad4: UInt8 = 0x5C + static let keypad5: UInt8 = 0x5D + static let keypad6: UInt8 = 0x5E + static let keypad7: UInt8 = 0x5F + static let keypad8: UInt8 = 0x60 + static let keypad9: UInt8 = 0x61 + static let keypad0: UInt8 = 0x62 + static let keypadPeriod: UInt8 = 0x63 + + // 方向键 + static let rightArrow: UInt8 = 0x4F + static let leftArrow: UInt8 = 0x50 + static let downArrow: UInt8 = 0x51 + static let upArrow: UInt8 = 0x52 + + /// 所有可用的键码选项(用于 UI 选择器) + static let allOptions: [(name: String, code: UInt8)] = [ + // 功能键 + ("F1", f1), ("F2", f2), ("F3", f3), ("F4", f4), + ("F5", f5), ("F6", f6), ("F7", f7), ("F8", f8), + ("F9", f9), ("F10", f10), ("F11", f11), ("F12", f12), + ("F13", f13), ("F14", f14), ("F15", f15), ("F16", f16), + ("F17", f17), ("F18", f18), ("F19", f19), ("F20", f20), + // 基础键 + ("Enter", enter), ("Escape", escape), ("Backspace", backspace), + ("Tab", tab), ("Space", space), ("CapsLock", capsLock), + ("Delete", deleteForward), ("Insert", insert), ("Home", home), + ("End", end), ("Page Up", pageUp), ("Page Down", pageDown), + ("-", minus), ("=", equal), ("[", leftBracket), ("]", rightBracket), + ("\\", backslash), (";", semicolon), ("'", quote), ("`", grave), + (",", comma), (".", period), ("/", slash), + // 方向键 + ("→", rightArrow), ("←", leftArrow), ("↓", downArrow), ("↑", upArrow), + // 字母键 + ("A", 0x04), ("B", 0x05), ("C", 0x06), ("D", 0x07), + ("E", 0x08), ("F", 0x09), ("G", 0x0A), ("H", 0x0B), + ("I", 0x0C), ("J", 0x0D), ("K", 0x0E), ("L", 0x0F), + ("M", 0x10), ("N", 0x11), ("O", 0x12), ("P", 0x13), + ("Q", 0x14), ("R", 0x15), ("S", 0x16), ("T", 0x17), + ("U", 0x18), ("V", 0x19), ("W", 0x1A), ("X", 0x1B), + ("Y", 0x1C), ("Z", 0x1D), + // 数字键 + ("1", 0x1E), ("2", 0x1F), ("3", 0x20), ("4", 0x21), + ("5", 0x22), ("6", 0x23), ("7", 0x24), ("8", 0x25), + ("9", 0x26), ("0", 0x27), + // 修饰键 + ("Left Ctrl", leftControl), ("Left Shift", leftShift), + ("Left Alt", leftAlt), ("Left Cmd", leftGUI), + ("Right Ctrl", rightControl), ("Right Shift", rightShift), + ("Right Alt", rightAlt), ("Right Cmd", rightGUI), + // 小键盘 + ("Keypad /", keypadSlash), ("Keypad *", keypadAsterisk), + ("Keypad -", keypadMinus), ("Keypad +", keypadPlus), + ("Keypad Enter", keypadEnter), ("Keypad 0", keypad0), + ("Keypad 1", keypad1), ("Keypad 2", keypad2), ("Keypad 3", keypad3), + ("Keypad 4", keypad4), ("Keypad 5", keypad5), ("Keypad 6", keypad6), + ("Keypad 7", keypad7), ("Keypad 8", keypad8), ("Keypad 9", keypad9), + ("Keypad .", keypadPeriod), + ] + + static let primaryOptions = allOptions + + /// 根据键码查找名称 + static func name(for code: UInt8) -> String { + allOptions.first { $0.code == code }?.name ?? String(format: "0x%02X", code) + } + + static func hidCode(forMacKeyCode keyCode: UInt16) -> UInt8? { + switch keyCode { + case 0: return 0x04 // A + case 1: return 0x16 // S + case 2: return 0x07 // D + case 3: return 0x09 // F + case 4: return 0x0B // H + case 5: return 0x0A // G + case 6: return 0x1D // Z + case 7: return 0x1B // X + case 8: return 0x06 // C + case 9: return 0x19 // V + case 11: return 0x05 // B + case 12: return 0x14 // Q + case 13: return 0x1A // W + case 14: return 0x08 // E + case 15: return 0x15 // R + case 16: return 0x1C // Y + case 17: return 0x17 // T + case 18: return 0x1E // 1 + case 19: return 0x1F // 2 + case 20: return 0x20 // 3 + case 21: return 0x21 // 4 + case 22: return 0x23 // 6 + case 23: return 0x22 // 5 + case 24: return equal + case 25: return 0x26 // 9 + case 26: return 0x24 // 7 + case 27: return minus + case 28: return 0x25 // 8 + case 29: return 0x27 // 0 + case 30: return rightBracket + case 31: return 0x12 // O + case 32: return 0x18 // U + case 33: return leftBracket + case 34: return 0x0C // I + case 35: return 0x13 // P + case 36: return enter + case 37: return 0x0F // L + case 38: return 0x0D // J + case 39: return quote + case 40: return 0x0E // K + case 41: return semicolon + case 42: return backslash + case 43: return comma + case 44: return slash + case 45: return 0x11 // N + case 46: return 0x10 // M + case 47: return period + case 48: return tab + case 49: return space + case 50: return grave + case 51: return backspace + case 53: return escape + case 54: return rightGUI + case 55: return leftGUI + case 56: return leftShift + case 57: return capsLock + case 58: return leftAlt + case 59: return leftControl + case 60: return rightShift + case 61: return rightAlt + case 62: return rightControl + case 63: return f19 // Fn/Globe reports as a function modifier on many Mac keyboards. + case 64: return f17 + case 65: return keypadPeriod + case 67: return keypadAsterisk + case 69: return keypadPlus + case 71: return 0x53 // Keypad Clear / Num Lock + case 75: return keypadSlash + case 76: return keypadEnter + case 78: return keypadMinus + case 79: return f18 + case 80: return f19 + case 82: return keypad0 + case 83: return keypad1 + case 84: return keypad2 + case 85: return keypad3 + case 86: return keypad4 + case 87: return keypad5 + case 88: return keypad6 + case 89: return keypad7 + case 90: return f20 + case 91: return keypad8 + case 92: return keypad9 + case 96: return f5 + case 97: return f6 + case 98: return f7 + case 99: return f3 + case 100: return f8 + case 101: return f9 + case 103: return f11 + case 105: return f13 + case 106: return f16 + case 107: return f14 + case 109: return f10 + case 111: return f12 + case 113: return f15 + case 115: return home + case 116: return pageUp + case 117: return deleteForward + case 118: return f4 + case 119: return end + case 120: return f2 + case 121: return pageDown + case 122: return f1 + case 123: return leftArrow + case 124: return rightArrow + case 125: return downArrow + case 126: return upArrow + default: return nil + } + } +} + +extension String { + /// 设备 LCD 描述只稳定支持 ASCII;非 ASCII 字符会在设备端变成乱码。 + func sanitizedASCII(maxLength: Int) -> String { + var result = String() + result.reserveCapacity(min(maxLength, count)) + + for scalar in unicodeScalars where scalar.isASCII { + guard result.utf8.count < maxLength else { break } + result.unicodeScalars.append(scalar) + } + + return result + } + + var containsNonASCII: Bool { + unicodeScalars.contains(where: { !$0.isASCII }) + } +} diff --git a/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift new file mode 100644 index 00000000..1962a5fe --- /dev/null +++ b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift @@ -0,0 +1,1340 @@ +import Foundation + +enum AhaKeyModeSlot: Int, CaseIterable, Codable, Identifiable { + case mode0 = 0 + case mode1 = 1 + case mode2 = 2 + case mode3 = 3 + + var id: Int { rawValue } + + var title: String { + "Mode \(rawValue + 1)" + } + + var shortTitle: String { + "M\(rawValue + 1)" + } + + var defaultName: String { + switch self { + case .mode0: "Claude" + case .mode1: "Cursor" + case .mode2: "Codex" + case .mode3: "custom" + } + } + + var name: String { + AhaKeyModeNameStore.load()[rawValue] ?? defaultName + } + + var subtitle: String { + switch self { + case .mode0: + "Claude Code · 终端权限 Y/N" + case .mode1: + "Cursor · Composer Accept/Reject" + case .mode2: + "Codex · ↵ / Esc" + case .mode3: + "custom · 自定义模式" + } + } + + var guidance: String { + switch self { + case .mode0: + "针对 Claude Code 终端权限菜单:Key2 直接输入 Y(同意),Key3 直接输入 N(拒绝)。" + case .mode1: + "针对 Cursor Composer / Agent:Key2 发 ↵、Key3 发 ⌫(与裸键一致)。" + case .mode2: + "针对 Codex 终端审批:Key2 发送 ↵ 确认,Key3 发送 Esc 取消。" + case .mode3: + "自定义模式:可自由配置所有按键和灯效。" + } + } + + var guidanceHoverDetail: String? { + switch self { + case .mode1: + return "若需与「⌘↵ 接受 / ⌘⌫ 拒绝」等组合键一致,请在编辑器里为对应键加修饰,并在 Cursor 设置 → Keyboard Shortcuts 中绑成相同组合。" + case .mode0, .mode2, .mode3: + return nil + } + } +} + +enum AhaKeyModeNameStore { + private static let key = "ahakey.mode.customNames.v1" + + static func load() -> [Int: String] { + guard let data = UserDefaults.standard.data(forKey: key), + let dict = try? JSONDecoder().decode([Int: String].self, from: data) else { + return [:] + } + return dict + } + + static func save(_ names: [Int: String]) { + guard let data = try? JSONEncoder().encode(names) else { return } + UserDefaults.standard.set(data, forKey: key) + } +} + +enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { + case lightBar + case oledDisplay + case key1 + case key2 + case key3 + case key4 + case toggleSwitch + + var id: String { rawValue } + + var title: String { + switch self { + case .lightBar: + "灯条" + case .oledDisplay: + "LCD 屏幕" + case .key1: + "Key 1" + case .key2: + "Key 2" + case .key3: + "Key 3" + case .key4: + "Key 4" + case .toggleSwitch: + "拨杆" + } + } + + var subtitle: String { + switch self { + case .lightBar: + "AI 状态反馈" + case .oledDisplay: + "动图显示" + case .key1: + "语音键" + case .key2: + "确认键" + case .key3: + "取消键" + case .key4: + "删除键" + case .toggleSwitch: + "批准方式" + } + } + + var systemImage: String { + switch self { + case .lightBar: + // lightspectrum.horizontal requires macOS 13; fall back to light.max on macOS 12 + if #available(macOS 13, *) { "lightspectrum.horizontal" } else { "light.max" } + case .oledDisplay: + "rectangle.inset.filled" + case .key1: + "microphone" + case .key2: + "checkmark" + case .key3: + "xmark" + case .key4: + "delete.left" + case .toggleSwitch: + "switch.2" + } + } + + var keyRole: AhaKeyKeyRole? { + switch self { + case .key1: + .voice + case .key2: + .approve + case .key3: + .reject + case .key4: + .submit + default: + nil + } + } + + var isKey: Bool { keyRole != nil } +} + +enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { + case voice = 0 + case approve = 1 + case reject = 2 + case submit = 3 + + var id: Int { rawValue } + + var part: AhaKeyStudioPart { + switch self { + case .voice: + .key1 + case .approve: + .key2 + case .reject: + .key3 + case .submit: + .key4 + } + } + + var title: String { + switch self { + case .voice: + "语音键" + case .approve: + "确认键" + case .reject: + "取消键" + case .submit: + "删除键" + } + } + + var systemImage: String { + switch self { + case .voice: + "microphone" + case .approve: + "checkmark" + case .reject: + "xmark" + case .submit: + "delete.left" + } + } + + var defaultDescription: String { + switch self { + case .voice: + "Record" + case .approve: + "Accept" + case .reject: + "Reject" + case .submit: + "Backspace" + } + } + + var manualText: String { + switch self { + case .voice: + "优先用来触发语音输入,用户在软件里看到的是语音软件名,底层仍写成快捷键。" + case .approve: + "适合批准、确认、继续执行这类高频动作。" + case .reject: + "适合拒绝、取消、停止这类相反动作。" + case .submit: + "出厂默认 Backspace,适合删除、撤销输入或清理当前内容。" + } + } +} + +enum ShortcutModifier: String, CaseIterable, Codable, Identifiable { + case control + case option + case shift + case command + + var id: String { rawValue } + + var title: String { + switch self { + case .control: + "Control" + case .option: + "Option" + case .shift: + "Shift" + case .command: + "Command" + } + } + + var symbol: String { + switch self { + case .control: + "⌃" + case .option: + "⌥" + case .shift: + "⇧" + case .command: + "⌘" + } + } + + var hidCode: UInt8 { + switch self { + case .control: + HIDUsage.leftControl + case .option: + HIDUsage.leftAlt + case .shift: + HIDUsage.leftShift + case .command: + HIDUsage.leftGUI + } + } + + static let displayOrder: [ShortcutModifier] = [.control, .option, .shift, .command] +} + +struct ShortcutBinding: Codable, Equatable { + var modifiers: [ShortcutModifier] + var keyCode: UInt8 + + init(modifiers: [ShortcutModifier] = [], keyCode: UInt8 = 0) { + self.modifiers = Self.normalized(modifiers) + self.keyCode = keyCode + } + + var hidCodes: [UInt8] { + orderedModifiers.map(\.hidCode) + (keyCode == 0 ? [] : [keyCode]) + } + + var orderedModifiers: [ShortcutModifier] { + modifiers.sorted { lhs, rhs in + let order = ShortcutModifier.displayOrder + return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)! + } + } + + var displayLabel: String { + let modifierLabel = orderedModifiers.map(\.symbol).joined() + let keyLabel = keyCode == 0 ? "" : HIDUsage.name(for: keyCode) + let combined = modifierLabel + keyLabel + return combined.isEmpty ? "未设置" : combined + } + + var isConfigured: Bool { + keyCode != 0 || !modifiers.isEmpty + } + + mutating func setModifier(_ modifier: ShortcutModifier, enabled: Bool) { + var next = modifiers + if enabled { + next.append(modifier) + } else { + next.removeAll { $0 == modifier } + } + modifiers = Self.normalized(next) + } + + private static func normalized(_ modifiers: [ShortcutModifier]) -> [ShortcutModifier] { + var seen = Set() + var result: [ShortcutModifier] = [] + for modifier in ShortcutModifier.displayOrder where modifiers.contains(modifier) { + if seen.insert(modifier).inserted { + result.append(modifier) + } + } + return result + } +} + +enum VoicePreset: String, CaseIterable, Codable, Identifiable { + case macOSNative + case typeless + case wechat + case claudeCode + case kimiCode + case codex + case doubao + case custom + + var id: String { rawValue } + + /// claudeCode / kimiCode 与 macOSNative 底层路由完全相同,合并展示为同一选项。 + /// 保留枚举 case 是为了向下兼容已存储的配置数据;迁移在 AhaKeyStudioStore 完成。 + var isMacOSNativeFamily: Bool { + self == .macOSNative || self == .claudeCode || self == .kimiCode + } + + /// Picker 中实际展示的选项(微信/豆包并入 Fn/Globe;旧 case 保留用于迁移) + static var visibleCases: [VoicePreset] { + [.macOSNative, .typeless, .custom] + } + + var title: String { + switch self { + case .macOSNative, .claudeCode, .kimiCode: + "macOS 原生转写" + case .typeless: + "Fn/Globe" + case .wechat: + "微信语音" + case .codex: + "Codex" + case .doubao: + "豆包输入法" + case .custom: + "自定义快捷键" + } + } + + var detail: String { + switch self { + case .macOSNative, .claudeCode, .kimiCode: + "调用苹果原生语音转写,识别完成后以 ⌘V 写回当前光标位置。适合 Claude Code、Kimi Code、Codex 等 CLI 终端及任意输入框。按一次开始,再按一次结束。" + case .typeless: + "预设对应快捷键:Typeless/微信语音/豆包输入法内仍选 Fn/Globe。本 Studio 使用 F19 作为 Fn 触发键;按下后向系统注入「按住 Fn」。旧版 F18 仍会兼容监听。请授予输入监控与辅助功能。" + case .wechat: + "AhaKey Studio 使用 F19 作为 Fn 触发键,并在后台把语音键的按下/松开转换成 Fn/Globe,便于接入微信语音。" + case .doubao: + "豆包输入法 Mac 版需要直接接收真实语音键事件。AhaKey Studio 会切到豆包输入源,并把 F18 配置为豆包长按语音快捷键;按住语音键说话,松开后由豆包提交文字。" + case .codex: + "规划中,保留入口。" + case .custom: + "直接自己指定底层快捷键。" + } + } + + var availableInV1: Bool { + switch self { + case .codex: + false + default: + true + } + } + + var defaultBinding: ShortcutBinding { + switch self { + case .macOSNative: + ShortcutBinding(keyCode: HIDUsage.f18) + case .typeless: + // 与 macOS 原生默认 F18 错开;固件可把 Typeless 档语音键设为 F19,Mode 0 另有 F18 出厂兼容路由 + ShortcutBinding(keyCode: HIDUsage.f19) + case .wechat: + ShortcutBinding(keyCode: HIDUsage.f19) + case .claudeCode: + ShortcutBinding(keyCode: HIDUsage.f18) + case .kimiCode: + ShortcutBinding(keyCode: HIDUsage.f18) + case .codex: + ShortcutBinding(keyCode: HIDUsage.f18) + case .doubao: + ShortcutBinding(keyCode: HIDUsage.f18) + case .custom: + ShortcutBinding() + } + } +} + +enum LightBarPreviewState: String, CaseIterable, Codable, Identifiable { + case aiRunning + case waitingApproval + case stopped + case taskCompleted + + var id: String { rawValue } + + var title: String { + switch self { + case .aiRunning: + "AI 运行中" + case .waitingApproval: + "等待批准" + case .stopped: + "已停止" + case .taskCompleted: + "任务完成" + } + } + + var detail: String { + switch self { + case .aiRunning: + "默认效果是来回流水灯。" + case .waitingApproval: + "提醒用户当前需要确认。" + case .stopped: + "默认用红色常亮停住。" + case .taskCompleted: + "表示本轮执行已经完成。" + } + } + + var ideState: IDEState { + switch self { + case .aiRunning: + .preToolUse + case .waitingApproval: + .permissionRequest + case .stopped: + .stop + case .taskCompleted: + .taskCompleted + } + } +} + +enum LightEffectStyle: String, CaseIterable, Codable, Identifiable { + case off + case middleLight + case singleMove + case breathing + case rainbowMove + case rainbowWave + case rainbowWaveSlow + case typingRipple + case comet + case scanBar + case pulseCenter + case warningBlink + case successSweep + case blueThinking + case lowBattery + case chargingFlow + case approvalWait + + var id: String { rawValue } + + var firmwareIndex: UInt8 { + switch self { + case .off: 0 + case .singleMove: 1 + case .rainbowMove: 2 + case .rainbowWave: 3 + case .rainbowWaveSlow: 4 + case .breathing: 5 + case .middleLight: 6 + case .typingRipple: 7 + case .comet: 8 + case .scanBar: 9 + case .pulseCenter: 10 + case .warningBlink: 11 + case .successSweep: 12 + case .blueThinking: 13 + case .lowBattery: 14 + case .chargingFlow: 15 + case .approvalWait: 16 + } + } + + init?(firmwareIndex: UInt8) { + guard let match = Self.allCases.first(where: { $0.firmwareIndex == firmwareIndex }) else { + return nil + } + self = match + } + + var title: String { + switch self { + case .off: "熄灭" + case .middleLight: "中间停住" + case .singleMove: "来回流水" + case .breathing: "整条呼吸" + case .rainbowMove: "彩虹流水" + case .rainbowWave: "彩虹波浪" + case .rainbowWaveSlow: "彩虹慢波浪" + case .typingRipple: "打字涟漪" + case .comet: "彗星拖尾" + case .scanBar: "扫描条" + case .pulseCenter: "中心脉冲" + case .warningBlink: "警告闪烁" + case .successSweep: "成功扫过" + case .blueThinking: "蓝色思考" + case .lowBattery: "低电量" + case .chargingFlow: "充电流动" + case .approvalWait: "等待审批" + } + } + + var detail: String { + switch self { + case .off: "不点亮灯条。" + case .middleLight: "中间最亮,两侧渐弱,适合停住提示。" + case .singleMove: "单点来回移动,适合运行中。" + case .breathing: "整条均匀起伏,适合等待确认。" + case .rainbowMove: "彩色单点流水,更活跃。" + case .rainbowWave: "整条彩色流动,更显眼。" + case .rainbowWaveSlow: "比普通彩虹波浪更慢,适合做氛围效果。" + case .typingRipple: "从中心向两侧扩散的涟漪效果。" + case .comet: "带拖尾的单向扫过,像彗星。" + case .scanBar: "3 灯一组左右扫描。" + case .pulseCenter: "中心快速脉冲扩散。" + case .warningBlink: "橙色快速闪烁,适合警告。" + case .successSweep: "绿色从左到右逐渐点亮。" + case .blueThinking: "蓝色呼吸波浪,适合思考中。" + case .lowBattery: "红色慢闪,表示低电量。" + case .chargingFlow: "绿色填充流动,表示充电中。" + case .approvalWait: "琥珀色呼吸 + 中心闪烁,等待用户操作。" + } + } +} + +struct AhaKeyLightStateDraft: Codable, Equatable, Identifiable { + var state: IDEState + var effect: LightEffectStyle + + var id: UInt8 { state.rawValue } + + private enum CodingKeys: String, CodingKey { + case state, effect + } + + init(state: IDEState, effect: LightEffectStyle) { + self.state = state + self.effect = effect + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + effect = try container.decode(LightEffectStyle.self, forKey: .effect) + if let ideState = try? container.decode(IDEState.self, forKey: .state) { + state = ideState + } else if let legacy = try? container.decode(LightBarPreviewState.self, forKey: .state) { + state = legacy.ideState + } else { + state = .notification + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(state, forKey: .state) + try container.encode(effect, forKey: .effect) + } +} + +struct AhaKeyLightBarDraft: Codable, Equatable { + var stateMappings: [AhaKeyLightStateDraft] + var brightness: Int + + func effect(for state: IDEState) -> LightEffectStyle { + stateMappings.first(where: { $0.state == state })?.effect ?? .singleMove + } + + private enum CodingKeys: String, CodingKey { + case stateMappings, brightness + } + + init(stateMappings: [AhaKeyLightStateDraft], brightness: Int = 35) { + self.stateMappings = stateMappings + self.brightness = brightness + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawMappings = try container.decode([AhaKeyLightStateDraft].self, forKey: .stateMappings) + let rawBrightness = try container.decodeIfPresent(Int.self, forKey: .brightness) ?? 35 + brightness = max(1, min(100, rawBrightness)) + + if rawMappings.count >= IDEState.allCases.count { + stateMappings = rawMappings + } else { + let defaults = AhaKeyLightBarDraft.defaultMappings + var merged = rawMappings + for defaultMapping in defaults { + if !merged.contains(where: { $0.state == defaultMapping.state }) { + merged.append(defaultMapping) + } + } + stateMappings = merged.sorted { $0.state.rawValue < $1.state.rawValue } + } + } + + static let defaultMappings: [AhaKeyLightStateDraft] = [ + AhaKeyLightStateDraft(state: .notification, effect: .pulseCenter), + AhaKeyLightStateDraft(state: .permissionRequest, effect: .approvalWait), + AhaKeyLightStateDraft(state: .postToolUse, effect: .successSweep), + AhaKeyLightStateDraft(state: .preToolUse, effect: .singleMove), + AhaKeyLightStateDraft(state: .sessionStart, effect: .rainbowWave), + AhaKeyLightStateDraft(state: .stop, effect: .middleLight), + AhaKeyLightStateDraft(state: .taskCompleted, effect: .successSweep), + AhaKeyLightStateDraft(state: .userPromptSubmit, effect: .breathing), + AhaKeyLightStateDraft(state: .sessionEnd, effect: .off), + ] + + static func `default`(for mode: AhaKeyModeSlot) -> AhaKeyLightBarDraft { + _ = mode + return AhaKeyLightBarDraft(stateMappings: defaultMappings, brightness: 35) + } +} + +/// 固件宏步骤动作类型。 +/// 对应老 Python 客户端 `MacroAction`,固件端已实现执行逻辑。 +enum MacroAction: UInt8, Codable, CaseIterable, Identifiable { + case noOp = 0 + case downKey = 1 + case upKey = 2 + /// `param` 单位为 3ms(固件规定),最大 255 ≈ 765ms。 + case delay = 3 + case upAllKeys = 4 + + var id: UInt8 { rawValue } + + var title: String { + switch self { + case .noOp: return "空操作" + case .downKey: return "按下" + case .upKey: return "松开" + case .delay: return "延时" + case .upAllKeys: return "全部松开" + } + } + + /// 是否需要 HID 键码作为 param。 + var takesKeycodeParam: Bool { + self == .downKey || self == .upKey + } + + /// 是否把 param 当 delay 单位(×3ms)使用。 + var takesDelayParam: Bool { + self == .delay + } +} + +/// 一个宏步骤。对固件协议而言就是 (action, param) 两个字节。 +struct MacroStep: Codable, Equatable, Identifiable { + var id: UUID + var action: MacroAction + /// downKey/upKey:HID keycode;delay:×3ms;noOp / upAllKeys:忽略。 + var param: UInt8 + + init(id: UUID = UUID(), action: MacroAction, param: UInt8 = 0) { + self.id = id + self.action = action + self.param = param + } + + private enum CodingKeys: String, CodingKey { + case id + case action + case param + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.action = try c.decode(MacroAction.self, forKey: .action) + self.param = try c.decodeIfPresent(UInt8.self, forKey: .param) ?? 0 + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(action, forKey: .action) + try c.encode(param, forKey: .param) + } + + /// 渲染成 `↓` / `Enter` / `+5ms`… 这样的人类可读片段,inspector 和 summary 都用。 + var displayLabel: String { + switch action { + case .noOp: + return "no-op" + case .downKey: + return "↓\(HIDUsage.name(for: param))" + case .upKey: + return "↑\(HIDUsage.name(for: param))" + case .delay: + let ms = Int(param) * 3 + return "+\(ms)ms" + case .upAllKeys: + return "↑ALL" + } + } +} + +extension Array where Element == MacroStep { + /// 展平成 (action, param, action, param, ...) 字节流,长度 = 2 × 步数。 + /// 固件上限 98 字节 ≈ 49 步;这里不做截断,由调用方检查/提示。 + var flattenedBytes: [UInt8] { + flatMap { [$0.action.rawValue, $0.param] } + } + + /// 浓缩描述:把连续的 down/up 对合并成 `X` 方便展示。 + /// 不能完整还原所有细节,只用于 UI summary。 + var displaySummary: String { + var parts: [String] = [] + var i = 0 + while i < count { + let step = self[i] + if step.action == .downKey, + i + 1 < count, + self[i + 1].action == .upKey, + self[i + 1].param == step.param + { + parts.append(HIDUsage.name(for: step.param)) + i += 2 + } else { + parts.append(step.displayLabel) + i += 1 + } + } + return parts.joined(separator: " → ") + } +} + +struct AhaKeyKeyDraft: Codable, Equatable, Identifiable { + let role: AhaKeyKeyRole + var shortcut: ShortcutBinding + /// 非空则整个按键走固件宏下发(`cmdUpdateCustomKey / subMacro`), + /// 此时 `shortcut` 被忽略。为空则走 `subShortcut`(单键/组合键)。 + var macro: [MacroStep] + var description: String + var voicePreset: VoicePreset? + + init( + role: AhaKeyKeyRole, + shortcut: ShortcutBinding, + macro: [MacroStep] = [], + description: String, + voicePreset: VoicePreset? = nil + ) { + self.role = role + self.shortcut = shortcut + self.macro = macro + self.description = description + self.voicePreset = voicePreset + } + + private enum CodingKeys: String, CodingKey { + case role + case shortcut + case macro + case description + case voicePreset + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.role = try c.decode(AhaKeyKeyRole.self, forKey: .role) + self.shortcut = try c.decode(ShortcutBinding.self, forKey: .shortcut) + self.macro = try c.decodeIfPresent([MacroStep].self, forKey: .macro) ?? [] + self.description = try c.decode(String.self, forKey: .description) + self.voicePreset = try c.decodeIfPresent(VoicePreset.self, forKey: .voicePreset) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(role, forKey: .role) + try c.encode(shortcut, forKey: .shortcut) + if !macro.isEmpty { + try c.encode(macro, forKey: .macro) + } + try c.encode(description, forKey: .description) + try c.encodeIfPresent(voicePreset, forKey: .voicePreset) + } + + var id: Int { role.rawValue } + + var title: String { role.title } + + /// 当前按键是否以"宏"形式下发。 + var usesMacro: Bool { !macro.isEmpty } + + var displaySummary: String { + if role == .voice, let voicePreset { + return voicePreset.title + } + if usesMacro { + return "宏:\(macro.displaySummary)" + } + return shortcut.displayLabel + } +} + +struct AhaKeyOLEDDraft: Codable, Equatable { + var localAssetPath: String? + var statusLine: String + var framesPerSecond: Int + + private enum CodingKeys: String, CodingKey { + case localAssetPath + case statusLine + case framesPerSecond + } + + init(localAssetPath: String?, statusLine: String, framesPerSecond: Int = 12) { + self.localAssetPath = localAssetPath + self.statusLine = statusLine + self.framesPerSecond = framesPerSecond + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + localAssetPath = try container.decodeIfPresent(String.self, forKey: .localAssetPath) + statusLine = try container.decode(String.self, forKey: .statusLine) + let storedFPS = try container.decodeIfPresent(Int.self, forKey: .framesPerSecond) ?? 12 + framesPerSecond = min(30, max(1, storedFPS)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(localAssetPath, forKey: .localAssetPath) + try container.encode(statusLine, forKey: .statusLine) + try container.encode(framesPerSecond, forKey: .framesPerSecond) + } + + static func `default`(for mode: AhaKeyModeSlot) -> AhaKeyOLEDDraft { + let statusLine: String + switch mode { + case .mode0: + statusLine = "Claude Code · 终端权限菜单 Y/N。" + case .mode1: + statusLine = "Cursor · ↵ 接受改动 / ⌫ 拒绝改动。" + case .mode2: + statusLine = "Codex · 审批 ↵ / Esc。" + case .mode3: + statusLine = "自定义模式。" + } + return AhaKeyOLEDDraft( + localAssetPath: DefaultOLEDAssets.bundledAssetPath(for: mode), + statusLine: statusLine, + framesPerSecond: 12 + ) + } +} + +struct AhaKeyModeDraft: Codable, Equatable, Identifiable { + let mode: AhaKeyModeSlot + var keys: [AhaKeyKeyDraft] + var oled: AhaKeyOLEDDraft + var lightBar: AhaKeyLightBarDraft + + var id: Int { mode.rawValue } + + init(mode: AhaKeyModeSlot, keys: [AhaKeyKeyDraft], oled: AhaKeyOLEDDraft, lightBar: AhaKeyLightBarDraft) { + self.mode = mode + self.keys = keys + self.oled = oled + self.lightBar = lightBar + } + + private enum CodingKeys: String, CodingKey { + case mode + case keys + case oled + case lightBar + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + mode = try container.decode(AhaKeyModeSlot.self, forKey: .mode) + keys = try container.decode([AhaKeyKeyDraft].self, forKey: .keys) + oled = try container.decodeIfPresent(AhaKeyOLEDDraft.self, forKey: .oled) ?? .default(for: mode) + lightBar = try container.decodeIfPresent(AhaKeyLightBarDraft.self, forKey: .lightBar) ?? .default(for: mode) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(mode, forKey: .mode) + try container.encode(keys, forKey: .keys) + try container.encode(oled, forKey: .oled) + try container.encode(lightBar, forKey: .lightBar) + } + + func key(for role: AhaKeyKeyRole) -> AhaKeyKeyDraft { + keys.first { $0.role == role } ?? AhaKeyModeDraft.default(for: mode).keys[role.rawValue] + } + + mutating func updateKey(_ updated: AhaKeyKeyDraft) { + if let index = keys.firstIndex(where: { $0.role == updated.role }) { + keys[index] = updated + } + } + + /// Claude CLI 新版菜单 "1. Yes / 2. Yes, allow all / 3. No": + /// 光标默认在 Yes 上,所以 No 需要先 ↓ 两次再回车。 + /// 这是一个固件原生宏(action/param pairs),由键盘自己串行吐三个 HID 事件。 + static let claudeNoMacroSteps: [MacroStep] = [ + .init(action: .downKey, param: HIDUsage.downArrow), + .init(action: .upKey, param: HIDUsage.downArrow), + .init(action: .delay, param: 5), + .init(action: .downKey, param: HIDUsage.downArrow), + .init(action: .upKey, param: HIDUsage.downArrow), + .init(action: .delay, param: 5), + .init(action: .downKey, param: HIDUsage.enter), + .init(action: .upKey, param: HIDUsage.enter), + ] + + static func `default`(for mode: AhaKeyModeSlot) -> AhaKeyModeDraft { + let voicePreset: VoicePreset = .macOSNative + let approveShortcut: ShortcutBinding + let rejectShortcut: ShortcutBinding + var rejectMacro: [MacroStep] = [] + let approveDescription: String + let rejectDescription: String + + switch mode { + case .mode0: + // Yes 按 Enter;No 用固件原生宏 ↓↓⏎。 + approveShortcut = ShortcutBinding(keyCode: HIDUsage.enter) + rejectShortcut = ShortcutBinding() + rejectMacro = claudeNoMacroSteps + approveDescription = "Yes" + rejectDescription = "No" + case .mode1: + // 与固件 `defult_key_0_1` 等裸 HID 风格一致:单键 Enter / Backspace。若要用 Composer 默认 ⌘ 组合,由用户在编辑器中勾选 ⌘ 或改 Cursor 快捷键。 + approveShortcut = ShortcutBinding(keyCode: HIDUsage.enter) + rejectShortcut = ShortcutBinding(keyCode: HIDUsage.backspace) + approveDescription = "Accept" + rejectDescription = "Reject" + case .mode2: + approveShortcut = ShortcutBinding(keyCode: HIDUsage.enter) + rejectShortcut = ShortcutBinding(keyCode: HIDUsage.escape) + approveDescription = "Accept" + rejectDescription = "Reject" + case .mode3: + approveShortcut = ShortcutBinding(keyCode: HIDUsage.enter) + rejectShortcut = ShortcutBinding(keyCode: HIDUsage.escape) + approveDescription = "Accept" + rejectDescription = "Reject" + } + + return AhaKeyModeDraft( + mode: mode, + keys: [ + AhaKeyKeyDraft( + role: .voice, + shortcut: voicePreset.defaultBinding, + description: AhaKeyKeyRole.voice.defaultDescription, + voicePreset: voicePreset + ), + AhaKeyKeyDraft( + role: .approve, + shortcut: approveShortcut, + description: approveDescription, + voicePreset: nil + ), + AhaKeyKeyDraft( + role: .reject, + shortcut: rejectShortcut, + macro: rejectMacro, + description: rejectDescription, + voicePreset: nil + ), + AhaKeyKeyDraft( + role: .submit, + shortcut: ShortcutBinding(keyCode: HIDUsage.backspace), + description: AhaKeyKeyRole.submit.defaultDescription, + voicePreset: nil + ), + ], + oled: .default(for: mode), + lightBar: .default(for: mode) + ) + } +} + +struct AhaKeyStudioDraft: Codable, Equatable { + var modes: [AhaKeyModeDraft] + + static let `default` = AhaKeyStudioDraft( + modes: AhaKeyModeSlot.allCases.map { AhaKeyModeDraft.default(for: $0) } + ) + + func draft(for mode: AhaKeyModeSlot) -> AhaKeyModeDraft { + modes.first(where: { $0.mode == mode }) ?? AhaKeyModeDraft.default(for: mode) + } + + mutating func updateMode(_ updated: AhaKeyModeDraft) { + if let index = modes.firstIndex(where: { $0.mode == updated.mode }) { + modes[index] = updated + } + } +} + +enum AhaKeyStudioStore { + private static let key = "ahakey.studio.draft.v1" + + static func load() -> AhaKeyStudioDraft? { + guard let data = UserDefaults.standard.data(forKey: key), + var draft = try? JSONDecoder().decode(AhaKeyStudioDraft.self, from: data) else { + return nil + } + let existingSlots = Set(draft.modes.map(\.mode)) + for slot in AhaKeyModeSlot.allCases where !existingSlots.contains(slot) { + draft.modes.append(AhaKeyModeDraft.default(for: slot)) + } + return migratedDraft(from: draft) + } + + static func save(_ draft: AhaKeyStudioDraft) { + guard let data = try? JSONEncoder().encode(draft) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + private static func migratedDraft(from draft: AhaKeyStudioDraft) -> AhaKeyStudioDraft { + var next = draft + var mode0 = next.draft(for: .mode0) + let legacyDescriptions: [AhaKeyKeyRole: String] = [ + .voice: "语音", + .approve: "批准", + .reject: "拒绝", + .submit: "回车", + ] + + for role in AhaKeyKeyRole.allCases { + var key = mode0.key(for: role) + if key.description.isEmpty || key.description == legacyDescriptions[role] { + key.description = role.defaultDescription + } + if role == .voice, + key.voicePreset == .macOSNative, + key.shortcut.keyCode == HIDUsage.f17, + key.shortcut.modifiers.isEmpty + { + key.shortcut = ShortcutBinding(keyCode: HIDUsage.f18) + } + mode0.updateKey(key) + } + next.updateMode(mode0) + + // claudeCode / kimiCode 已合并到 macOSNative,迁移所有 mode 里的旧 preset。 + for modeSlot in AhaKeyModeSlot.allCases { + var modeDraft = next.draft(for: modeSlot) + var voiceKey = modeDraft.key(for: .voice) + if voiceKey.voicePreset == .claudeCode || voiceKey.voicePreset == .kimiCode { + voiceKey.voicePreset = .macOSNative + modeDraft.updateKey(voiceKey) + next.updateMode(modeDraft) + } + } + + // 旧 Mode 0 = Cursor / 旧 Mode 1 = Claude 的用户,自动对调成新默认布局。 + // 仅当两个 mode 的 approve/reject 都完全等于旧默认时触发,保护手动改过的配置。 + let cursorApproveBinding = ShortcutBinding(modifiers: [.command], keyCode: HIDUsage.enter) + let cursorRejectBinding = ShortcutBinding(modifiers: [.command], keyCode: HIDUsage.backspace) + let claudeApproveBinding = ShortcutBinding(keyCode: 0x1C) + let claudeRejectBinding = ShortcutBinding(keyCode: 0x11) + + let legacyMode0 = next.draft(for: .mode0) + let legacyMode1 = next.draft(for: .mode1) + let mode0LooksLikeCursor = + legacyMode0.key(for: .approve).shortcut == cursorApproveBinding + && legacyMode0.key(for: .approve).description == "Accept" + && legacyMode0.key(for: .reject).shortcut == cursorRejectBinding + && legacyMode0.key(for: .reject).description == "Reject" + let mode1LooksLikeClaude = + legacyMode1.key(for: .approve).shortcut == claudeApproveBinding + && legacyMode1.key(for: .approve).description == "Yes" + && legacyMode1.key(for: .reject).shortcut == claudeRejectBinding + && legacyMode1.key(for: .reject).description == "No" + + if mode0LooksLikeCursor, mode1LooksLikeClaude { + let m1Def = AhaKeyModeDraft.default(for: .mode1) + var newMode0 = legacyMode0 + var newMode1 = legacyMode1 + var approve0 = newMode0.key(for: .approve) + approve0.shortcut = claudeApproveBinding + approve0.description = "Yes" + newMode0.updateKey(approve0) + var reject0 = newMode0.key(for: .reject) + reject0.shortcut = claudeRejectBinding + reject0.description = "No" + newMode0.updateKey(reject0) + var approve1 = newMode1.key(for: .approve) + approve1.shortcut = m1Def.key(for: .approve).shortcut + approve1.description = "Accept" + newMode1.updateKey(approve1) + var reject1 = newMode1.key(for: .reject) + reject1.shortcut = m1Def.key(for: .reject).shortcut + reject1.macro = m1Def.key(for: .reject).macro + reject1.description = "Reject" + newMode1.updateKey(reject1) + next.updateMode(newMode0) + next.updateMode(newMode1) + } + + let legacyOLEDStatusLines: Set = [ + "当前仅支持动图", + "切换模式时会先显示按键描述,再回到 Mode 1 默认动图。", + "当前模式还未上传动图,后续可替换成你的自定义 GIF。", + "Cursor · ⌘↵ 接受改动 / ⌘⌫ 拒绝改动。", + "Claude Code · 终端权限菜单 Y/N。", + "Codex · 审批 ↵ / Esc。", + ] + let legacyApproveBinding = ShortcutBinding(keyCode: HIDUsage.enter) + let legacyRejectBinding = ShortcutBinding(keyCode: HIDUsage.escape) + let legacyApproveDescriptions: Set = ["Accept", "批准", ""] + let legacyRejectDescriptions: Set = ["Reject", "拒绝", ""] + + for mode in AhaKeyModeSlot.allCases { + var modeDraft = next.draft(for: mode) + let target = AhaKeyModeDraft.default(for: mode) + + if legacyOLEDStatusLines.contains(modeDraft.oled.statusLine) { + modeDraft.oled.statusLine = AhaKeyOLEDDraft.default(for: mode).statusLine + } + + // LCD 素材路径自愈:用户没选过自定义 GIF(为 nil)或引用的是旧 bundle 路径时, + // 刷成当前构建下内置 GIF 的绝对路径;用户自选的外部路径原样保留。 + if let bundled = DefaultOLEDAssets.bundledAssetPath(for: mode) { + if modeDraft.oled.localAssetPath == nil + || (modeDraft.oled.localAssetPath.map(DefaultOLEDAssets.isBundledPath) ?? false) + { + modeDraft.oled.localAssetPath = bundled + } + } else if let existing = modeDraft.oled.localAssetPath, + DefaultOLEDAssets.isBundledPath(existing) { + modeDraft.oled.localAssetPath = nil + } + + var voiceKey = modeDraft.key(for: .voice) + if voiceKey.voicePreset == .wechat || voiceKey.voicePreset == .doubao { + voiceKey.voicePreset = .typeless + modeDraft.updateKey(voiceKey) + } + voiceKey = modeDraft.key(for: .voice) + if voiceKey.voicePreset == .macOSNative, + voiceKey.shortcut.keyCode == HIDUsage.f17, + voiceKey.shortcut.modifiers.isEmpty + { + voiceKey.shortcut = ShortcutBinding(keyCode: HIDUsage.f18) + modeDraft.updateKey(voiceKey) + } + if (voiceKey.voicePreset == .typeless || voiceKey.voicePreset == .wechat), + voiceKey.shortcut.keyCode == HIDUsage.f18, + voiceKey.shortcut.modifiers.isEmpty + { + voiceKey.shortcut = ShortcutBinding(keyCode: HIDUsage.f19) + modeDraft.updateKey(voiceKey) + } + + var submitKey = modeDraft.key(for: .submit) + if submitKey.macro.isEmpty, + submitKey.shortcut == ShortcutBinding(keyCode: HIDUsage.enter), + (submitKey.description == "Enter" || submitKey.description == "回车" || submitKey.description.isEmpty) + { + let targetSubmit = target.key(for: .submit) + submitKey.shortcut = targetSubmit.shortcut + submitKey.description = targetSubmit.description + modeDraft.updateKey(submitKey) + } + + submitKey = modeDraft.key(for: .submit) + if submitKey.shortcut == ShortcutBinding(keyCode: HIDUsage.backspace), + submitKey.macro.isEmpty, + ["", "backspace", "Back space", "Back Space", "删除", "删除键"].contains(submitKey.description) + { + submitKey.description = AhaKeyKeyRole.submit.defaultDescription + modeDraft.updateKey(submitKey) + } + + if modeDraft.lightBar.brightness == 50 { + modeDraft.lightBar.brightness = 35 + } + + // 旧版「全模式通用」模板曾用 主键↵/Esc + Accept/Reject 文案。Codex/其它 mode 的升级仍需要; + // Mode 1(Cursor)允许用户**有意**改组合键,若继续套用下面规则会在每次启动时改回出厂 ↵/⌫,表现为改键不保存。 + if mode != .mode1 { + var approveKey = modeDraft.key(for: .approve) + if approveKey.shortcut == legacyApproveBinding, + legacyApproveDescriptions.contains(approveKey.description) + { + let targetApprove = target.key(for: .approve) + approveKey.shortcut = targetApprove.shortcut + approveKey.description = targetApprove.description + modeDraft.updateKey(approveKey) + } + + var rejectKey = modeDraft.key(for: .reject) + if rejectKey.shortcut == legacyRejectBinding, + legacyRejectDescriptions.contains(rejectKey.description) + { + let targetReject = target.key(for: .reject) + rejectKey.shortcut = targetReject.shortcut + rejectKey.description = targetReject.description + // 必须与当前 mode 的默认一致:Mode 0 的 No 依赖固件宏 ↓↓⏎,不能只拷 shortcut(否则宏为空,UI 会退化成单键展示)。 + rejectKey.macro = targetReject.macro + modeDraft.updateKey(rejectKey) + } + } + + // Mode 0 (Claude) 专门的升级路径: + // 老草稿 1:reject = "N" (0x11) → 升级成固件原生宏 ↓↓⏎ + // 老草稿 2:reject = "F20" (0x6F) 代理键 → 升级成固件原生宏 ↓↓⏎ + // 同时把 approve 从 0x1C (Y) 升级成 Enter。 + // 升级前提:用户没手动改过描述(为空或仍是默认 "Yes" / "No")。 + if mode == .mode0 { + var approve0 = modeDraft.key(for: .approve) + if approve0.shortcut == ShortcutBinding(keyCode: 0x1C), + approve0.description == "Yes" || approve0.description.isEmpty, + approve0.macro.isEmpty + { + approve0.shortcut = ShortcutBinding(keyCode: HIDUsage.enter) + approve0.description = "Yes" + modeDraft.updateKey(approve0) + } + var reject0 = modeDraft.key(for: .reject) + let wasLegacyN = reject0.shortcut == ShortcutBinding(keyCode: 0x11) + let wasF20Proxy = reject0.shortcut == ShortcutBinding(keyCode: HIDUsage.f20) + if (wasLegacyN || wasF20Proxy), + reject0.description == "No" || reject0.description.isEmpty, + reject0.macro.isEmpty + { + reject0.shortcut = ShortcutBinding() + reject0.macro = AhaKeyModeDraft.claudeNoMacroSteps + reject0.description = "No" + modeDraft.updateKey(reject0) + } + + // 自愈:旧版迁移从 Esc 切到 No 时曾漏拷 macro;或用户在 Inspector 里把「宏」切到「单键/组合键」会清空宏。No 的应有配置是空 shortcut + ↓↓⏎。 + var rejectNo = modeDraft.key(for: .reject) + if rejectNo.description == "No", + rejectNo.macro.isEmpty, + rejectNo.shortcut == ShortcutBinding() + || rejectNo.shortcut == ShortcutBinding(keyCode: HIDUsage.enter) + { + rejectNo.shortcut = ShortcutBinding() + rejectNo.macro = AhaKeyModeDraft.claudeNoMacroSteps + modeDraft.updateKey(rejectNo) + } + } + + // Mode 1(Cursor)取消键为「单键 Backspace」HID 快捷键。若草稿里仍残留非空 macro(例如从其它 mode 误带、或 UI 曾切宏后未清干净), + // `usesMacro` 会为 true,全量同步会走 0x74 覆盖 0x73,设备表现与界面上的 ⌫ 不一致(ble-comm 里可见「取消键 宏: …」)。 + if mode == .mode1 { + let oldDefaultApprove = ShortcutBinding(modifiers: [.command], keyCode: HIDUsage.enter) + let oldDefaultReject = ShortcutBinding(modifiers: [.command], keyCode: HIDUsage.backspace) + var approve1 = modeDraft.key(for: .approve) + var reject1 = modeDraft.key(for: .reject) + // 一版曾出厂为 ⌘↵/⌘⌫:与当前出厂一致(仍为默认文案且无宏)时升到裸键 ↵/⌫。 + if approve1.shortcut == oldDefaultApprove, + reject1.shortcut == oldDefaultReject, + approve1.description == "Accept", + reject1.description == "Reject", + approve1.macro.isEmpty, + reject1.macro.isEmpty + { + let t = AhaKeyModeDraft.default(for: .mode1) + approve1.shortcut = t.key(for: .approve).shortcut + modeDraft.updateKey(approve1) + reject1.shortcut = t.key(for: .reject).shortcut + reject1.macro = t.key(for: .reject).macro + modeDraft.updateKey(reject1) + } + + reject1 = modeDraft.key(for: .reject) + let defaultCursorReject = AhaKeyModeDraft.default(for: .mode1).key(for: .reject) + if !reject1.macro.isEmpty, + reject1.shortcut == defaultCursorReject.shortcut + { + reject1.macro = [] + modeDraft.updateKey(reject1) + } + } + + next.updateMode(modeDraft) + } + + return next + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift new file mode 100644 index 00000000..bb4b898f --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift @@ -0,0 +1,1773 @@ +import Foundation +import os.log + +private let log = Logger(subsystem: "lab.jawa.ahakeyconfig", category: "AgentManager") + +// MARK: - 蓝牙占用方(AhaKey Studio 与 Agent 是两套独立进程,同一时刻只应有一个 GATT 连接键盘) + +/// 由谁持有与键盘的 BLE 连接。 +/// - `ahaKeyStudio`:主 App 连接,用于改键、LCD、本机 LED 测试等。 +/// - `agentDaemon`:仅运行 `ahakeyconfig-agent`(Hook → Unix socket → 写 0x90 状态、读拨杆),由 LaunchAgent 拉起。 +enum BluetoothConnectionOwner: String, CaseIterable, Identifiable { + case ahaKeyStudio + case agentDaemon + + var id: String { rawValue } + + var title: String { + switch self { + case .ahaKeyStudio: return "AhaKey Studio" + case .agentDaemon: return "ahakeyconfig-agent" + } + } + + var shortDetail: String { + switch self { + case .ahaKeyStudio: return "本 App 连接蓝牙,用于配置与同步。Agent 的 LaunchJob 在持有方为 App 时不会加载,避免抢连接。" + case .agentDaemon: return "仅 Agent 连接蓝牙。Claude/Cursor/Codex/Kimi Code CLI Hook 才能驱动灯条与拨杆查询;本 App 里无法对键盘发 BLE 命令。" + } + } +} + +/// 管理 ahakeyconfig-agent 守护进程的安装、启停、状态查询 +@MainActor +final class AgentManager: ObservableObject { + static let shared = AgentManager() + + private static let bluetoothOwnerKey = "lab.jawa.ahakeyconfig.bluetoothConnectionOwner" + private static var didApplyLaunchBluetoothPreference = false + + @Published private(set) var isInstalled = false + @Published private(set) var isRunning = false + @Published private(set) var isAgentBLEConnected = false // agent 的 BLE 是否真正连上键盘 + @Published private(set) var hooksInstalled = false // Claude / Cursor / Codex / Kimi hooks 是否装了任何一个 + @Published private(set) var claudeHooksInstalled = false + @Published private(set) var cursorHooksInstalled = false + @Published private(set) var codexHooksInstalled = false + @Published private(set) var kimiHooksInstalled = false + + /// 用户选择的蓝牙占用方(存 UserDefaults,启动时应用一次) + @Published var bluetoothConnectionOwner: BluetoothConnectionOwner = .agentDaemon + + /// 安装 / 启停 Agent、写 Hooks 等操作的结果说明;关闭弹窗后由 UI 置 `nil`。 + @Published var agentUserAlert: String? + + /// 正在执行安装或 launchctl 启停,用于界面显示进度,避免「点了没反应」。 + @Published private(set) var isAgentOperationInProgress = false + + private let label = "lab.jawa.ahakeyconfig.agent" + private let socketPath = "/tmp/ahakey.sock" + + private var launchAgentsDirectoryURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents", isDirectory: true) + } + + private var plistPath: String { + launchAgentsDirectoryURL.appendingPathComponent("\(label).plist").path + } + + /// `~/Library/LaunchAgents` 在全新系统用户下可能尚不存在,必须先创建再写 plist,否则会报「folder doesn't exist」类错误。 + private func ensureLaunchAgentsDirectory() throws { + try FileManager.default.createDirectory(at: launchAgentsDirectoryURL, withIntermediateDirectories: true, attributes: nil) + } + + private var agentBinaryPath: String { + // agent 安装到 app bundle 内部(发版须将 ahakeyconfig-agent 与主程序一并复制到 Contents/MacOS/) + let appPath = Bundle.main.bundlePath + return "\(appPath)/Contents/MacOS/ahakeyconfig-agent" + } + + /// 供界面判断:包内是否带有 agent 可执行文件(发版缺拷贝时 LaunchAgent 无法真正运行)。 + var isAgentBinaryPresentInBundle: Bool { + FileManager.default.isExecutableFile(atPath: agentBinaryPath) + } + + /// 兼容性:老版本通过 shell 脚本转发;现在直接调用 agent 二进制。保留路径用于卸载时清理。 + private var legacyHookScriptPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".claude/hooks/ahakey-state.sh").path + } + + private var claudeSettingsPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".claude/settings.json").path + } + + private var cursorHooksPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".cursor/hooks.json").path + } + + private var codexConfigPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/config.toml").path + } + + private var codexAppCliPath: String { + "/Applications/Codex.app/Contents/Resources/codex" + } + + private var kimiConfigPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".kimi/config.toml").path + } + + private var kimiCliFallbackRoot: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share/uv/tools/kimi-cli").path + } + + private var localBinDirectoryPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/bin", isDirectory: true).path + } + + private var localCodexCliPath: String { + (localBinDirectoryPath as NSString).appendingPathComponent("codex") + } + + private var zshrcPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".zshrc").path + } + + /// `~/.cursor/cli-config.json`:Cursor **CLI** 的 `permissions`(`Shell(...)` 等)与 `approvalMode`。 + private var cursorCliConfigPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor/cli-config.json").path + } + + /// `~/.cursor/permissions.json`:IDE 内 **Agent 终端 TUI** 的 `terminalAllowlist`(与 cli-config 独立,见官方文档)。 + private var cursorPermissionsJsonPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor/permissions.json").path + } + + init() { + if let raw = UserDefaults.standard.string(forKey: Self.bluetoothOwnerKey), + let stored = BluetoothConnectionOwner(rawValue: raw) { + bluetoothConnectionOwner = stored + } else { + bluetoothConnectionOwner = .agentDaemon + UserDefaults.standard.set(BluetoothConnectionOwner.agentDaemon.rawValue, forKey: Self.bluetoothOwnerKey) + } + refresh() + } + + // MARK: - 状态刷新 + + func refresh() { + isInstalled = FileManager.default.fileExists(atPath: plistPath) + isRunning = checkRunning() + claudeHooksInstalled = detectClaudeHooksInstalled() + cursorHooksInstalled = detectCursorHooksInstalled() + codexHooksInstalled = detectCodexHooksInstalled() + kimiHooksInstalled = detectKimiHooksInstalled() + hooksInstalled = claudeHooksInstalled || cursorHooksInstalled || codexHooksInstalled || kimiHooksInstalled + if isRunning { + let socketPath = socketPath + DispatchQueue.global(qos: .utility).async { [weak self, socketPath] in + let bleConnected = Self.querySocketBLEConnected(socketPath: socketPath) + DispatchQueue.main.async { self?.isAgentBLEConnected = bleConnected } + } + } else { + isAgentBLEConnected = false + } + } + + /// 通知 agent 设置/清除虚拟拨杆覆盖。fire-and-forget;agent 会: + /// 1) 落进 UserDefaults 持久化 + /// 2) 写入共享文件让主 App 立即看到 + /// 3) 不再发送旧 0x91;最新固件 0x91 用于灯效预览 + /// value=nil 表示清除覆盖(回到读真实 GPIO 值)。 + func sendSwitchOverride(_ value: UInt8?) { + DispatchQueue.global(qos: .userInitiated).async { [socketPath] in + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return } + defer { close(fd) } + var tv = timeval(tv_sec: 2, 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)) + } + } + guard ok == 0 else { return } + let valuePart: String = value.map { "\($0)" } ?? "null" + let payload = "{\"cmd\":\"set_switch_override\",\"value\":\(valuePart)}\n" + guard let data = payload.data(using: .utf8) else { return } + _ = data.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return -1 } + return write(fd, base, ptr.count) + } + var buf = [UInt8](repeating: 0, count: 256) + _ = read(fd, &buf, buf.count) // 等回包再关 fd,避免 agent 还没处理就被 reset + } + } + + /// 向 agent socket 发 status 命令,switchState 非 null 即代表 BLE 已连上键盘。 + /// 同步执行,需在后台线程调用。 + nonisolated private static func querySocketBLEConnected(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: 2, 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)) + } + } + guard ok == 0 else { return false } + + guard let payload = "{\"cmd\":\"status\"}\n".data(using: .utf8) else { return false } + let wrote = payload.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return -1 } + return write(fd, base, ptr.count) + } + guard wrote > 0 else { return false } + + var buf = [UInt8](repeating: 0, count: 256) + let n = read(fd, &buf, buf.count) + guard n > 0 else { return false } + + guard let json = try? JSONSerialization.jsonObject(with: Data(buf[0.. Bool { + guard let settings = loadClaudeSettings(), + let hooks = settings["hooks"] as? [String: Any] else { return false } + for (_, value) in hooks { + guard let eventHooks = value as? [[String: Any]] else { continue } + for entry in eventHooks { + let cmds = entry["hooks"] as? [[String: Any]] ?? [] + if cmds.contains(where: { isAhakeyHookCommand(($0["command"] as? String) ?? "") }) { + return true + } + } + } + return false + } + + private func detectCursorHooksInstalled() -> Bool { + guard let settings = loadCursorSettings(), + let hooks = settings["hooks"] as? [String: Any] else { return false } + for (_, value) in hooks { + guard let entries = value as? [[String: Any]] else { continue } + if entries.contains(where: { isAhakeyHookCommand(($0["command"] as? String) ?? "") }) { + return true + } + } + return false + } + + private func detectCodexHooksInstalled() -> Bool { + guard let text = try? String(contentsOfFile: codexConfigPath, encoding: .utf8) else { + return false + } + // AhaKey 写入的 BEGIN/END 块即可判定(不依赖文件中是否仍能匹配到 ahakeyconfig-agent 字面量,避免因路径别名/重装 App 路径变化导致误判未装) + if text.contains(codexHookBlockStart), text.contains(codexHookBlockEnd) { + return true + } + return isAhakeyHookCommand(text) + && (text.contains("hook Codex") || text.contains("CodexPermissionRequest")) + } + + private func detectKimiHooksInstalled() -> Bool { + guard let text = try? String(contentsOfFile: kimiConfigPath, encoding: .utf8) else { + return false + } + return text.contains(kimiHookBlockStart) + && text.contains(kimiHookBlockEnd) + && isAhakeyHookCommand(text) + } + + private func isAhakeyHookCommand(_ command: String) -> Bool { + command.contains("ahakeyconfig-agent") || command.contains("ahakey-state") + } + + private func checkRunning() -> Bool { + // 检查 socket 是否存在(agent 运行时会创建) + var statBuf = stat() + return stat(socketPath, &statBuf) == 0 && (statBuf.st_mode & S_IFSOCK) != 0 + } + + // MARK: - 安装/卸载 LaunchAgent + + private func launchAgentPlist() -> String { + """ + + + + + Label + \(label) + ProgramArguments + + \(agentBinaryPath) + --socket + \(socketPath) + + RunAtLoad + + KeepAlive + + StandardOutPath + \(logFilePath) + StandardErrorPath + \(logFilePath) + + + """ + } + + @discardableResult + private func writeLaunchAgentPlist() -> Bool { + do { + try ensureLaunchAgentsDirectory() + try launchAgentPlist().write(toFile: plistPath, atomically: true, encoding: .utf8) + log.info("LaunchAgent 已安装: \(self.plistPath)") + return true + } catch { + log.error("LaunchAgent 安装失败: \(error)") + agentUserAlert = "无法写入 LaunchAgent 配置文件:\(error.localizedDescription)\n\n将写入:\(plistPath)\n已尝试创建目录:\(launchAgentsDirectoryURL.path)\n若仍失败,请检查对「~/Library」是否有写权限,或本机管理策略是否禁止用户 LaunchAgents。" + return false + } + } + + private func installedAgentBinaryPath() -> String? { + guard let plist = NSDictionary(contentsOfFile: plistPath), + let args = plist["ProgramArguments"] as? [String], + let first = args.first else { return nil } + return first + } + + private func launchAgentNeedsRewrite() -> Bool { + installedAgentBinaryPath() != agentBinaryPath + } + + func install() { + agentUserAlert = nil + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + + guard isAgentBinaryPresentInBundle else { + agentUserAlert = "应用包内没有可执行的 ahakeyconfig-agent(路径:…/Contents/MacOS/ahakeyconfig-agent)。请确认发版脚本已把该二进制一并打进 .app;仅有主程序时无法安装守护进程。" + return + } + + // 1. 先卸载旧 job,再写入 plist。否则同 Label 已加载时 launchd 可能继续持有旧 ProgramArguments。 + unloadAgentLaunchJobRemovingSocket() + guard writeLaunchAgentPlist() else { return } + + // 2. 仅当用户希望 Agent 持有蓝牙时才 load(否则只写入 plist,避免装完立刻抢 GATT) + var loadFailed = false + if bluetoothConnectionOwner == .agentDaemon { + let load = runLaunchctlDetailed(["load", plistPath]) + if !load.ok && !isBenignLaunchctlLoadMessage(load.mergedOutput) { + loadFailed = true + log.error("launchctl load failed: \(load.mergedOutput)") + let out = load.mergedOutput.isEmpty ? "(无输出,退出非 0)" : load.mergedOutput + agentUserAlert = "LaunchAgent 的 plist 已保存,但 launchctl load 失败,守护进程未载入。\n\nlaunchctl 输出:\n\(out)\n\n常见原因:同一 Label 已存在、plist 无效、对 ~/Library/LaunchAgents 无写权限。可先点「卸载」再装,或在「控制台」搜索 \(label)。" + } + } + + // 3. 安装 Claude / Cursor / Codex / Kimi hooks(直接指向 agent 二进制 hook 子命令) + let claudeLine = installClaudeHooks() + let cursorLine = installCursorHooks() + let codexLine = installCodexHooks() + let kimiLine = installKimiHooks() + + refresh() + + var lines: [String] = [] + if bluetoothConnectionOwner == .agentDaemon, !loadFailed { + lines.append("launchctl load 已执行。若数秒后未显示「运行中」,请点「查看日志」。") + } + if !claudeLine.isEmpty { lines.append(claudeLine) } + if !cursorLine.isEmpty { lines.append(cursorLine) } + if !codexLine.isEmpty { lines.append(codexLine) } + if !kimiLine.isEmpty { lines.append(kimiLine) } + let tail = lines.joined(separator: "\n\n") + if let err = agentUserAlert { + agentUserAlert = err + (tail.isEmpty ? "" : "\n\n——\n\n" + tail) + } else { + agentUserAlert = tail.isEmpty ? "安装完成。" : tail + } + } + + func uninstall(bleManager: AhaKeyBLEManager? = nil) { + // 1. 卸载 LaunchAgent + _ = runLaunchctlQuiet(["unload", plistPath]) + try? FileManager.default.removeItem(atPath: plistPath) + + // 2. 清理老版本 shell hook 脚本(如果存在) + try? FileManager.default.removeItem(atPath: legacyHookScriptPath) + + // 3. 移除 Claude / Cursor / Codex / Kimi hooks 中的 ahakey 条目(同时覆盖老 shell 脚本与新二进制命令) + removeClaudeHooks() + removeCursorHooks() + removeCodexHooks() + _ = removeKimiHooks() + + // 4. 清理 socket + if FileManager.default.fileExists(atPath: socketPath) { + try? FileManager.default.removeItem(atPath: socketPath) + } + + bluetoothConnectionOwner = .ahaKeyStudio + UserDefaults.standard.set(BluetoothConnectionOwner.ahaKeyStudio.rawValue, forKey: Self.bluetoothOwnerKey) + bleManager?.setSuppressedForAgentOwningKeyboard(false) + + log.info("已卸载 agent + hooks") + refresh() + } + + /// 启动 Agent 守护进程(先确保 Job 已 load,再 start;适合「已安装但未运行」)。 + func start() { + guard isInstalled else { + agentUserAlert = "尚未安装 LaunchAgent。请先点「安装并启用」。" + return + } + if launchAgentNeedsRewrite() { + unloadAgentLaunchJobRemovingSocket() + guard writeLaunchAgentPlist() else { return } + } + isAgentOperationInProgress = true + let loadRes = runLaunchctlDetailed(["load", plistPath]) + let startRes = runLaunchctlDetailed(["start", label]) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.25) { [weak self] in + guard let self else { return } + self.isAgentOperationInProgress = false + self.refresh() + if !self.isRunning { + var m = "已执行 launchctl load / start,但尚未检测到 Agent 在运行(未出现 /tmp/ahakey.sock)。\n\n" + if !loadRes.ok && !isBenignLaunchctlLoadMessage(loadRes.mergedOutput) { + m += "load:\n\(loadRes.mergedOutput.isEmpty ? "(无输出)" : loadRes.mergedOutput)\n\n" + } + if !startRes.ok { + m += "start:\n\(startRes.mergedOutput.isEmpty ? "(无输出)" : startRes.mergedOutput)\n\n" + } + m += "请点「查看日志」检查 \(self.logFilePath);并确认系统「隐私与安全性」中已允许本应用使用蓝牙;若通过 LaunchAgent 拉起 agent 子进程,也需为同一签名的二进制授权。" + self.agentUserAlert = m + } else if (!loadRes.ok && !isBenignLaunchctlLoadMessage(loadRes.mergedOutput)) || !startRes.ok { + self.agentUserAlert = "Agent 已运行。附注:launchctl 输出 — load:\(loadRes.mergedOutput) start:\(startRes.mergedOutput)" + } + } + } + + /// 停止 Agent 并 **unload** 出 launchd,否则 `KeepAlive` 会让进程立刻重启并继续占蓝牙。 + func stop() { + unloadAgentLaunchJobRemovingSocket() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.refresh() + } + } + + // MARK: - Log + + var logFilePath: String { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics") + try? FileManager.default.createDirectory(atPath: dir.path, withIntermediateDirectories: true) + return dir.appendingPathComponent("agent.log").path + } + + /// Hook 子进程在每次 Claude `PermissionRequest` 时追加 JSON 行,与 `HookClient` 中 diagnostics 路径一致。 + var permissionRequestLogPath: String { + URL(fileURLWithPath: logFilePath).deletingLastPathComponent() + .appendingPathComponent("permission-request.log") + .path + } + + /// Codex 所有 AhaKey hook 触发记录:状态 hook 与 PermissionRequest 都会追加 JSON 行。 + var codexHookLogPath: String { + URL(fileURLWithPath: logFilePath).deletingLastPathComponent() + .appendingPathComponent("codex-hook.log") + .path + } + + func readLog() -> String { + (try? String(contentsOfFile: logFilePath, encoding: .utf8)) ?? "(无日志)" + } + + // MARK: - Cursor 用户级文件(可展示、可合并,非 Hook 子进程管理) + + /// 与「安装 Cursor Hooks」写入路径一致,便于在 UI 中展示或对照。 + var userCursorHooksJsonFilePath: String { cursorHooksPath } + + /// Codex 0.125 使用 `~/.codex/config.toml` 的 inline `[[hooks.Event]]`。 + var userCodexConfigFilePath: String { codexConfigPath } + + /// Kimi Code CLI(Beta)使用 `~/.kimi/config.toml` 的 `[[hooks]]`。 + var userKimiConfigFilePath: String { kimiConfigPath } + + /// Cursor CLI / Agent 的全局 `permissions` 等(控制 Shell 等是否仍弹层确认,与 `hooks.json` 独立)。 + var userCursorCliConfigFilePath: String { cursorCliConfigPath } + + /// 将 `~/.cursor/hooks.json` 以可读(pretty)形式读出;不存在时返回说明。 + func readUserCursorHooksJsonForDisplay() -> String { + let path = cursorHooksPath + guard FileManager.default.fileExists(atPath: path) else { + return "(文件不存在:\(path))\n\n可先点「安装 Cursor Hooks」生成或合并;若只使用**项目内** `.cursor/hooks.json`,本路径仍可能为空。" + } + return Self.prettyJsonString(atPath: path) ?? "(存在但无法解析为 JSON:\(path))" + } + + /// 将 `~/.cursor/cli-config.json` 以可读(pretty)形式读出;不存在时提示。 + func readUserCursorCliConfigForDisplay() -> String { + let path = cursorCliConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "(文件不存在:\(path))\n\n可点诊断面板中「合并 Shell 白名单 + approvalMode=auto」从空白创建;或自行在文档中按 `permissions` 配置。" + } + return Self.prettyJsonString(atPath: path) ?? "(存在但无法解析为 JSON:\(path))" + } + + /// 将 `~/.codex/config.toml` 原样读出;Codex hooks 是 TOML,不是 JSON。 + func readUserCodexConfigForDisplay() -> String { + let path = codexConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "(文件不存在:\(path))\n\n可先点「安装 Codex Hooks」创建并合并 `[features].hooks` 与 AhaKey hook block。" + } + return (try? String(contentsOfFile: path, encoding: .utf8)) ?? "(存在但无法读取:\(path))" + } + + /// `~/.kimi/config.toml` 原样读出(Kimi Hooks 配置为文本 TOML)。 + func readUserKimiConfigForDisplay() -> String { + let path = kimiConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "(文件不存在:\(path))\n\n可先点「安装 Kimi Hooks」创建并写入 AhaKey 标记块;须已安装并使用 Kimi Code CLI:https://moonshotai.github.io/kimi-cli/" + } + return (try? String(contentsOfFile: path, encoding: .utf8)) ?? "(存在但无法读取:\(path))" + } + + /// 备份当前 `cli-config` 后,合并 `permissions.allow`(不删你已有项),并设置 `approvalMode` 为 `auto`。 + /// 用于减轻「hook 已 allow 但 Cursor 仍要求再点一次」中 **Cursor 自己那一层** 的拦阻。 + /// - Returns: 给用户看的结果说明。 + func mergeUserCursorCliConfigForShellAutoApprove() -> String { + let path = cursorCliConfigPath + let cursorDir = (path as NSString).deletingLastPathComponent + do { + try FileManager.default.createDirectory(atPath: cursorDir, withIntermediateDirectories: true) + } catch { + return "无法创建目录 \(cursorDir):\(error.localizedDescription)" + } + if FileManager.default.fileExists(atPath: path) { + let bak = path + ".ahakey.bak" + do { + if FileManager.default.fileExists(atPath: bak) { + try FileManager.default.removeItem(atPath: bak) + } + try FileManager.default.copyItem(atPath: path, toPath: bak) + } catch { + return "已存在 \(path) 但无法复制备份到 \(bak):\(error.localizedDescription)" + } + } + var root = loadCursorCliConfig() ?? [:] + if root["version"] == nil { root["version"] = 1 } + + var perms = root["permissions"] as? [String: Any] ?? [:] + var allow = Self.stringArrayValue(perms["allow"]) + let additions: [String] = [ + "Shell(*)", "Shell(cd)", "Shell(swift)", "Shell(xcodebuild)", "Shell(git)", "Shell(python3)", "Shell(npm)", "Shell(cargo)", "Shell(curl)", "Shell(ls)", + ] + var merged = 0 + for a in additions { + if !allow.contains(a) { + allow.append(a) + merged += 1 + } + } + perms["allow"] = allow + if perms["deny"] == nil { perms["deny"] = [String]() } + root["permissions"] = perms + root["approvalMode"] = "auto" + + guard saveCursorCliConfig(root) else { + return "合并后的 JSON 无法写回:\(path)" + } + log.info("cli-config: merged Shell allow + approvalMode=auto at \(path)") + return "已写回:\(path)\n(此前若存在同路径文件,已备份为 \(path).ahakey.bak)\n\n本次在 permissions.allow 中新增合并 \(merged) 条常见 Shell(...) 规则(已有规则保留);approvalMode 已设为 auto。\n\n若某版本仍弹窗,请把仍被拦的命令首词对照文档自行追加白名单:\nhttps://cursor.com/docs/cli/reference/permissions\n或检查工作区 .cursor/cli.json 是否另有限制。" + } + + /// 合并 `~/.cursor/permissions.json` 的 `terminalAllowlist`(**IDE「Not in allowlist」** 与 cli-config 无关)。 + func mergeUserCursorPermissionsJsonForAgentTUI() -> String { + let path = cursorPermissionsJsonPath + let cursorDir = (path as NSString).deletingLastPathComponent + do { + try FileManager.default.createDirectory(atPath: cursorDir, withIntermediateDirectories: true) + } catch { + return "无法创建目录 \(cursorDir):\(error.localizedDescription)" + } + if FileManager.default.fileExists(atPath: path) { + let bak = path + ".ahakey.bak" + do { + if FileManager.default.fileExists(atPath: bak) { try FileManager.default.removeItem(atPath: bak) } + try FileManager.default.copyItem(atPath: path, toPath: bak) + } catch { + return "已存在 permissions.json 但无法备份到 \(bak):\(error.localizedDescription)" + } + } + var root = loadCursorPermissionsJson() ?? [:] + var list = Self.stringArrayValue(root["terminalAllowlist"]) + let additions = [ + "cd", "swift", "swift build", "xcodebuild", "git", "npm", "yarn", "pnpm", "bun", "deno", "node", + "make", "cargo", "go", "python3", "python", "bash", "zsh", "sh", "curl", "ls", + ] + var n = 0 + for a in additions where !list.contains(a) { + list.append(a) + n += 1 + } + root["terminalAllowlist"] = list + guard saveCursorPermissionsJson(root) else { + return "无法写回:\(path)" + } + log.info("permissions.json: merged terminalAllowlist at \(path)") + return "已写回:\(path)(备份为 \(path).ahakey.bak)\n\n本次在 terminalAllowlist 中新增合并 \(n) 条前缀;用于 Agent 内「Not in allowlist」层,与 cli-config 的 Shell(...) 是两套。文档:\nhttps://cursor.com/docs/reference/permissions" + } + + private func loadCursorPermissionsJson() -> [String: Any]? { + guard let data = FileManager.default.contents(atPath: cursorPermissionsJsonPath), + let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return j + } + + private func saveCursorPermissionsJson(_ root: [String: Any]) -> Bool { + guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) else { return false } + do { + try data.write(to: URL(fileURLWithPath: cursorPermissionsJsonPath), options: .atomic) + return true + } catch { + log.error("saveCursorPermissionsJson: \(error.localizedDescription)") + return false + } + } + + private static func prettyJsonString(atPath path: String) -> String? { + guard let data = FileManager.default.contents(atPath: path), + let obj = try? JSONSerialization.jsonObject(with: data) else { return nil } + guard let out = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) else { return nil } + return String(data: out, encoding: .utf8) + } + + private static func stringArrayValue(_ v: Any?) -> [String] { + if let a = v as? [String] { return a } + if let a = v as? [Any] { return a.compactMap { $0 as? String } } + return [] + } + + private func loadCursorCliConfig() -> [String: Any]? { + guard let data = FileManager.default.contents(atPath: cursorCliConfigPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return json + } + + private func saveCursorCliConfig(_ root: [String: Any]) -> Bool { + guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) else { return false } + do { + try data.write(to: URL(fileURLWithPath: cursorCliConfigPath), options: .atomic) + return true + } catch { + log.error("saveCursorCliConfig: \(error.localizedDescription)") + return false + } + } + + /// 只读;由 `ahakeyconfig-agent` 在 `PermissionRequest` 与 Cursor 批准类 hook 中写入。 + func readPermissionRequestLog() -> String { + (try? String(contentsOfFile: permissionRequestLogPath, encoding: .utf8)) + ?? "尚无记录。在 Claude 中触发 PermissionRequest,在 Cursor 中让 Agent 调工具/Shell/MCP,或在 Kimi Code CLI 中触发工具调用后,会在此追加带 `ide` / `hookEvent` 的 JSON 行。若始终为空,请确认已安装 Agent、Hooks、蓝牙由 Agent 占用,且 `~/Library/.../AhaKeyConfig/diagnostics/` 可写。" + } + + /// 只读;由 `ahakeyconfig-agent hook Codex*` 子进程写入,用于判断 Codex 客户端/终端是否真的触发了 hook。 + func readCodexHookLog() -> String { + (try? String(contentsOfFile: codexHookLogPath, encoding: .utf8)) + ?? "尚无记录。触发 Codex 后应在此追加 JSON 行。若终端 Codex 有记录、Codex 客户端没有记录,说明客户端未加载当前 `~/.codex/config.toml` hook,通常需要重启 Codex 客户端/新开终端后再测。" + } + + // MARK: - Claude hooks 追加 + + /// Claude Code 支持的 hook 事件(和 HookClient.eventMap 对齐) + private let hookEvents: [String] = [ + "Notification", + "PermissionRequest", + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", // Claude Code 拆分后:手动终止任务时触发此事件 + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "TaskCompleted", + "PreCompact", + ] + + /// Shell 安全地引用一个路径(单引号包裹 + 转义内部单引号) + private func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// 空串表示已写入;非空为「跳过 / 失败」说明,需展示给用户。 + private func installClaudeHooks() -> String { + guard var settings = loadClaudeSettings() else { + return "Claude Hooks:未找到 ~/.claude/settings.json,已跳过。使用 Claude Code 并生成该文件后,可再点「安装 Claude Hooks」。" + } + var hooks = settings["hooks"] as? [String: Any] ?? [:] + + let binQuoted = shellQuote(agentBinaryPath) + + for event in hookEvents { + let ahakeyCmd = "\(binQuoted) hook \(event)" + var eventHooks = hooks[event] as? [[String: Any]] ?? [] + + // 先清掉老的 ahakey 条目,避免 shell 脚本 + 新二进制并存 + for i in eventHooks.indices { + var entry = eventHooks[i] + if var cmds = entry["hooks"] as? [[String: Any]] { + cmds.removeAll { isAhakeyHookCommand(($0["command"] as? String) ?? "") } + entry["hooks"] = cmds + eventHooks[i] = entry + } + } + + if let idx = eventHooks.firstIndex(where: { ($0["matcher"] as? String) == "" }) { + var entry = eventHooks[idx] + var cmds = entry["hooks"] as? [[String: Any]] ?? [] + cmds.append(["type": "command", "command": ahakeyCmd]) + entry["hooks"] = cmds + eventHooks[idx] = entry + } else { + eventHooks.append([ + "matcher": "", + "hooks": [["type": "command", "command": ahakeyCmd]], + ]) + } + hooks[event] = eventHooks + } + + settings["hooks"] = hooks + if saveClaudeSettings(settings) { + log.info("Claude hooks 已写入 ahakeyconfig-agent hook 子命令") + return "" + } + return "Claude Hooks:无法写入 \(claudeSettingsPath)。请检查该文件或父目录的权限/只读状态。" + } + + private func removeClaudeHooks() { + guard var settings = loadClaudeSettings() else { return } + guard var hooks = settings["hooks"] as? [String: Any] else { return } + + for event in hookEvents { + guard var eventHooks = hooks[event] as? [[String: Any]] else { continue } + for i in eventHooks.indices { + var entry = eventHooks[i] + if var cmds = entry["hooks"] as? [[String: Any]] { + cmds.removeAll { isAhakeyHookCommand(($0["command"] as? String) ?? "") } + entry["hooks"] = cmds + eventHooks[i] = entry + } + } + hooks[event] = eventHooks + } + + settings["hooks"] = hooks + if !saveClaudeSettings(settings) { + log.error("removeClaudeHooks: 无法写回 settings") + } else { + log.info("Claude hooks 中 ahakey 条目已移除") + } + } + + private func loadClaudeSettings() -> [String: Any]? { + guard let data = FileManager.default.contents(atPath: claudeSettingsPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return json + } + + private func saveClaudeSettings(_ settings: [String: Any]) -> Bool { + guard let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) else { return false } + do { + try data.write(to: URL(fileURLWithPath: claudeSettingsPath), options: .atomic) + return true + } catch { + log.error("saveClaudeSettings: \(error.localizedDescription)") + return false + } + } + + // MARK: - Cursor hooks + + /// Cursor 支持的 hook 事件(小驼峰,与 `HookClient` 一致)。 + /// 批准链集中在 `preToolUse`(在任意工具前调用,可 stdout `permission`);若你在 `hooks.json` 里自行添加 + /// `beforeShellExecution` / `beforeMCPExecution` 并指向本 agent,其事件名在 `HookClient` 中同样支持拨杆。 + /// 安装时写入这些事件;**卸载**时会遍历 `hooks` 的**所有键**(含旧版/合并进的 `beforeReadFile`、`beforeSubmitPrompt` 等),避免只卸一半导致「没反应」。 + private let cursorHookEvents: [String] = [ + "sessionStart", + "sessionEnd", + "preToolUse", + "beforeShellExecution", + "beforeMCPExecution", + "beforeReadFile", + "beforeSubmitPrompt", + "postToolUse", + "stop", + ] + + private let codexHookBlockStart = "# BEGIN AhaKey Codex Hooks" + private let codexHookBlockEnd = "# END AhaKey Codex Hooks" + private let codexHookEvents: [(event: String, agentEvent: String, timeout: Int)] = [ + ("SessionStart", "CodexSessionStart", 10), + ("PostToolUse", "CodexPostToolUse", 10), + ("PreToolUse", "CodexPreToolUse", 20), + ("PermissionRequest", "CodexPermissionRequest", 20), + ("UserPromptSubmit", "CodexUserPromptSubmit", 10), + ("Stop", "CodexStop", 10), + ] + + private let kimiHookBlockStart = "# BEGIN AhaKey Kimi Hooks" + private let kimiHookBlockEnd = "# END AhaKey Kimi Hooks" + private let kimiHookEntries: [(event: String, agentEvent: String, timeout: Int)] = [ + ("Notification", "KimiNotification", 10), + ("SessionStart", "KimiSessionStart", 10), + ("SessionEnd", "KimiSessionEnd", 10), + ("PreToolUse", "KimiPreToolUse", 20), + ("PostToolUse", "KimiPostToolUse", 10), + ("UserPromptSubmit", "KimiUserPromptSubmit", 10), + ("Stop", "KimiStop", 10), + ] + + /// 单独安装 Claude hooks + func installClaudeHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + let s = installClaudeHooks() + agentUserAlert = s.isEmpty ? "Claude Hooks 已写入 ~/.claude/settings.json。" : s + refresh() + } + + /// 单独移除 Claude hooks + func removeClaudeHooksOnly() { + removeClaudeHooks() + refresh() + } + + /// 单独安装 Cursor hooks(公开给 UI 用,例如只想补装 Cursor 时调用) + func installCursorHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + let s = installCursorHooks() + agentUserAlert = s.isEmpty ? "Cursor Hooks 已写入 ~/.cursor/hooks.json。" : s + refresh() + } + + /// 单独安装 Codex hooks(Codex 0.125 为 inline TOML)。 + func installCodexHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + let s = installCodexHooks() + agentUserAlert = s.isEmpty ? "Codex Hooks 已写入 ~/.codex/config.toml。\n\n安装完成。请重启 Codex 终端或客户端后再使用。" : s + refresh() + } + + /// 单独移除 Cursor hooks + func removeCursorHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + agentUserAlert = performRemoveCursorHooksUserMessage() + refresh() + } + + /// 单独移除 Codex hooks。 + func removeCodexHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + agentUserAlert = removeCodexHooks() + refresh() + } + + /// 单独安装 Kimi Code CLI hooks(`~/.kimi/config.toml`,Beta)。 + func installKimiHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + let s = installKimiHooks() + agentUserAlert = s.isEmpty + ? """ + Kimi Hooks 已写入 ~/.kimi/config.toml。 + + **AhaKey 拨杆接管也会一并重打到本机 kimi-cli**。如果 kimi 当前已经打开,请**完全关闭并重新打开一次**;重开后,**拨杆 0/1 会直接接管当前会话的自动批准**,**不需要 `/reload`,也不需要 `/yolo`**。 + 以后若你**升级了 kimi-cli**,再次点击一次「安装 Kimi Hooks」即可把这层拨杆接管补回去,然后再重开一次 kimi。 + + 安装完成。Hooks 为 Beta,行为以官方文档为准。 + """ + : s + refresh() + } + + /// 单独移除 Kimi Hooks 标记块。 + func removeKimiHooksOnly() { + isAgentOperationInProgress = true + defer { isAgentOperationInProgress = false } + agentUserAlert = removeKimiHooks() + refresh() + } + + private func installCursorHooks() -> String { + // Cursor 的目录可能不存在,先建好 + let cursorDir = (cursorHooksPath as NSString).deletingLastPathComponent + do { + try FileManager.default.createDirectory(atPath: cursorDir, withIntermediateDirectories: true) + } catch { + return "Cursor Hooks:无法创建目录 \(cursorDir):\(error.localizedDescription)" + } + + var settings = loadCursorSettings() ?? [:] + var hooks = settings["hooks"] as? [String: Any] ?? [:] + + let binQuoted = shellQuote(agentBinaryPath) + + for event in cursorHookEvents { + let cmd = "\(binQuoted) hook \(event)" + // Cursor:`{ "hooks": { "": [{ "command": "...", "timeout": N }] } }` + // 读拨杆/写状态略慢,长超时与 `HookClient` 一致 + let t: Int + if event == "beforeSubmitPrompt" { t = 30 } + else if ["preToolUse", "beforeShellExecution", "beforeMCPExecution", "beforeReadFile", "sessionStart"].contains(event) { t = 20 } + else { t = 10 } + var entries = hooks[event] as? [[String: Any]] ?? [] + entries.removeAll { isAhakeyHookCommand(($0["command"] as? String) ?? "") } + entries.append(["command": cmd, "timeout": t]) + hooks[event] = entries + } + + settings["hooks"] = hooks + if settings["version"] == nil { + settings["version"] = 1 + } + if saveCursorSettings(settings) { + log.info("Cursor hooks 已写入") + return "" + } + return "Cursor Hooks:无法写入 \(cursorHooksPath)。请检查权限或磁盘空间。" + } + + private func installCodexHooks() -> String { + let codexDir = (codexConfigPath as NSString).deletingLastPathComponent + do { + try FileManager.default.createDirectory(atPath: codexDir, withIntermediateDirectories: true) + } catch { + return "Codex Hooks:无法创建目录 \(codexDir):\(error.localizedDescription)" + } + + var config = (try? String(contentsOfFile: codexConfigPath, encoding: .utf8)) ?? "" + config = removeCodexHookBlock(from: config) + config = ensureCodexHooksFeatureEnabled(in: config) + config = config.trimmingCharacters(in: .whitespacesAndNewlines) + if !config.isEmpty { config += "\n\n" } + config += buildCodexHookBlock() + config += "\n" + + do { + try config.write(toFile: codexConfigPath, atomically: true, encoding: .utf8) + guard FileManager.default.fileExists(atPath: codexConfigPath), + let written = try? String(contentsOfFile: codexConfigPath, encoding: .utf8), + written.contains(codexHookBlockStart), + written.contains(codexHookBlockEnd) else { + log.error("installCodexHooks: 写入后校验失败 \(self.codexConfigPath)") + return "Codex Hooks:已尝试写入 \(codexConfigPath),但校验时未发现 AhaKey 标记块。请确认对「用户主目录 /.codex」有写权限,或关闭占用该文件的其它程序。" + } + log.info("Codex hooks 已写入 ~/.codex/config.toml") + let cliRepair = repairCodexCliPathIfNeeded() + return cliRepair.isEmpty + ? "" + : "Codex Hooks 已写入 ~/.codex/config.toml。\n\n\(cliRepair)\n\n安装完成。请重启 Codex 终端或客户端后再使用。" + } catch { + log.error("installCodexHooks: \(error.localizedDescription)") + return "Codex Hooks:无法写入 \(codexConfigPath):\(error.localizedDescription)" + } + } + + private func repairCodexCliPathIfNeeded() -> String { + if isExecutableOnPath("codex") { + return "" + } + guard FileManager.default.isExecutableFile(atPath: codexAppCliPath) else { + return "未在 PATH 中找到 `codex` 命令,也未找到 Codex App 自带 CLI:\(codexAppCliPath)。Hook 配置已安装;若需在终端使用 Codex,请先安装或更新 Codex 客户端。" + } + + do { + try FileManager.default.createDirectory(atPath: localBinDirectoryPath, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: localCodexCliPath) { + try FileManager.default.removeItem(atPath: localCodexCliPath) + } + try FileManager.default.createSymbolicLink(atPath: localCodexCliPath, withDestinationPath: codexAppCliPath) + } catch { + return "检测到终端中 `codex` 不可用,但无法创建 \(localCodexCliPath):\(error.localizedDescription)。Hook 配置已安装。" + } + + let zshLine = #"export PATH="$HOME/.local/bin:$PATH""# + do { + var zshrc = (try? String(contentsOfFile: zshrcPath, encoding: .utf8)) ?? "" + if !zshrc.contains("HOME/.local/bin") && !zshrc.contains("$HOME/.local/bin") { + if FileManager.default.fileExists(atPath: zshrcPath) { + let bak = zshrcPath + ".ahakey.bak" + if FileManager.default.fileExists(atPath: bak) { + try FileManager.default.removeItem(atPath: bak) + } + try FileManager.default.copyItem(atPath: zshrcPath, toPath: bak) + } + zshrc = zshrc.trimmingCharacters(in: .whitespacesAndNewlines) + if !zshrc.isEmpty { zshrc += "\n" } + zshrc += zshLine + "\n" + try zshrc.write(toFile: zshrcPath, atomically: true, encoding: .utf8) + return "已检测到 Codex App 自带 CLI,并修复终端命令:\n\(localCodexCliPath) → \(codexAppCliPath)\n\n已将 `~/.local/bin` 加入 ~/.zshrc(若原文件存在,已备份为 ~/.zshrc.ahakey.bak)。" + } + } catch { + return "已创建 \(localCodexCliPath),但无法更新 ~/.zshrc:\(error.localizedDescription)。请手动把 `~/.local/bin` 加入 PATH。" + } + + return "已检测到 Codex App 自带 CLI,并创建终端命令:\n\(localCodexCliPath) → \(codexAppCliPath)" + } + + private func isExecutableOnPath(_ command: String) -> Bool { + executablePathOnPath(command) != nil + } + + private func executablePathOnPath(_ command: String) -> String? { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + for dir in path.split(separator: ":") { + let candidate = (String(dir) as NSString).appendingPathComponent(command) + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + @discardableResult + private func removeCodexHooks() -> String { + let path = codexConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "未找到 \(path),无需移除 Codex Hooks。" + } + guard let config = try? String(contentsOfFile: path, encoding: .utf8) else { + return "无法读取 \(path),请检查权限。" + } + let next = removeCodexHookBlock(from: config) + guard next != config else { + return "在 \(path) 中未发现 AhaKey Codex hook 标记块。" + } + do { + try next.write(toFile: path, atomically: true, encoding: .utf8) + log.info("Codex hooks 中 AhaKey 标记块已移除") + return "已从 \(path) 移除 AhaKey Codex Hooks。" + } catch { + return "已生成移除后的内容,但无法写回 \(path):\(error.localizedDescription)" + } + } + + private func buildCodexHookBlock() -> String { + let binQuoted = shellQuote(agentBinaryPath) + var lines: [String] = [ + codexHookBlockStart, + "# Managed by AhaKey Studio. Codex 0.125 uses inline TOML hooks; each command hook needs type = \"command\".", + ] + for item in codexHookEvents { + lines.append("") + lines.append("[[hooks.\(item.event)]]") + lines.append("matcher = \"\"") + lines.append("") + lines.append("[[hooks.\(item.event).hooks]]") + lines.append("type = \"command\"") + lines.append("command = \"\(escapeTomlBasicString("/bin/zsh -lc \(shellQuote("\(binQuoted) hook \(item.agentEvent)"))"))\"") + lines.append("timeout = \(item.timeout)") + } + lines.append("") + lines.append(codexHookBlockEnd) + return lines.joined(separator: "\n") + } + + private func removeCodexHookBlock(from config: String) -> String { + var lines = config.components(separatedBy: .newlines) + while let start = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == codexHookBlockStart }), + let end = lines[start...].firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == codexHookBlockEnd }) { + lines.removeSubrange(start...end) + } + return lines.joined(separator: "\n") + .replacingOccurrences(of: "\n\n\n", with: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + + private func ensureCodexHooksFeatureEnabled(in config: String) -> String { + var lines = config.components(separatedBy: .newlines) + var featuresStart: Int? + for (idx, line) in lines.enumerated() { + if line.trimmingCharacters(in: .whitespaces) == "[features]" { + featuresStart = idx + break + } + } + + guard let start = featuresStart else { + var next = config.trimmingCharacters(in: .whitespacesAndNewlines) + if !next.isEmpty { next += "\n\n" } + next += "[features]\nhooks = true\n" + return next + } + + var sectionEnd = lines.count + if start + 1 < lines.count { + for idx in (start + 1).. String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private func installKimiHooks() -> String { + let kimiDir = (kimiConfigPath as NSString).deletingLastPathComponent + do { + try FileManager.default.createDirectory(atPath: kimiDir, withIntermediateDirectories: true) + } catch { + return "Kimi Hooks:无法创建目录 \(kimiDir):\(error.localizedDescription)" + } + + var config = (try? String(contentsOfFile: kimiConfigPath, encoding: .utf8)) ?? "" + config = removeKimiHookBlock(from: config) + config = removeLegacyKimiHookEntries(from: config) + config = config.trimmingCharacters(in: .whitespacesAndNewlines) + if !config.isEmpty { config += "\n\n" } + config += buildKimiHookBlock() + config += "\n" + + do { + try config.write(toFile: kimiConfigPath, atomically: true, encoding: .utf8) + log.info("Kimi hooks 已写入 ~/.kimi/config.toml") + return patchInstalledKimiCliForAhaKeyDialControl() + } catch { + log.error("installKimiHooks: \(error.localizedDescription)") + return "Kimi Hooks:无法写入 \(kimiConfigPath):\(error.localizedDescription)" + } + } + + private struct KimiCliPatchTargets { + let approvalPyPath: String + let slashPyPath: String + let sourceHint: String + } + + private enum KimiCliPatchStatus { + case alreadyPatched + case patched + } + + private func patchInstalledKimiCliForAhaKeyDialControl() -> String { + guard let targets = resolveKimiCliPatchTargets() else { + return """ + Kimi Hooks 已写入 ~/.kimi/config.toml,但**未找到可重打补丁的本机 kimi-cli 安装**。 + + 请确认终端里存在 `kimi` 命令;确认后再次点击「安装 Kimi Hooks」即可重试拨杆接管补丁。 + """ + } + + do { + _ = try patchKimiApprovalPy(atPath: targets.approvalPyPath) + _ = try patchKimiSlashPy(atPath: targets.slashPyPath) + log.info("Kimi CLI dial-control patch ensured at \(targets.sourceHint)") + return "" + } catch { + log.error("patchInstalledKimiCliForAhaKeyDialControl: \(error.localizedDescription)") + return """ + Kimi Hooks 已写入 ~/.kimi/config.toml,但**本机 kimi-cli 拨杆接管补丁未完成**: + \(error.localizedDescription) + + 你可在确认 `kimi` 可执行后,再次点击「安装 Kimi Hooks」重试。 + """ + } + } + + private func resolveKimiCliPatchTargets() -> KimiCliPatchTargets? { + if let kimiPath = executablePathOnPath("kimi"), + let targets = resolveKimiCliPatchTargets(fromKimiEntryPath: kimiPath) { + return targets + } + + let fallbackRoot = URL(fileURLWithPath: kimiCliFallbackRoot, isDirectory: true) + if let targets = resolveKimiCliPatchTargets(fromEnvRoot: fallbackRoot, sourceHint: fallbackRoot.path) { + return targets + } + return nil + } + + private func resolveKimiCliPatchTargets(fromKimiEntryPath path: String) -> KimiCliPatchTargets? { + guard let wrapper = try? String(contentsOfFile: path, encoding: .utf8), + let firstLine = wrapper.components(separatedBy: .newlines).first, + firstLine.hasPrefix("#!") else { + return nil + } + let shebang = String(firstLine.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !shebang.isEmpty else { return nil } + let pythonPath = shebang.components(separatedBy: .whitespaces).first ?? shebang + let envRoot = URL(fileURLWithPath: pythonPath) + .deletingLastPathComponent() + .deletingLastPathComponent() + return resolveKimiCliPatchTargets(fromEnvRoot: envRoot, sourceHint: path) + } + + private func resolveKimiCliPatchTargets(fromEnvRoot envRoot: URL, sourceHint: String) -> KimiCliPatchTargets? { + let libRoot = envRoot.appendingPathComponent("lib", isDirectory: true) + guard let children = try? FileManager.default.contentsOfDirectory(at: libRoot, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { + return nil + } + for child in children.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard child.lastPathComponent.hasPrefix("python") else { continue } + let pkgRoot = child.appendingPathComponent("site-packages/kimi_cli", isDirectory: true) + let approval = pkgRoot.appendingPathComponent("soul/approval.py").path + let slash = pkgRoot.appendingPathComponent("soul/slash.py").path + if FileManager.default.fileExists(atPath: approval), + FileManager.default.fileExists(atPath: slash) { + return KimiCliPatchTargets( + approvalPyPath: approval, + slashPyPath: slash, + sourceHint: sourceHint + ) + } + } + return nil + } + + private func patchKimiApprovalPy(atPath path: String) throws -> KimiCliPatchStatus { + let marker = "_AHAKEY_SOCKET_PATH = \"/tmp/ahakey.sock\"" + let helperAnchor = "type Response = Literal[\"approve\", \"approve_for_session\", \"reject\"]\n" + let helperBlock = """ + type Response = Literal["approve", "approve_for_session", "reject"] + + _AHAKEY_SOCKET_PATH = "/tmp/ahakey.sock" + _AHAKEY_APPROVAL_CACHE_TTL_S = 0.35 + _ahakey_cache_at = 0.0 + _ahakey_cache_value: dict[str, object] | None = None + + + def _load_ahakey_override_uncached() -> dict[str, object] | None: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(2.0) + sock.connect(_AHAKEY_SOCKET_PATH) + sock.sendall(b'{"cmd":"approval_status"}\\n') + + chunks: list[bytes] = [] + while True: + part = sock.recv(4096) + if not part: + break + chunks.append(part) + if b"\\n" in part: + break + except OSError: + return None + + raw = b"".join(chunks).decode("utf-8", errors="ignore").strip() + if not raw: + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return None + switch_state = payload.get("switchState") + if not isinstance(switch_state, int): + return None + return { + "switch_state": switch_state, + "is_auto": switch_state == 0, + "mode_label": "auto" if switch_state == 0 else "manual", + } + + + def get_ahakey_approval_override(*, force_refresh: bool = False) -> dict[str, object] | None: + global _ahakey_cache_at, _ahakey_cache_value + + now = time.monotonic() + if not force_refresh and (now - _ahakey_cache_at) < _AHAKEY_APPROVAL_CACHE_TTL_S: + return _ahakey_cache_value + + value = _load_ahakey_override_uncached() + _ahakey_cache_at = now + _ahakey_cache_value = value + return value + """ + let oldImports = "import uuid\n" + let newImports = """ + import json + import socket + import time + import uuid + """ + let oldApprovalLogic = """ + if self.is_auto_approve(): + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="afk" if self.is_afk() else "yolo", + ) + return ApprovalResult(approved=True) + + if action in self._state.auto_approve_actions: + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="auto_session", + ) + return ApprovalResult(approved=True) + """ + let newApprovalLogic = """ + ahakey_override = get_ahakey_approval_override(force_refresh=True) + if ahakey_override is not None and bool(ahakey_override["is_auto"]): + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="ahakey_dial_auto", + ) + return ApprovalResult(approved=True) + + ahakey_manual_lock = ahakey_override is not None and not bool(ahakey_override["is_auto"]) + + if not ahakey_manual_lock and self.is_auto_approve(): + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="afk" if self.is_afk() else "yolo", + ) + return ApprovalResult(approved=True) + + if not ahakey_manual_lock and action in self._state.auto_approve_actions: + from kimi_cli.telemetry import track + + track( + "tool_approved", + tool_name=tool_call.function.name, + approval_mode="auto_session", + ) + return ApprovalResult(approved=True) + """ + return try patchTextFile( + atPath: path, + marker: marker, + replacements: [ + (oldImports, newImports + "\n"), + (helperAnchor, helperBlock + "\n"), + (oldApprovalLogic, newApprovalLogic), + ], + friendlyName: "kimi_cli/soul/approval.py" + ) + } + + private func patchKimiSlashPy(atPath path: String) throws -> KimiCliPatchStatus { + let marker = "from kimi_cli.soul.approval import get_ahakey_approval_override" + let oldImport = "from kimi_cli import logger\n" + let newImport = """ + from kimi_cli import logger + from kimi_cli.soul.approval import get_ahakey_approval_override + """ + let oldYoloLead = """ + # Inspect only the yolo flag: afk is independent and is toggled by /afk. + """ + let newYoloLead = """ + ahakey_override = get_ahakey_approval_override(force_refresh=True) + if ahakey_override is not None: + mode_label = "自动批准" if bool(ahakey_override["is_auto"]) else "手动批准" + wire_send( + TextPart( + text=( + f"AhaKey 拨杆接管中:当前为{mode_label}。" + "请直接拨动键盘上的物理拨杆切换;`/yolo` 不会覆盖拨杆。" + ) + ) + ) + return + + # Inspect only the yolo flag: afk is independent and is toggled by /afk. + """ + return try patchTextFile( + atPath: path, + marker: marker, + replacements: [ + (oldImport, newImport + "\n"), + (oldYoloLead, newYoloLead), + ], + friendlyName: "kimi_cli/soul/slash.py" + ) + } + + private func patchTextFile( + atPath path: String, + marker: String, + replacements: [(String, String)], + friendlyName: String + ) throws -> KimiCliPatchStatus { + let url = URL(fileURLWithPath: path) + var text = try String(contentsOf: url, encoding: .utf8) + if text.contains(marker) { + return .alreadyPatched + } + for (old, new) in replacements { + guard text.contains(old) else { + throw NSError( + domain: "AhaKeyKimiPatch", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "未在 \(friendlyName) 中找到可替换的上游锚点,可能是 kimi-cli 版本已变。"] + ) + } + text = text.replacingOccurrences(of: old, with: new) + } + try text.write(to: url, atomically: true, encoding: .utf8) + return .patched + } + + @discardableResult + private func removeKimiHooks() -> String { + let path = kimiConfigPath + guard FileManager.default.fileExists(atPath: path) else { + return "未找到 \(path),无需移除 Kimi Hooks。" + } + guard let config = try? String(contentsOfFile: path, encoding: .utf8) else { + return "无法读取 \(path),请检查权限。" + } + let next = removeLegacyKimiHookEntries(from: removeKimiHookBlock(from: config)) + guard next != config else { + return "在 \(path) 中未发现 AhaKey Kimi hook 标记块或旧版裸 hook。" + } + do { + try next.write(toFile: path, atomically: true, encoding: .utf8) + log.info("Kimi hooks 中 AhaKey 标记块与旧版裸 hook 已移除") + return "已从 \(path) 移除 AhaKey Kimi Hooks。" + } catch { + return "已生成移除后的内容,但无法写回 \(path):\(error.localizedDescription)" + } + } + + private func buildKimiHookBlock() -> String { + let binQuoted = shellQuote(agentBinaryPath) + var lines: [String] = [ + kimiHookBlockStart, + "# Managed by AhaKey Studio. Kimi CLI (Beta): multiple [[hooks]] entries; each runs with JSON on stdin.", + "# Dial integration is managed by AhaKey Studio. Re-click 'Install Kimi Hooks' after kimi-cli upgrades, then reopen kimi once.", + ] + for item in kimiHookEntries { + let cmdToml = escapeTomlBasicString("/bin/zsh -lc \(shellQuote("\(binQuoted) hook \(item.agentEvent)"))") + lines.append("") + lines.append("[[hooks]]") + lines.append("event = \"\(item.event)\"") + lines.append("matcher = \"\"") + lines.append("command = \"\(cmdToml)\"") + lines.append("timeout = \(item.timeout)") + } + lines.append("") + lines.append(kimiHookBlockEnd) + return lines.joined(separator: "\n") + } + + private func removeKimiHookBlock(from config: String) -> String { + var lines = config.components(separatedBy: .newlines) + while let start = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == kimiHookBlockStart }), + let end = lines[start...].firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == kimiHookBlockEnd }) { + lines.removeSubrange(start...end) + } + return lines.joined(separator: "\n") + .replacingOccurrences(of: "\n\n\n", with: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + + /// 清理未包在 BEGIN/END 标记块中的旧版 AhaKey Kimi hook,避免同一事件重复触发两次。 + private func removeLegacyKimiHookEntries(from config: String) -> String { + let lines = config.components(separatedBy: .newlines) + var kept: [String] = [] + var idx = 0 + + while idx < lines.count { + let trimmed = lines[idx].trimmingCharacters(in: .whitespaces) + guard trimmed == "[[hooks]]" else { + kept.append(lines[idx]) + idx += 1 + continue + } + + var block = [lines[idx]] + idx += 1 + while idx < lines.count { + let nextTrimmed = lines[idx].trimmingCharacters(in: .whitespaces) + if nextTrimmed == "[[hooks]]" || nextTrimmed == kimiHookBlockStart || nextTrimmed == kimiHookBlockEnd { + break + } + block.append(lines[idx]) + idx += 1 + } + + let joined = block.joined(separator: "\n") + if isAhakeyHookCommand(joined), joined.contains("hook Kimi") { + continue + } + kept.append(contentsOf: block) + } + + return kept.joined(separator: "\n") + .replacingOccurrences(of: "\n\n\n", with: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + + /// 供「卸载主流程」等内部调用,无 UI 提示。 + private func removeCursorHooks() { + _ = performRemoveCursorHooksUserMessage(writeAndLog: true, preferCompactMessage: true) + } + + /// 从 `~/.cursor/hooks.json` 的 **全部** 事件里删掉指向 ahakey 的条目,并写回文件。 + /// - Returns: 给用户看的说明(弹窗用);`writeAndLog==false` 时仍返回文案但不写盘(当前未用)。 + private func performRemoveCursorHooksUserMessage(writeAndLog: Bool = true, preferCompactMessage: Bool = false) -> String { + let path = cursorHooksPath + guard FileManager.default.fileExists(atPath: path) else { + return "未找到用户级 \(path)。\n\n若你只在**项目**里合并过 `.cursor/hooks.json`,需在该项目根目录中手动编辑或删除 AhaKey 相关条目,用户级里本来就没有可卸内容。" + } + guard var settings = loadCursorSettings() else { + return "无法解析 \(path)(非合法 JSON 或已损坏)。请用编辑器打开修正后再试,或从备份恢复。" + } + guard var hooks = settings["hooks"] as? [String: Any], !hooks.isEmpty else { + return "hooks.json 中无「hooks」或为空,没有可移除的 AhaKey 项。" + } + + var removedCount = 0 + for event in Array(hooks.keys).sorted() { + guard var entries = hooks[event] as? [[String: Any]] else { continue } + let before = entries.count + entries.removeAll { isAhakeyHookCommand(($0["command"] as? String) ?? "") } + removedCount += before - entries.count + if entries.isEmpty { + hooks.removeValue(forKey: event) + } else { + hooks[event] = entries + } + } + + if removedCount == 0 { + return "在 \(path) 中**未发现**包含 `ahakeyconfig-agent` 或 `ahakey-state` 的 `command`。\n\n若 Hook 在**项目级** `.cursor/hooks.json`,请在该仓库内手动删除;本按钮只改用户级 `~/.cursor/hooks.json`。" + } + + if hooks.isEmpty { + settings.removeValue(forKey: "hooks") + } else { + settings["hooks"] = hooks + } + + if writeAndLog { + if !saveCursorSettings(settings) { + log.error("removeCursorHooks: 无法写回 hooks.json") + return "已删除内存中的 AhaKey 条目,但**无法写回** \(path)。请检查对「用户目录下 .cursor」的写权限,或关闭占用该文件的其他应用后重试。" + } + log.info("Cursor hooks: removed \(removedCount) ahakey command(s)") + } + + if preferCompactMessage { return "" } + return "已从用户级 Cursor Hooks 中移除 AhaKey 相关条目(共 \(removedCount) 条子命令)。\n\n文件:\(path)\n\n若某仓库仍有**项目级** `.cursor/hooks.json` 且其中含有本工具,其优先级可能更高,需在该项目内同步删除或合并。" + } + + private func loadCursorSettings() -> [String: Any]? { + guard let data = FileManager.default.contents(atPath: cursorHooksPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return json + } + + private func saveCursorSettings(_ settings: [String: Any]) -> Bool { + guard let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) else { return false } + do { + try data.write(to: URL(fileURLWithPath: cursorHooksPath), options: .atomic) + return true + } catch { + log.error("saveCursorSettings: \(error.localizedDescription)") + return false + } + } + + // MARK: - launchctl + + private struct LaunchctlResult { + let ok: Bool + let mergedOutput: String + } + + private func runLaunchctlDetailed(_ args: [String]) -> LaunchctlResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return LaunchctlResult(ok: process.terminationStatus == 0, mergedOutput: text) + } catch { + return LaunchctlResult(ok: false, mergedOutput: error.localizedDescription) + } + } + + /// 再次 load 时系统常提示「已加载」类信息,不当作致命错误。 + private func isBenignLaunchctlLoadMessage(_ message: String) -> Bool { + let m = message.lowercased() + if m.isEmpty { return false } + if m.contains("already") { return true } + if m.contains("repeated load") { return true } + if m.contains("service already") { return true } + return false + } + + @discardableResult + private func runLaunchctlQuiet(_ args: [String]) -> Bool { + runLaunchctlDetailed(args).ok + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift b/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift new file mode 100644 index 00000000..3c08f878 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift @@ -0,0 +1,346 @@ +import Foundation + +@MainActor +final class AhaTypeTextOptimizer: ObservableObject { + static let shared = AhaTypeTextOptimizer() + + @Published private(set) var isEnabled = false + @Published private(set) var statusMessage = "AhaType 未启用。" + @Published private(set) var lastQuotaSummary = "尚未读取 AhaType 配置。" + + private let fallbackAPIBase = "https://956798.xyz/prod-api" + + private init() { + refreshFromDisk() + } + + func refreshFromDisk() { + let config = loadConfig() + isEnabled = boolValue(config["typeless_enabled"]) + updateStatus(from: config) + } + + func setEnabled(_ enabled: Bool) { + var config = loadConfig() + config["typeless_enabled"] = enabled + saveConfig(config) + isEnabled = enabled + updateStatus(from: config) + } + + func patchCloudToken(_ token: String) { + var config = loadConfig() + config["access_token"] = token + saveConfig(config) + refreshFromDisk() + } + + func setUserProfile(_ profile: [String: Any]) { + var config = loadConfig() + config["user"] = [ + "phone": stringValue(profile["phone"]), + "user_id": stringValue(profile["id"]).isEmpty ? stringValue(profile["user_id"]) : stringValue(profile["id"]), + ] + config["token_valid_until"] = profile["token_valid_until"] ?? NSNull() + for key in ["limit_daily", "limit_weekly", "limit_monthly", "used_daily", "used_weekly", "used_monthly"] { + config[key] = intValue(profile[key]) + } + saveConfig(config) + refreshFromDisk() + } + + func clearSessionKeepToggle() { + var config = loadConfig() + let enabled = boolValue(config["typeless_enabled"]) + config["access_token"] = "" + config["user"] = NSNull() + config["token_valid_until"] = NSNull() + for key in ["limit_daily", "limit_weekly", "limit_monthly", "used_daily", "used_weekly", "used_monthly"] { + config[key] = 0 + } + config["typeless_enabled"] = enabled + saveConfig(config) + refreshFromDisk() + } + + func processIfEnabled(_ text: String) async -> String { + let source = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty else { return text } + + var config = loadConfig() + sanitize(&config) + isEnabled = boolValue(config["typeless_enabled"]) + guard isEnabled else { + statusMessage = "AhaType 未启用,直接写入原始转写。" + return text + } + + guard tokenIsStillValid(config["token_valid_until"]) else { + statusMessage = "AhaType 登录已过期,直接写入原始转写。" + return text + } + + let token = stringValue(config["access_token"]).trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + statusMessage = "AhaType 缺少登录令牌,直接写入原始转写。" + return text + } + + guard let url = URL(string: "\(resolveAPIBase(legacyAPIBase: stringValue(config["api_base"])))/api/v1/typeless/process") else { + statusMessage = "AhaType 云端地址无效,直接写入原始转写。" + return text + } + + statusMessage = "AhaType 整理中…" + + var request = URLRequest(url: url, timeoutInterval: 120) + request.httpMethod = "POST" + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["text": source], options: []) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard statusCode == 200 else { + statusMessage = "AhaType 请求失败(HTTP \(statusCode)),已写入原始转写。" + return text + } + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + statusMessage = "AhaType 返回非 JSON,已写入原始转写。" + return text + } + let code = intValue(object["code"]) + guard code == 0 || code == 200 else { + let message = responseMessage(object) + statusMessage = message.isEmpty ? "AhaType 处理失败,已写入原始转写。" : "AhaType 处理失败:\(message)" + return text + } + guard let inner = object["data"] as? [String: Any] else { + statusMessage = "AhaType 返回缺少 data,已写入原始转写。" + return text + } + + if let quota = inner["quota"] as? [String: Any] { + mergeQuota(quota, into: &config) + saveConfig(config) + updateStatus(from: config) + } + + let output = stringValue(inner["text"]).isEmpty ? stringValue(inner["result"]) : stringValue(inner["text"]) + let polished = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !polished.isEmpty else { + statusMessage = "AhaType 返回空文本,已写入原始转写。" + return text + } + statusMessage = "AhaType 已整理,准备粘贴。" + return polished + } catch { + statusMessage = "AhaType 网络错误,已写入原始转写。" + return text + } + } + + private func updateStatus(from config: [String: Any]) { + let enabled = boolValue(config["typeless_enabled"]) + let token = stringValue(config["access_token"]).trimmingCharacters(in: .whitespacesAndNewlines) + let valid = tokenIsStillValid(config["token_valid_until"]) + + if !enabled { + statusMessage = "AhaType 未启用。" + } else if token.isEmpty { + statusMessage = "AhaType 已开启,但尚未登录。" + } else if !valid { + statusMessage = "AhaType 已开启,但登录已过期。" + } else { + statusMessage = "AhaType 已开启,语音结果会先经云端整理。" + } + + let daily = quotaLine(title: "日", used: config["used_daily"], limit: config["limit_daily"]) + let weekly = quotaLine(title: "周", used: config["used_weekly"], limit: config["limit_weekly"]) + let monthly = quotaLine(title: "月", used: config["used_monthly"], limit: config["limit_monthly"]) + let validUntil = stringValue(config["token_valid_until"]) + lastQuotaSummary = [daily, weekly, monthly] + .filter { !$0.isEmpty } + .joined(separator: " · ") + if !validUntil.isEmpty { + lastQuotaSummary += lastQuotaSummary.isEmpty ? "有效期 \(validUntil)" : " · 有效期 \(validUntil)" + } + if lastQuotaSummary.isEmpty { + lastQuotaSummary = "暂无配额信息。" + } + } + + private func quotaLine(title: String, used: Any?, limit: Any?) -> String { + let usedValue = intValue(used) + let limitValue = intValue(limit) + guard usedValue > 0 || limitValue > 0 else { return "" } + return "\(title) \(usedValue)/\(limitValue)" + } + + private func resolveAPIBase(legacyAPIBase: String) -> String { + for key in ["VIBE_TYPELESS_API_BASE", "VIBE_API_BASE"] { + let value = normalizeAPIBase(ProcessInfo.processInfo.environment[key] ?? "") + if !value.isEmpty { return value } + } + let fallback = normalizeAPIBase(fallbackAPIBase) + if !fallback.isEmpty { return fallback } + return normalizeAPIBase(legacyAPIBase) + } + + private func normalizeAPIBase(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + while value.hasSuffix("/") { + value.removeLast() + } + if !value.isEmpty, !value.contains("://") { + value = "https://\(value)" + } + return value + } + + private func tokenIsStillValid(_ raw: Any?) -> Bool { + guard let date = parseDate(raw) else { return false } + return Date() < date + } + + private func parseDate(_ raw: Any?) -> Date? { + let value = stringValue(raw).trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss"] { + formatter.dateFormat = format + if let date = formatter.date(from: value) { return date } + } + + let iso = ISO8601DateFormatter() + if let date = iso.date(from: value) { return date } + return nil + } + + private func mergeQuota(_ quota: [String: Any], into config: inout [String: Any]) { + if let validUntil = quota["token_valid_until"] { + config["token_valid_until"] = validUntil + } + for key in ["limit_daily", "limit_weekly", "limit_monthly", "used_daily", "used_weekly", "used_monthly"] { + if let value = quota[key] { + config[key] = intValue(value) + } + } + config["quota_updated_at"] = Date().timeIntervalSince1970 + } + + private func loadConfig() -> [String: Any] { + ensureConfigFileExists() + guard let data = try? Data(contentsOf: configURL), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return defaultPayload() + } + var merged = defaultPayload() + for (key, value) in object { + merged[key] = value + } + sanitize(&merged) + return merged + } + + private func saveConfig(_ config: [String: Any]) { + var sanitized = defaultPayload() + for (key, value) in config { + sanitized[key] = value + } + sanitize(&sanitized) + do { + try FileManager.default.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: sanitized, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: configURL, options: .atomic) + } catch { + statusMessage = "AhaType 配置写入失败。" + } + } + + private func ensureConfigFileExists() { + guard !FileManager.default.fileExists(atPath: configURL.path) else { return } + saveConfig(defaultPayload()) + } + + private func sanitize(_ config: inout [String: Any]) { + for key in ["api_base", "token_balance", "typeless_balance"] { + config.removeValue(forKey: key) + } + if var user = config["user"] as? [String: Any] { + user.removeValue(forKey: "is_admin") + config["user"] = user + } + } + + private func defaultPayload() -> [String: Any] { + [ + "schema_version": 1, + "access_token": "", + "typeless_enabled": false, + "token_valid_until": NSNull(), + "limit_daily": 0, + "limit_weekly": 0, + "limit_monthly": 0, + "used_daily": 0, + "used_weekly": 0, + "used_monthly": 0, + "user": NSNull(), + ] + } + + private var configURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/VibeKeyboard", isDirectory: true) + .appendingPathComponent("typeless_config.json") + } + + private func stringValue(_ value: Any?) -> String { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number.stringValue + default: + return "" + } + } + + private func responseMessage(_ object: [String: Any]) -> String { + for key in ["errorMsg", "msg", "message", "error"] { + let value = stringValue(object[key]).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + } + return "" + } + + private func intValue(_ value: Any?) -> Int { + switch value { + case let int as Int: + return int + case let number as NSNumber: + return number.intValue + case let string as String: + return Int(string) ?? 0 + default: + return 0 + } + } + + private func boolValue(_ value: Any?) -> Bool { + switch value { + case let bool as Bool: + return bool + case let number as NSNumber: + return number.boolValue + case let string as String: + return ["1", "true", "yes", "on"].contains(string.lowercased()) + default: + return false + } + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift new file mode 100644 index 00000000..c73a3c6c --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift @@ -0,0 +1,677 @@ +import Foundation + +@MainActor +final class CloudAccountManager: ObservableObject { + static let shared = CloudAccountManager() + + @Published var phone = "" + @Published var password = "" + @Published var rememberPassword = false + @Published var couponCode = "" + @Published private(set) var isLoggedIn = false + @Published private(set) var isBusy = false + @Published private(set) var profile: [String: Any]? + @Published private(set) var paymentOrder: CloudPaymentOrder? + @Published private(set) var statusMessage = "尚未登录。" + @Published var alertMessage: String? + + private let fallbackAPIBase = "https://956798.xyz/prod-api" + private let tokenKey = "lab.jawa.ahakeyconfig.cloud.accessToken" + private let rememberKey = "lab.jawa.ahakeyconfig.cloud.remember" + private let phoneKey = "lab.jawa.ahakeyconfig.cloud.phone" + private let passwordKey = "lab.jawa.ahakeyconfig.cloud.password" + + private init() { + let defaults = UserDefaults.standard + rememberPassword = defaults.bool(forKey: rememberKey) + phone = defaults.string(forKey: phoneKey) ?? "" + if rememberPassword { + password = defaults.string(forKey: passwordKey) ?? "" + } + isLoggedIn = !accessToken.isEmpty + if isLoggedIn { + statusMessage = "已登录,等待刷新用户信息。" + } + } + + func login() { + authenticate(path: "api/v1/auth/login", successMessage: "登录成功。", fallbackError: "登录失败。") + } + + func register() { + authenticate(path: "api/v1/auth/register", successMessage: "注册成功。", fallbackError: "注册失败。") + } + + func logout() { + UserDefaults.standard.removeObject(forKey: tokenKey) + AhaTypeTextOptimizer.shared.clearSessionKeepToggle() + profile = nil + isLoggedIn = false + statusMessage = "已退出登录。" + } + + func prepareForRelogin() { + UserDefaults.standard.removeObject(forKey: tokenKey) + profile = nil + isLoggedIn = false + statusMessage = "请输入账号密码重新登录。" + } + + func refreshProfile(showAlertOnFailure: Bool = true, + forceRefresh: Bool = true, + successMessage: String = "用户信息已刷新。") { + guard !accessToken.isEmpty else { + logout() + return + } + isBusy = true + statusMessage = "正在刷新用户信息…" + Task { + defer { Task { @MainActor in self.isBusy = false } } + do { + let object = try await request( + path: cacheBustedPath("api/v1/auth/users/me", enabled: forceRefresh), + method: "GET", + body: nil, + authorized: true, + bypassCache: forceRefresh + ) + let data = try payloadData(from: object, fallbackError: "获取用户信息失败") + await MainActor.run { + self.applyProfile(data) + self.statusMessage = successMessage + } + } catch { + await MainActor.run { + if showAlertOnFailure { + self.alertMessage = error.localizedDescription + self.statusMessage = "刷新失败。" + } else { + self.statusMessage = "已登录,用户信息稍后可刷新。" + } + if showAlertOnFailure, (error as? CloudAccountError)?.statusCode == 401 { + self.logout() + } + } + } + } + } + + func redeemCoupon() { + let code = couponCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !code.isEmpty else { + alertMessage = "请输入兑换码。" + return + } + isBusy = true + statusMessage = "正在兑换免费券…" + Task { + defer { Task { @MainActor in self.isBusy = false } } + do { + let object = try await request(path: "api/v1/coupon/redeem", method: "POST", body: ["code": code], authorized: true) + let data = try payloadData(from: object, fallbackError: "兑换失败") + await MainActor.run { + self.couponCode = "" + self.applyProfile(data) + self.statusMessage = "兑换成功。" + self.alertMessage = "免费券已生效。" + } + } catch { + await MainActor.run { + self.alertMessage = error.localizedDescription + self.statusMessage = "兑换失败。" + } + } + } + } + + func createWechatOrder(plan: CloudRechargePlan) { + guard isLoggedIn else { + alertMessage = "请先登录后再充值。" + return + } + isBusy = true + statusMessage = "正在创建微信支付订单…" + Task { + defer { Task { @MainActor in self.isBusy = false } } + do { + let object = try await request( + path: "api/v1/payment/wechat/native", + method: "POST", + body: ["plan": plan.rawValue, "description": plan.orderDescription], + authorized: true + ) + let data = try payloadData(from: object, fallbackError: "创建支付订单失败") + let codeURL = firstString(in: data, keys: ["code_url", "codeUrl"]) + let h5URL = firstString(in: data, keys: ["h5_url", "h5Url", "mweb_url", "mwebUrl"]) + let outTradeNo = firstString(in: data, keys: ["out_trade_no", "outTradeNo"]) + guard !outTradeNo.isEmpty else { throw CloudAccountError("云端未返回订单号,无法查询支付状态。") } + guard !codeURL.isEmpty || !h5URL.isEmpty else { throw CloudAccountError("云端未返回可支付链接。") } + let amountFen = firstInt(in: data, keys: ["amount_fen", "amountFen"]) + await MainActor.run { + self.paymentOrder = CloudPaymentOrder( + plan: plan, + amountFen: amountFen, + outTradeNo: outTradeNo, + codeURL: codeURL, + h5URL: h5URL, + status: "pending" + ) + self.statusMessage = "订单已创建,请使用微信扫码支付。" + self.pollPaymentStatus(outTradeNo: outTradeNo) + } + } catch { + await MainActor.run { + self.alertMessage = error.localizedDescription + self.statusMessage = "创建支付订单失败。" + } + } + } + } + + func clearPaymentOrder() { + paymentOrder = nil + statusMessage = "已关闭支付订单。" + } + + func refreshCurrentPaymentOrder() { + guard let order = paymentOrder else { + refreshProfile() + return + } + isBusy = true + statusMessage = "正在查询订单状态…" + Task { + defer { Task { @MainActor in self.isBusy = false } } + do { + let status = try await fetchPaymentStatus(outTradeNo: order.outTradeNo) + await MainActor.run { + _ = self.applyPaymentStatus(status, outTradeNo: order.outTradeNo, notifyPending: true) + } + } catch { + await MainActor.run { + self.alertMessage = error.localizedDescription + self.statusMessage = "订单状态查询失败。" + } + } + } + } + + var profileSummary: String { + guard let profile else { return isLoggedIn ? "已登录,点击刷新获取用户信息。" : "登录后可启用 AhaType 云端整理。" } + let phone = stringValue(profile["phone"]) + let validUntil = stringValue(profile["token_valid_until"]) + return [ + phone.isEmpty ? "" : "手机号:\(phone)", + validUntil.isEmpty ? "有效期:无" : "有效期:\(validUntil)", + ].filter { !$0.isEmpty }.joined(separator: "\n") + } + + func quotaText(_ period: String) -> String { + guard let profile else { return "暂无" } + let used = intValue(profile["used_\(period)"]) + let limit = intValue(profile["limit_\(period)"]) + if limit <= 0 { + return used > 0 ? "已用 \(used) · 无上限" : "暂无" + } + return "\(used) / \(limit)" + } + + func priceText(for plan: CloudRechargePlan) -> String { + let fallback = plan.fallbackAmountFen + guard let prices = (profile?["policy"] as? [String: Any])?["recharge_prices_fen"] as? [String: Any] else { + return formatFen(fallback) + } + let amount = intValue(prices[plan.rawValue]) + return formatFen(amount > 0 ? amount : fallback) + } + + private func pollPaymentStatus(outTradeNo: String) { + Task { + let deadline = Date().addingTimeInterval(180) + while Date() < deadline { + try? await Task.sleep(nanoseconds: 2_000_000_000) + if self.paymentOrder?.outTradeNo != outTradeNo { return } + do { + let status = try await fetchPaymentStatus(outTradeNo: outTradeNo) + let finished = await MainActor.run { + self.applyPaymentStatus(status, outTradeNo: outTradeNo, notifyPending: false) + } + if finished { + return + } + } catch { + // 轮询中允许单次失败,避免网络抖动中断支付流程。 + continue + } + } + await MainActor.run { + if self.paymentOrder?.outTradeNo == outTradeNo { + self.statusMessage = "等待支付超时,可稍后刷新用户信息确认到账。" + } + } + } + } + + private func fetchPaymentStatus(outTradeNo: String) async throws -> String { + let encoded = outTradeNo.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? outTradeNo + let path = cacheBustedPath("api/v1/payment/wechat/order-status?outTradeNo=\(encoded)", enabled: true) + let object = try await request( + path: path, + method: "GET", + body: nil, + authorized: true, + bypassCache: true + ) + let data = try payloadData(from: object, fallbackError: "查询订单状态失败") + return normalizedPaymentStatus(from: data) + } + + @discardableResult + private func applyPaymentStatus(_ status: String, outTradeNo: String, notifyPending: Bool) -> Bool { + let normalized = status.isEmpty ? "pending" : status + if var order = paymentOrder, order.outTradeNo == outTradeNo { + order.status = normalized + paymentOrder = order + } + if isPaidPaymentStatus(normalized) { + statusMessage = "充值成功,正在刷新额度。" + paymentOrder = nil + refreshProfile( + forceRefresh: true, + successMessage: "充值到账已刷新。" + ) + return true + } + if isFailedPaymentStatus(normalized) { + statusMessage = "订单支付失败。" + alertMessage = "订单已标记为失败,请重新发起充值。" + return true + } + statusMessage = "订单尚未到账,请稍后再刷新。" + if notifyPending { + alertMessage = "当前订单仍未到账,请确认微信支付已完成后再刷新。" + } + return false + } + + private func authenticate(path: String, successMessage: String, fallbackError: String) { + let p = phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard !p.isEmpty, !password.isEmpty else { + alertMessage = "请输入手机号和密码。" + return + } + isBusy = true + statusMessage = "正在请求云端账号…" + Task { + defer { Task { @MainActor in self.isBusy = false } } + do { + let object = try await request(path: path, method: "POST", body: ["phone": p, "password": password], authorized: false) + let data = try payloadData(from: object, fallbackError: fallbackError) + let token = firstString(in: data, keys: ["access_token", "token"]) + guard !token.isEmpty else { throw CloudAccountError("云端未返回 access_token。") } + await MainActor.run { + self.saveLogin(token: token, authData: data) + self.statusMessage = successMessage + } + await MainActor.run { + self.refreshProfile(showAlertOnFailure: false) + } + } catch { + await MainActor.run { + self.alertMessage = error.localizedDescription + self.statusMessage = "账号请求失败。" + } + } + } + } + + private func saveLogin(token: String, authData: [String: Any] = [:]) { + let defaults = UserDefaults.standard + defaults.set(token, forKey: tokenKey) + defaults.set(rememberPassword, forKey: rememberKey) + defaults.set(phone.trimmingCharacters(in: .whitespacesAndNewlines), forKey: phoneKey) + if rememberPassword { + defaults.set(password, forKey: passwordKey) + } else { + defaults.removeObject(forKey: passwordKey) + } + AhaTypeTextOptimizer.shared.patchCloudToken(token) + seedLocalProfile(token: token, authData: authData) + isLoggedIn = true + } + + private func applyProfile(_ profile: [String: Any]) { + let normalized = normalizedProfile(profile) + self.profile = normalized + isLoggedIn = true + AhaTypeTextOptimizer.shared.patchCloudToken(accessToken) + AhaTypeTextOptimizer.shared.setUserProfile(normalized) + } + + private func seedLocalProfile(token: String, authData: [String: Any]) { + var profile = normalizedProfile(authData) + let phoneValue = firstString(in: authData, keys: ["phone", "mobile", "username"]) + profile["phone"] = phoneValue.isEmpty ? phone.trimmingCharacters(in: .whitespacesAndNewlines) : phoneValue + let userID = firstString(in: authData, keys: ["id", "user_id", "userId"]) + if !userID.isEmpty { + profile["user_id"] = userID + profile["id"] = userID + } + if let validUntil = jwtExpirationString(token) { + profile["token_valid_until"] = validUntil + } + profile["limit_daily"] = firstInt(in: authData, keys: ["limit_daily", "limitDaily"]) + profile["limit_weekly"] = firstInt(in: authData, keys: ["limit_weekly", "limitWeekly"]) + profile["limit_monthly"] = firstInt(in: authData, keys: ["limit_monthly", "limitMonthly"]) + profile["used_daily"] = firstInt(in: authData, keys: ["used_daily", "usedDaily"]) + profile["used_weekly"] = firstInt(in: authData, keys: ["used_weekly", "usedWeekly"]) + profile["used_monthly"] = firstInt(in: authData, keys: ["used_monthly", "usedMonthly"]) + self.profile = profile + AhaTypeTextOptimizer.shared.setUserProfile(profile) + } + + private func normalizedProfile(_ raw: [String: Any]) -> [String: Any] { + var profile = raw + let aliases: [(String, String)] = [ + ("id", "userId"), + ("user_id", "userId"), + ("token_valid_until", "tokenValidUntil"), + ("limit_daily", "limitDaily"), + ("limit_weekly", "limitWeekly"), + ("limit_monthly", "limitMonthly"), + ("used_daily", "usedDaily"), + ("used_weekly", "usedWeekly"), + ("used_monthly", "usedMonthly"), + ] + for (snake, camel) in aliases where profile[snake] == nil { + if let value = raw[camel] { + profile[snake] = value + } + } + if stringValue(profile["token_valid_until"]).isEmpty, let validUntil = jwtExpirationString(accessToken) { + profile["token_valid_until"] = validUntil + } + if var policy = profile["policy"] as? [String: Any] { + let policyAliases: [(String, String)] = [ + ("recharge_prices_fen", "rechargePricesFen"), + ("default_limit_daily", "defaultLimitDaily"), + ("default_limit_weekly", "defaultLimitWeekly"), + ("default_limit_monthly", "defaultLimitMonthly"), + ("enable_daily", "enableDaily"), + ("enable_weekly", "enableWeekly"), + ("enable_monthly", "enableMonthly"), + ] + for (snake, camel) in policyAliases where policy[snake] == nil { + if let value = policy[camel] { + policy[snake] = value + } + } + profile["policy"] = policy + } + return profile + } + + private func request(path: String, + method: String, + body: [String: Any]?, + authorized: Bool, + bypassCache: Bool = false) async throws -> [String: Any] { + guard let url = URL(string: "\(apiBase)/\(path)") else { + throw CloudAccountError("云端地址无效。") + } + var request = URLRequest(url: url, timeoutInterval: 90) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if bypassCache { + request.cachePolicy = .reloadIgnoringLocalCacheData + request.setValue("no-cache, no-store, max-age=0", forHTTPHeaderField: "Cache-Control") + request.setValue("no-cache", forHTTPHeaderField: "Pragma") + } + if authorized { + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + if let body { + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) + } + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw CloudAccountError(networkMessage(for: error)) + } + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CloudAccountError("服务器返回非 JSON。", statusCode: statusCode) + } + if statusCode != 200 { + throw CloudAccountError(responseMessage(object).isEmpty ? "请求失败(HTTP \(statusCode))。" : responseMessage(object), statusCode: statusCode) + } + return object + } + + private func cacheBustedPath(_ path: String, enabled: Bool) -> String { + guard enabled else { return path } + let separator = path.contains("?") ? "&" : "?" + return "\(path)\(separator)_refresh=\(UUID().uuidString)" + } + + private func payloadData(from object: [String: Any], fallbackError: String) throws -> [String: Any] { + let code = intValue(object["code"]) + guard code == 0 || code == 200 else { + let msg = responseMessage(object) + throw CloudAccountError(msg.isEmpty ? fallbackError : msg) + } + return object["data"] as? [String: Any] ?? [:] + } + + private var accessToken: String { + UserDefaults.standard.string(forKey: tokenKey) ?? "" + } + + private var apiBase: String { + for key in ["VIBE_TYPELESS_API_BASE", "VIBE_API_BASE"] { + let v = normalizeAPIBase(ProcessInfo.processInfo.environment[key] ?? "") + if !v.isEmpty { return v } + } + return normalizeAPIBase(fallbackAPIBase) + } + + private func normalizeAPIBase(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + while value.hasSuffix("/") { value.removeLast() } + if !value.isEmpty, !value.contains("://") { + value = "https://\(value)" + } + return value + } + + private func stringValue(_ value: Any?) -> String { + switch value { + case let string as String: return string + case let number as NSNumber: return number.stringValue + default: return "" + } + } + + private func firstString(in object: [String: Any], keys: [String]) -> String { + for key in keys { + let value = stringValue(object[key]).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + } + return "" + } + + private func firstInt(in object: [String: Any], keys: [String]) -> Int { + for key in keys { + let value = intValue(object[key]) + if value != 0 { return value } + } + return 0 + } + + private func normalizedPaymentStatus(from data: [String: Any]) -> String { + firstString(in: data, keys: ["status", "tradeState", "trade_state", "payStatus", "pay_status", "orderStatus", "order_status"]) + .lowercased() + .replacingOccurrences(of: "-", with: "_") + } + + private func isPaidPaymentStatus(_ status: String) -> Bool { + let normalized = status.lowercased().replacingOccurrences(of: "-", with: "_") + return [ + "paid", + "success", + "succeeded", + "complete", + "completed", + "pay_success", + "trade_success", + "wechat_success", + "finished", + "done", + "1", + ].contains(normalized) + } + + private func isFailedPaymentStatus(_ status: String) -> Bool { + let normalized = status.lowercased().replacingOccurrences(of: "-", with: "_") + return [ + "failed", + "failure", + "fail", + "closed", + "cancelled", + "canceled", + "expired", + "timeout", + "trade_closed", + "pay_error", + "2", + ].contains(normalized) + } + + private func responseMessage(_ object: [String: Any]) -> String { + firstString(in: object, keys: ["errorMsg", "msg", "message", "error"]) + } + + private func jwtExpirationString(_ token: String) -> String? { + let parts = token.split(separator: ".") + guard parts.count >= 2 else { return nil } + var payload = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = payload.count % 4 + if remainder > 0 { + payload += String(repeating: "=", count: 4 - remainder) + } + guard let data = Data(base64Encoded: payload), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let exp = Double(intValue(object["exp"])) + guard exp > 0 else { return nil } + let date = Date(timeIntervalSince1970: exp) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.string(from: date) + } + + private func intValue(_ value: Any?) -> Int { + switch value { + case let int as Int: return int + case let number as NSNumber: return number.intValue + case let string as String: return Int(string) ?? 0 + default: return 0 + } + } + + private func formatFen(_ fen: Int) -> String { + String(format: "%.2f 元", Double(max(0, fen)) / 100.0) + } + + private func networkMessage(for error: Error) -> String { + guard let urlError = error as? URLError else { + return "云端连接失败:\(error.localizedDescription)" + } + switch urlError.code { + case .secureConnectionFailed, .serverCertificateHasBadDate, .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, .clientCertificateRejected, .clientCertificateRequired: + return "云端连接失败:TLS/SSL 校验未通过,请检查系统时间、网络代理/证书,或确认云端 HTTPS 证书配置正常。" + case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .timedOut: + return "云端连接失败:当前网络无法访问 AhaType 服务,请检查网络后重试。" + default: + return "云端连接失败:\(urlError.localizedDescription)" + } + } +} + +struct CloudAccountError: LocalizedError { + let message: String + let statusCode: Int? + + init(_ message: String, statusCode: Int? = nil) { + self.message = message + self.statusCode = statusCode + } + + var errorDescription: String? { message } +} + +enum CloudRechargePlan: String, CaseIterable, Identifiable { + case monthly + case quarterly + case yearly + + var id: String { rawValue } + + var title: String { + switch self { + case .monthly: return "按月订阅" + case .quarterly: return "按季订阅" + case .yearly: return "按年订阅" + } + } + + var subtitle: String { + switch self { + case .monthly: return "30 天" + case .quarterly: return "90 天" + case .yearly: return "365 天" + } + } + + var orderDescription: String { + switch self { + case .monthly: return "包月充值" + case .quarterly: return "包季充值" + case .yearly: return "包年充值" + } + } + + var fallbackAmountFen: Int { + switch self { + case .monthly: return 100 + case .quarterly: return 270 + case .yearly: return 999 + } + } +} + +struct CloudPaymentOrder: Equatable { + let plan: CloudRechargePlan + let amountFen: Int + let outTradeNo: String + let codeURL: String + let h5URL: String + var status: String + + var paymentURL: String { + codeURL.isEmpty ? h5URL : codeURL + } + + var amountText: String { + String(format: "%.2f 元", Double(max(0, amountFen)) / 100.0) + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift b/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift new file mode 100644 index 00000000..753e1917 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift @@ -0,0 +1,76 @@ +import Foundation + +/// 本地开发专用:调用 scripts/fix-debug-permissions.sh, +/// 用稳定自签证书重签 dist/AhaKey Studio.app 并重置 TCC 权限。 +/// +/// 识别"本地开发环境"的方式:检查 app bundle 兄弟目录里是否存在 +/// scripts/fix-debug-permissions.sh。只有从源码构建的 dev build +/// 才具备这一条件 —— 正式发布到 /Applications 的 .app 没有兄弟 +/// scripts/ 目录,`isAvailable` 会返回 false,UI 不会显示按钮。 +/// +/// 这样不依赖 `#if DEBUG` 宏,任何构建配置下代码都被编译, +/// 但只在开发环境下真正对外暴露入口。 +enum DebugSigningFixer { + struct Result { + let success: Bool + let output: String + } + + /// 仅当能定位到源码中的修复脚本时才视为"可用"。 + /// 这就是"开发环境 vs 已安装发行版"的运行时区分。 + static var isAvailable: Bool { + FileManager.default.isExecutableFile(atPath: scriptURL.path) + } + + private static var scriptURL: URL { + URL(fileURLWithPath: Bundle.main.bundlePath) + .deletingLastPathComponent() // .../dist/ 或 安装目录 + .deletingLastPathComponent() // .../ 项目根 或 /Applications + .appendingPathComponent("scripts") + .appendingPathComponent("fix-debug-permissions.sh") + } + + static func run(completion: @escaping (Result) -> Void) { + let script = scriptURL + guard FileManager.default.isExecutableFile(atPath: script.path) else { + completion(Result( + success: false, + output: "找不到可执行脚本:\(script.path)\n\n此功能仅在从源码运行的开发构建中可用。" + )) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [script.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let ok = process.terminationStatus == 0 + DispatchQueue.main.async { + completion(Result( + success: ok, + output: ok + ? output + "\n请立即退出 AhaKey Studio 并重新启动,按系统提示重新勾选权限即可。" + : "脚本执行失败 (exit=\(process.terminationStatus))\n\n\(output)" + )) + } + } catch { + DispatchQueue.main.async { + completion(Result( + success: false, + output: "无法启动修复脚本: \(error.localizedDescription)" + )) + } + } + } + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift b/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift new file mode 100644 index 00000000..bf419616 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift @@ -0,0 +1,42 @@ +import Foundation + +/// 访问 app bundle 内置的默认 LCD 素材。 +/// 资源由 scripts/build-debug.sh 从项目根的 Resources/DefaultOLED/ 拷贝到 +/// AhaKey Studio.app/Contents/Resources/DefaultOLED/。 +enum DefaultOLEDAssets { + private static let subdirectory = "DefaultOLED" + + /// 每个 Mode 在工程里预置的出厂 GIF 文件名(不带扩展名)。 + /// 没有内置素材的 Mode 返回 nil,走用户自定义或固件端默认动图。 + static func bundledFileName(for mode: AhaKeyModeSlot) -> String? { + switch mode { + case .mode0: + return "claude_0" + case .mode1: + return "cursor" + case .mode2: + return "codex" + case .mode3: + return nil + } + } + + /// 解析出 bundle 内该 GIF 的绝对文件路径;资源不存在时返回 nil。 + static func bundledAssetPath(for mode: AhaKeyModeSlot) -> String? { + guard let name = bundledFileName(for: mode) else { return nil } + return bundledAssetPath(forName: name) + } + + /// 按名字查找 bundle 里的 .gif,返回绝对路径。 + static func bundledAssetPath(forName name: String) -> String? { + Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: subdirectory)?.path + } + + /// 判断一个 localAssetPath 是否指向某个 bundle 内置素材。 + /// 迁移逻辑用:当用户的草稿引用已失效的 bundle 路径(比如换 app 位置、换 mode)时可以安全重写。 + static func isBundledPath(_ path: String) -> Bool { + guard let resourcesURL = Bundle.main.resourceURL else { return false } + let resourcesPath = resourcesURL.appendingPathComponent(subdirectory).path + return path.hasPrefix(resourcesPath + "/") + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift new file mode 100644 index 00000000..c7191f81 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift @@ -0,0 +1,804 @@ +import AppKit +import AVFoundation +import ApplicationServices +import Foundation +import Speech + +@MainActor +final class NativeSpeechTranscriptionService: ObservableObject { + static let shared = NativeSpeechTranscriptionService() + + @Published private(set) var microphoneGranted = false + @Published private(set) var speechRecognitionGranted = false + @Published private(set) var siriEnabled = false + @Published private(set) var dictationEnabled = false + @Published private(set) var isRecording = false + @Published private(set) var statusMessage = "等待苹果原生转写就绪。" + @Published private(set) var transcriptPreview = "" + @Published private(set) var lastCommittedText = "" + @Published private(set) var lastPermissionCheckSummary = "尚未检查麦克风、语音转写与 Siri 权限。" + + // MARK: 录音触发方式配置 + /// 短按(切换式):录音结束后是否调用 AhaType 整理 + @Published var shortPressAhaTypeEnabled: Bool = UserDefaults.standard.object(forKey: "nativeSpeech.shortPressAhaType") as? Bool ?? true { + didSet { UserDefaults.standard.set(shortPressAhaTypeEnabled, forKey: "nativeSpeech.shortPressAhaType") } + } + /// 长按模式(按住录音,松手发送)始终开启,不再由用户关闭 + @Published var longPressEnabled: Bool = true + /// 长按模式结束后是否调用 AhaType(默认关闭:快速直发) + @Published var longPressAhaTypeEnabled: Bool = UserDefaults.standard.object(forKey: "nativeSpeech.longPressAhaType") as? Bool ?? false { + didSet { UserDefaults.standard.set(longPressAhaTypeEnabled, forKey: "nativeSpeech.longPressAhaType") } + } + /// 长按判定阈值(毫秒) + @Published var longPressThresholdMs: Int = UserDefaults.standard.object(forKey: "nativeSpeech.longPressThresholdMs") as? Int ?? 500 { + didSet { UserDefaults.standard.set(longPressThresholdMs, forKey: "nativeSpeech.longPressThresholdMs") } + } + + /// 当前是否处于长按录音模式(按住中,松手会直接发送) + @Published private(set) var isLongPressRecording = false + + private var longPressTimerWork: DispatchWorkItem? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var finalizeWorkItem: DispatchWorkItem? + private var currentTranscript = "" + /// 防止 `isFinal`、1s 超时、`error` 回调各触发一次,导致同一段被 ⌘V 多遍 + private var hasCommittedThisRecording = false + + + private let syntheticEventUserData: Int64 = 0x4148414B + + private init() { } + + func start() { + AhaTypeTextOptimizer.shared.refreshFromDisk() + refreshPermissions(requestIfNeeded: false) + } + + /// - Parameter deferredTCCRequery: 与 `VoiceRelayService` 一致:用户点「重新检查」时延后一拍再读,避免 TCC 状态未刷新时界面像「没反应」。 + func refreshPermissions(requestIfNeeded: Bool = false, deferredTCCRequery: Bool = false) { + if requestIfNeeded { + performPermissionRead(requestIfNeeded: true) + return + } + if deferredTCCRequery { + lastPermissionCheckSummary = "正在检查麦克风与语音转写权限…" + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(450) * 1_000_000) + self.performPermissionRead(requestIfNeeded: false) + if !self.microphoneGranted || !self.speechRecognitionGranted { + try? await Task.sleep(nanoseconds: UInt64(800) * 1_000_000) + self.performPermissionRead(requestIfNeeded: false) + } + } + return + } + performPermissionRead(requestIfNeeded: false) + } + + private func performPermissionRead(requestIfNeeded: Bool) { + let currentMicGranted = Self.isMicrophoneGranted() + + let currentSpeechStatus = SFSpeechRecognizer.authorizationStatus() + let currentSpeechGranted = currentSpeechStatus == .authorized + let currentSiriEnabled = Self.readBooleanPreference( + domain: "com.apple.assistant.support", + key: "Assistant Enabled" + ) ?? false + let currentDictationEnabled = Self.readBooleanPreference( + domain: "com.apple.assistant.support", + key: "Dictation Enabled" + ) ?? Self.readBooleanPreference( + domain: "com.apple.HIToolbox", + key: "AppleDictationAutoEnable" + ) ?? false + + if requestIfNeeded { + if Self.isMicrophoneUndetermined() { + // 先弹麦克风,用户响应后再检查语音识别,避免两个弹框同时排队、顺序混乱 + Self.requestMicrophoneAccess { + Task { @MainActor in + self.refreshPermissions() + if SFSpeechRecognizer.authorizationStatus() == .notDetermined { + SFSpeechRecognizer.requestAuthorization { _ in + Task { @MainActor in self.refreshPermissions() } + } + } + } + } + return + } + + if currentSpeechStatus == .notDetermined { + SFSpeechRecognizer.requestAuthorization { _ in + Task { @MainActor in + self.refreshPermissions() + } + } + } + } + + let timeLabel = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + microphoneGranted = currentMicGranted + speechRecognitionGranted = currentSpeechGranted + siriEnabled = currentSiriEnabled + dictationEnabled = currentDictationEnabled + lastPermissionCheckSummary = + "麦克风 \(currentMicGranted ? "已开启" : "未开启") · 语音转写 \(currentSpeechGranted ? "已开启" : "未开启") · Siri \(currentSiriEnabled ? "已开启" : "未开启") · 听写 \(currentDictationEnabled ? "已开启" : "未开启") · 检查于 \(timeLabel)" + + if !currentMicGranted || !currentSpeechGranted || !currentSiriEnabled || !currentDictationEnabled { + statusMessage = "还缺苹果原生语音权限,请先打开麦克风、语音转写、Siri 与听写。" + } else if !isRecording { + statusMessage = "苹果原生转写已就绪,按一次语音键开始,再按一次结束。" + } + + appendDiagnostic("permissions mic=\(currentMicGranted) speech=\(currentSpeechGranted) siri=\(currentSiriEnabled) dictation=\(currentDictationEnabled)") + } + + // MARK: - 语音键事件入口(VoiceRelayService 调用) + + /// keyDown 时调用:若长按模式启用,开启长按计时器;否则立即开始录音或等 keyUp 切换。 + func handleVoiceKeyDown() { + if longPressEnabled, !isRecording { + // 启动长按计时:阈值内松开 → 短按;超时后仍按着 → 进入长按录音 + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.longPressTimerWork = nil + if !self.isRecording { + self.isLongPressRecording = true + self.startRecording() + self.appendDiagnostic("long press threshold reached → start long press recording") + } + } + longPressTimerWork = work + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(longPressThresholdMs) / 1000, + execute: work + ) + } else if !longPressEnabled { + // 无长按:keyDown 直接切换(兼容旧行为) + toggleRecordingFromVoiceKey() + } + // 若 longPressEnabled 且已在录音中,keyDown 不做任何事,等 keyUp 判断 + } + + /// keyUp 时调用:若长按模式活跃 → 结束并直接发送;否则短按切换。 + func handleVoiceKeyUp() { + if isLongPressRecording { + // 长按录音结束:停止并按长按配置决定是否用 AhaType + isLongPressRecording = false + longPressTimerWork?.cancel() + longPressTimerWork = nil + appendDiagnostic("long press key up → stop + \(longPressAhaTypeEnabled ? "ahatype" : "direct")") + stopRecording(bypassAhaType: !longPressAhaTypeEnabled) + return + } + + if let work = longPressTimerWork { + // 计时器还没触发 → 短按,取消计时并切换录音 + work.cancel() + longPressTimerWork = nil + appendDiagnostic("short press (keyUp before threshold) → toggle") + if isRecording { + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } else { + startRecording() + } + } else if longPressEnabled, isRecording { + // 长按模式开启时,短按第一次已进入切换式录音;第二次短按没有 timer, + // 仍应按短按配置结束录音,保持“按一次开始,再按一次结束”的体验。 + appendDiagnostic("short press while recording → stop") + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } else if !longPressEnabled { + // 无长按模式:keyDown 已处理,keyUp 不重复 + } + } + + func toggleRecordingFromVoiceKey() { + if isRecording { + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } else { + startRecording() + } + } + + func stopRecording() { + stopRecording(bypassAhaType: !shortPressAhaTypeEnabled) + } + + func requestMicrophonePermission() { + if Self.isMicrophoneUndetermined() { + Self.requestMicrophoneAccess { + Task { @MainActor in + self.refreshPermissions() + // macOS 26 上 requestRecordPermission 可能静默返回、不弹窗; + // 若 completion 回来权限仍是 undetermined,说明系统没有显示弹框, + // 直接引导用户去系统设置手动开启。 + if Self.isMicrophoneUndetermined() { + self.openMicrophoneSystemSettings() + } + } + } + } else if Self.isMicrophoneDenied() { + Task { @MainActor in + self.attemptResetAndRequestMicrophonePermission() + } + } else { + refreshPermissions() + } + } + + private func openMicrophoneSystemSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")! + NSWorkspace.shared.open(url) + } + + private func attemptResetAndRequestMicrophonePermission() { + let alert = NSAlert() + alert.messageText = "麦克风权限已被拒绝" + alert.informativeText = "需要重置麦克风权限才能重新授权。" + alert.addButton(withTitle: "重置并授权") + alert.addButton(withTitle: "打开系统设置") + alert.addButton(withTitle: "取消") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + DispatchQueue.global(qos: .userInitiated).async { + PermissionSignatureChecker.resetMicrophonePermission { success, message in + DispatchQueue.main.async { + if success { + // 重置成功后直接重新请求,TCC 记录已清空无需重启 + Self.requestMicrophoneAccess { + Task { @MainActor in + self.refreshPermissions() + // 若弹框未出现(macOS 26 静默返回),直接打开系统设置 + if !Self.isMicrophoneGranted() { + self.openMicrophoneSystemSettings() + } + } + } + } else { + // tccutil 失败(SIP 开启时普通进程无权限),引导到系统设置 + print("[NativeSpeech] tccutil reset failed: \(message)") + self.openMicrophoneSystemSettings() + } + } + } + } + } else if response == .alertSecondButtonReturn { + openMicrophoneSystemSettings() + } + } + + func requestSpeechRecognitionPermission() { + let status = SFSpeechRecognizer.authorizationStatus() + appendDiagnostic("requestSpeechRecognitionPermission status=\(status.rawValue)") + switch status { + case .notDetermined: + SFSpeechRecognizer.requestAuthorization { _ in + Task { @MainActor in + self.refreshPermissions() + } + } + case .denied, .restricted: + Task { @MainActor in + self.attemptResetAndRequestSpeechRecognitionPermission() + } + default: + refreshPermissions() + } + } + + private func attemptResetAndRequestSpeechRecognitionPermission() { + let bundleId = Bundle.main.bundleIdentifier ?? "lab.jawa.ahakeyconfig" + appendDiagnostic("attemptResetAndRequestSpeechRecognition bundleId=\(bundleId)") + + let alert = NSAlert() + alert.messageText = "语音识别权限已被拒绝" + alert.informativeText = "需要重置语音识别权限才能继续。点击「重置」后需重启应用才能重新授权。" + alert.addButton(withTitle: "重置并重启") + alert.addButton(withTitle: "打开系统设置") + alert.addButton(withTitle: "取消") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + DispatchQueue.global(qos: .userInitiated).async { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + task.arguments = ["reset", "SpeechRecognition", bundleId] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + do { + try task.run() + task.waitUntilExit() + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + print("[NativeSpeech] tccutil reset SpeechRecognition status=\(task.terminationStatus) output=\(output)") + } catch { + print("[NativeSpeech] tccutil reset SpeechRecognition error=\(error.localizedDescription)") + } + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + } else if response == .alertSecondButtonReturn { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition") { + NSWorkspace.shared.open(url) + } + } + } + + func stopRecording(bypassAhaType: Bool) { + guard isRecording else { return } + isRecording = false + statusMessage = "正在结束录音并整理文字…" + VoiceStatusHUDController.shared.show(.recognizing) + pendingFinalizeBypassAhaType = bypassAhaType + appendDiagnostic("stop recording requested bypassAhaType=\(bypassAhaType)") + + finalizeWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor in + self?.finalizeCurrentTranscriptIfNeeded(reason: "timeout_finalize", bypassAhaType: bypassAhaType) + } + } + finalizeWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) + + audioEngine?.stop() + audioEngine?.inputNode.removeTap(onBus: 0) + recognitionRequest?.endAudio() + } + + private func startRecording() { + guard microphoneGranted, speechRecognitionGranted, siriEnabled, dictationEnabled else { + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + let speechStatus = SFSpeechRecognizer.authorizationStatus() + refreshPermissions(requestIfNeeded: true) + statusMessage = missingPermissionMessage( + micStatus: micStatus, + speechStatus: speechStatus, + siriEnabled: siriEnabled, + dictationEnabled: dictationEnabled + ) + appendDiagnostic("blocked start recording micStatus=\(micStatus.rawValue) speechStatus=\(speechStatus.rawValue) siri=\(siriEnabled) dictation=\(dictationEnabled)") + if !VoiceRelayService.shared.isPermissionOnboardingSuppressed { + VoiceRelayService.shared.showsPermissionOnboarding = true + } + NSApp.activate(ignoringOtherApps: true) + return + } + + guard let recognizer = makeSpeechRecognizer() else { + statusMessage = "当前系统语言暂不支持苹果原生转写。" + appendDiagnostic("speech recognizer unavailable") + return + } + + cancelRecognitionPipeline() + currentTranscript = "" + transcriptPreview = "" + lastCommittedText = "" + hasCommittedThisRecording = false + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + + let engine = AVAudioEngine() + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.recognitionRequest?.append(buffer) + } + + do { + engine.prepare() + try engine.start() + } catch { + inputNode.removeTap(onBus: 0) + statusMessage = "无法启动麦克风录音。" + appendDiagnostic("audio engine start failed: \(error.localizedDescription)") + return + } + + audioEngine = engine + recognitionRequest = request + isRecording = true + statusMessage = "苹果原生转写录音中… 再按一次语音键结束。" + VoiceStatusHUDController.shared.show(.recording) + appendDiagnostic("start recording locale=\(recognizer.locale.identifier)") + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + Task { @MainActor in + self.handleRecognition(result: result, error: error) + } + } + } + + /// 流式 + 同一段录音里停顿后续说:多数帧里 `formattedString` 是「从本段开录至今的整段」; + /// 若用英文空格去拼两段中文,或把「同一句的改判」与「下一段整句」都旧+新硬接,就会叠出很多遍。 + /// 结束提交:另见 `hasCommittedThisRecording`。 + private func applyStreamingTranscriptionPartial(_ newRaw: String) { + let newT = newRaw.trimmingCharacters(in: .whitespacesAndNewlines) + if newT.isEmpty { return } + + let oldT = currentTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + if oldT.isEmpty { + currentTranscript = newT + return + } + if newT == oldT { return } + if newT.hasPrefix(oldT) { + currentTranscript = newT + return + } + if oldT.hasPrefix(newT) { + return + } + if newT.contains(oldT), newT.count > oldT.count { + currentTranscript = newT + return + } + if oldT.contains(newT) { + return + } + if let merged = Self.mergeByTailHeadOverlap(prior: oldT, next: newT) { + currentTranscript = merged + return + } + if Self.commonPrefixLength(oldT, newT) >= 3 { + currentTranscript = newT + return + } + if newT.count <= 6, let a = oldT.last, let b = newT.first, Self.isCJK(a), Self.isCJK(b) { + currentTranscript = oldT + newT + return + } + if let last = oldT.last, last == "。" || last == "!" || last == "?" { + if let b = newT.first, Self.isCJK(b) { + currentTranscript = oldT + newT + return + } + } + // 不盲拼长串;以本次整段假设为准 + currentTranscript = newT + } + + private static func commonPrefixLength(_ a: String, _ b: String) -> Int { + var n = 0 + for (x, y) in zip(a, b) { + if x == y { n += 1 } else { break } + } + return n + } + + private static func mergeByTailHeadOverlap(prior: String, next: String) -> String? { + if prior.isEmpty { return next } + if next.isEmpty { return prior } + let maxK = min(prior.count, next.count) + guard maxK > 0 else { return nil } + for k in stride(from: maxK, through: 1, by: -1) { + if String(prior.suffix(k)) == String(next.prefix(k)) { + return prior + next.dropFirst(k) + } + } + return nil + } + + private static func isCJK(_ ch: Character) -> Bool { + for s in ch.unicodeScalars { + let v = s.value + if (0x4E00 ... 0x9FFF).contains(v) { return true } + if (0x3400 ... 0x4DBF).contains(v) { return true } + if (0x3000 ... 0x303F).contains(v) { return true } + } + return false + } + + private var pendingFinalizeBypassAhaType = false + + private func handleRecognition(result: SFSpeechRecognitionResult?, error: Error?) { + if let result { + let newText = result.bestTranscription.formattedString + // 流式结果:同一句会以前缀方式变长,直接取 new 即可;中间停顿后系统可能只返回 + // 新一段文字(不含前句),再整串赋值会顶掉前句——须按前缀关系合并,否则拼接。 + if !newText.isEmpty { + applyStreamingTranscriptionPartial(newText) + transcriptPreview = currentTranscript + } + appendDiagnostic("partial result=\(newText) isFinal=\(result.isFinal)") + if result.isFinal { + finalizeCurrentTranscriptIfNeeded(reason: "final_result", bypassAhaType: pendingFinalizeBypassAhaType) + return + } + } + + if let error { + appendDiagnostic("recognition error: \(error.localizedDescription)") + if !currentTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + finalizeCurrentTranscriptIfNeeded(reason: "error_with_text", bypassAhaType: pendingFinalizeBypassAhaType) + } else { + cancelRecognitionPipeline() + statusMessage = "苹果原生转写失败:\(error.localizedDescription)" + VoiceStatusHUDController.shared.show( + VoiceStatusHUDState(kind: .warning, title: "识别失败", subtitle: "请重试或检查语音权限"), + autoHideAfter: 2.0 + ) + } + } + } + + private func finalizeCurrentTranscriptIfNeeded(reason: String, bypassAhaType: Bool = false) { + finalizeWorkItem?.cancel() + finalizeWorkItem = nil + + if hasCommittedThisRecording { + appendDiagnostic("skip duplicate finalize reason=\(reason)") + return + } + + let text = currentTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + cancelRecognitionPipeline() + + guard !text.isEmpty else { + statusMessage = "未识别到有效语音内容。" + VoiceStatusHUDController.shared.show(.empty, autoHideAfter: 1.8) + appendDiagnostic("finalize empty reason=\(reason)") + return + } + + hasCommittedThisRecording = true + let willUseAhaType = !bypassAhaType && AhaTypeTextOptimizer.shared.isEnabled + statusMessage = willUseAhaType ? "AhaType 整理中…" : "准备粘贴…" + VoiceStatusHUDController.shared.show(willUseAhaType ? .ahaType : .pasting) + appendDiagnostic("finalize begin reason=\(reason) bypass=\(bypassAhaType) rawText=\(text)") + + Task { @MainActor in + let output: String + if willUseAhaType { + output = await AhaTypeTextOptimizer.shared.processIfEnabled(text) + } else { + output = text + } + if self.injectText(output) { + self.lastCommittedText = output + self.statusMessage = output == text ? "已写入:\(output)" : "AhaType 已整理并写入:\(output)" + VoiceStatusHUDController.shared.show(.done, autoHideAfter: 1.4) + self.appendDiagnostic("finalize success reason=\(reason) rawText=\(text) outputText=\(output)") + } else { + self.statusMessage = "识别完成,但写入当前光标失败。" + VoiceStatusHUDController.shared.show(.failed, autoHideAfter: 2.0) + self.appendDiagnostic("finalize inject failed reason=\(reason) text=\(output)") + } + } + } + + private func cancelRecognitionPipeline() { + audioEngine?.stop() + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine = nil + + recognitionRequest?.endAudio() + recognitionRequest = nil + + recognitionTask?.cancel() + recognitionTask = nil + isRecording = false + } + + private func makeSpeechRecognizer() -> SFSpeechRecognizer? { + if let preferredIdentifier = Locale.preferredLanguages.first { + let locale = Locale(identifier: preferredIdentifier) + if let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable { + return recognizer + } + } + + if let recognizer = SFSpeechRecognizer(), recognizer.isAvailable { + return recognizer + } + + return nil + } + + private func missingPermissionMessage( + micStatus: AVAuthorizationStatus, + speechStatus: SFSpeechRecognizerAuthorizationStatus, + siriEnabled: Bool, + dictationEnabled: Bool + ) -> String { + var missing: [String] = [] + if micStatus != .authorized { + missing.append("麦克风") + } + if speechStatus != .authorized { + missing.append("语音转写") + } + if !siriEnabled { + missing.append("Siri") + } + if !dictationEnabled { + missing.append("听写") + } + return "还缺\(missing.joined(separator: "、"))权限。请先在系统设置里打开后,再按一次语音键。" + } + + // MARK: - 麦克风权限辅助(macOS 14+ 用 AVAudioApplication,旧系统回退 AVCaptureDevice) + + private static func isMicrophoneGranted() -> Bool { + if #available(macOS 14.0, *) { + return AVAudioApplication.shared.recordPermission == .granted + } else { + return AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + } + } + + private static func isMicrophoneUndetermined() -> Bool { + if #available(macOS 14.0, *) { + return AVAudioApplication.shared.recordPermission == .undetermined + } else { + return AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined + } + } + + private static func isMicrophoneDenied() -> Bool { + if #available(macOS 14.0, *) { + let p = AVAudioApplication.shared.recordPermission + return p == .denied + } else { + let s = AVCaptureDevice.authorizationStatus(for: .audio) + return s == .denied || s == .restricted + } + } + + private static func requestMicrophoneAccess(completion: @escaping () -> Void) { + if #available(macOS 14.0, *) { + AVAudioApplication.requestRecordPermission { _ in completion() } + } else { + AVCaptureDevice.requestAccess(for: .audio) { _ in completion() } + } + } + + private static func readBooleanPreference(domain: String, key: String) -> Bool? { + guard let value = CFPreferencesCopyAppValue(key as CFString, domain as CFString) else { + return nil + } + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return CFBooleanGetValue((value as! CFBoolean)) + } + if let number = value as? NSNumber { + return number.boolValue + } + return nil + } + + private func injectText(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + guard CGPreflightPostEventAccess() else { + appendDiagnostic("inject denied: no post event access") + return false + } + + // 走剪贴板 + ⌘V 的方式: + // Electron / Chromium 应用(Cursor、VS Code、Slack 等)会吞掉 + // CGEvent.keyboardSetUnicodeString 合成的 Unicode 键盘事件,所以 + // 用标准的粘贴路径更通用稳定。粘贴完成后恢复原剪贴板内容。 + if injectViaPaste(text: text) { + return true + } + + // 理论上不会落到这里——保留 Unicode-synthesis 作为 last-resort fallback。 + appendDiagnostic("inject fallback to unicode-synthesis") + for scalar in text.utf16 { + var unit = scalar + guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true), + let up = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false) else { + return false + } + + withUnsafePointer(to: &unit) { pointer in + down.keyboardSetUnicodeString(stringLength: 1, unicodeString: pointer) + up.keyboardSetUnicodeString(stringLength: 1, unicodeString: pointer) + } + + down.setIntegerValueField(.eventSourceUserData, value: syntheticEventUserData) + up.setIntegerValueField(.eventSourceUserData, value: syntheticEventUserData) + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + usleep(5_000) + } + + return true + } + + /// 用 NSPasteboard + 合成 ⌘V 的方式把 `text` 注入到当前焦点位置。 + /// 返回 true 表示已投递粘贴事件;之后会异步恢复原剪贴板内容。 + private func injectViaPaste(text: String) -> Bool { + let pasteboard = NSPasteboard.general + + // 备份当前剪贴板(保留所有类型的数据,兼容图片/富文本) + var backup: [(NSPasteboard.PasteboardType, Data)] = [] + if let types = pasteboard.types { + for type in types { + if let data = pasteboard.data(forType: type) { + backup.append((type, data)) + } + } + } + + pasteboard.clearContents() + let wrote = pasteboard.setString(text, forType: .string) + guard wrote else { + appendDiagnostic("paste inject failed: pasteboard setString returned false") + restorePasteboard(backup: backup) + return false + } + + // 合成 ⌘V —— virtualKey 0x09 = V(kVK_ANSI_V) + let source = CGEventSource(stateID: .combinedSessionState) + guard let down = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) else { + appendDiagnostic("paste inject failed: cannot create CGEvent") + restorePasteboard(backup: backup) + return false + } + down.flags = .maskCommand + up.flags = .maskCommand + down.setIntegerValueField(.eventSourceUserData, value: syntheticEventUserData) + up.setIntegerValueField(.eventSourceUserData, value: syntheticEventUserData) + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + + appendDiagnostic("paste inject posted ⌘V for text.count=\(text.count)") + + // 给目标 app 足够时间消费粘贴事件再恢复剪贴板 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.restorePasteboard(backup: backup) + } + + return true + } + + private func restorePasteboard(backup: [(NSPasteboard.PasteboardType, Data)]) { + guard !backup.isEmpty else { return } + let pb = NSPasteboard.general + pb.clearContents() + for (type, data) in backup { + pb.setData(data, forType: type) + } + } + + private func appendDiagnostic(_ message: String) { + let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n" + let url = diagnosticLogURL + + Task.detached { + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: url.path) { + try Data(line.utf8).write(to: url) + } else { + let handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + try handle.write(contentsOf: Data(line.utf8)) + try handle.close() + } + } catch { + // ignore diagnostics write errors + } + } + } + + private var diagnosticLogURL: URL { + let directory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) + return directory.appendingPathComponent("native-speech.log") + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift b/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift new file mode 100644 index 00000000..5ad73dc5 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift @@ -0,0 +1,146 @@ +import CoreGraphics +import Foundation +import ImageIO + +enum OLEDFrameEncodingError: LocalizedError { + case cannotCreateImageSource + case noFrames + case cannotCreateContext + case sourceFileTooLarge(fileSize: Int, maxBytes: Int) + case tooManyFrames(count: Int, max: Int) + + var errorDescription: String? { + switch self { + case .cannotCreateImageSource: + return "无法读取 GIF 文件。" + case .noFrames: + return "没有可编码的图片帧。" + case .cannotCreateContext: + return "无法创建 LCD 编码上下文。" + case .sourceFileTooLarge(let fileSize, let maxBytes): + let f = ByteCountFormatter() + f.allowedUnits = [.useMB, .useKB, .useBytes] + f.countStyle = .file + let a = f.string(fromByteCount: Int64(fileSize)) + let b = f.string(fromByteCount: Int64(maxBytes)) + return "图片源文件约 \(a),超过单文件上限 \(b)。请压缩分辨率、减少帧数或缩短动图后再试。" + case .tooManyFrames(let count, let max): + return "当前动图共有 \(count) 帧,超过单模式上限 \(max) 帧。请减少帧数或缩短动图后再试。" + } + } +} + +enum OLEDFrameEncoder { + static func frameCount(at url: URL) -> Int { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return 0 } + return CGImageSourceGetCount(source) + } + + /// 源 GIF 文件字节数;无法读取时返回 `nil`。 + static func sourceFileByteCount(at url: URL) -> Int? { + if let v = try? url.resourceValues(forKeys: [.fileSizeKey]), let n = v.fileSize { return n } + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let n = attrs[.size] as? Int { + return n + } + return nil + } + + /// 若超过 `AhaKeyCommand.oledMaxSourceFileBytes` 则抛出 `sourceFileTooLarge`。 + static func validateGIFSourceFileSize(at url: URL) throws { + guard let n = sourceFileByteCount(at: url) else { + return + } + guard n <= AhaKeyCommand.oledMaxSourceFileBytes else { + throw OLEDFrameEncodingError.sourceFileTooLarge(fileSize: n, maxBytes: AhaKeyCommand.oledMaxSourceFileBytes) + } + } + + static func validateFrameCount(at url: URL) throws { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + throw OLEDFrameEncodingError.cannotCreateImageSource + } + let count = CGImageSourceGetCount(source) + guard count > 0 else { + throw OLEDFrameEncodingError.noFrames + } + guard count <= AhaKeyCommand.oledMaxFramesPerMode else { + throw OLEDFrameEncodingError.tooManyFrames(count: count, max: AhaKeyCommand.oledMaxFramesPerMode) + } + } + + static func frames(fromGIFAt url: URL) throws -> [Data] { + try validateGIFSourceFileSize(at: url) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + throw OLEDFrameEncodingError.cannotCreateImageSource + } + + let count = CGImageSourceGetCount(source) + guard count > 0 else { + throw OLEDFrameEncodingError.noFrames + } + guard count <= AhaKeyCommand.oledMaxFramesPerMode else { + throw OLEDFrameEncodingError.tooManyFrames(count: count, max: AhaKeyCommand.oledMaxFramesPerMode) + } + + var frames: [Data] = [] + frames.reserveCapacity(count) + for index in 0 ..< count { + guard let image = CGImageSourceCreateImageAtIndex(source, index, nil) else { continue } + frames.append(try encodeFrame(image)) + } + + guard !frames.isEmpty else { + throw OLEDFrameEncodingError.noFrames + } + return frames + } + + private static func encodeFrame(_ image: CGImage) throws -> Data { + let width = AhaKeyCommand.oledWidth + let height = AhaKeyCommand.oledHeight + let bytesPerPixel = 4 + let bytesPerRow = width * bytesPerPixel + var rgba = [UInt8](repeating: 0, count: width * height * bytesPerPixel) + + guard let context = CGContext( + data: &rgba, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw OLEDFrameEncodingError.cannotCreateContext + } + + context.interpolationQuality = .high + context.setFillColor(CGColor(gray: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + let scale = min(Double(width) / Double(image.width), Double(height) / Double(image.height)) + let drawWidth = Double(image.width) * scale + let drawHeight = Double(image.height) * scale + let drawRect = CGRect( + x: (Double(width) - drawWidth) / 2, + y: (Double(height) - drawHeight) / 2, + width: drawWidth, + height: drawHeight + ) + context.draw(image, in: drawRect) + + // 每帧恰好 160*80*2 = 25600 字节 RGB565 大端,原厂 Python 也不做 padding。 + // flash 物理帧槽是 28672 字节,剩下的 3072 字节由 address 递增自然留空。 + var data = Data(capacity: width * height * 2) + for pixel in stride(from: 0, to: rgba.count, by: bytesPerPixel) { + let red = UInt16(rgba[pixel]) + let green = UInt16(rgba[pixel + 1]) + let blue = UInt16(rgba[pixel + 2]) + let rgb565 = ((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3) + data.append(UInt8((rgb565 >> 8) & 0xFF)) + data.append(UInt8(rgb565 & 0xFF)) + } + return data + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/PermissionSignatureChecker.swift b/ahakeyconfig-mac/Sources/Utilities/PermissionSignatureChecker.swift new file mode 100644 index 00000000..810385a2 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/PermissionSignatureChecker.swift @@ -0,0 +1,146 @@ +import Foundation + +/// 检测应用签名变化并处理权限问题 +/// +/// 当应用签名改变时(如重新安装、更换证书),macOS 会将其视为新应用, +/// 但旧的 TCC 授权记录仍然存在。由于麦克风权限在系统设置中没有手动添加的 '+' 号, +/// 用户无法手动添加新签名的应用。 +/// +/// 此工具检测签名变化,并提供重置麦克风权限的功能。 +enum PermissionSignatureChecker { + + private static let lastSignatureKey = "AhaKey_LastSignatureHash" + + /// 获取当前应用的签名哈希 + private static func currentSignatureHash() -> String? { + let bundlePath = Bundle.main.bundlePath + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + task.arguments = ["-dv", "--verbose=4", bundlePath] + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: data, encoding: .utf8) { + // 提取签名信息(cdhash) + let lines = output.components(separatedBy: .newlines) + for line in lines { + if let range = line.range(of: "cdhash=") { + let hash = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + return hash + } + } + } + } catch { + print("Error getting signature: \(error)") + } + return nil + } + + /// 检查签名是否发生变化 + static func hasSignatureChanged() -> Bool { + guard let current = currentSignatureHash() else { return false } + let last = UserDefaults.standard.string(forKey: lastSignatureKey) + return last != current + } + + /// 保存当前签名 + static func saveCurrentSignature() { + if let current = currentSignatureHash() { + UserDefaults.standard.set(current, forKey: lastSignatureKey) + } + } + + /// 重置麦克风权限(需要 sudo) + /// - Parameter completion: (success, message) + static func resetMicrophonePermission(completion: @escaping (Bool, String) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let bundleId = Bundle.main.bundleIdentifier ?? "lab.jawa.ahakeyconfig" + + print("[PermissionSignatureChecker] 开始重置麦克风权限, bundleId: \(bundleId)") + + // 先尝试普通模式重置 + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + task.arguments = ["reset", "Microphone", bundleId] + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + print("[PermissionSignatureChecker] 普通模式 tccutil 返回: \(task.terminationStatus), 输出: \(output)") + + if task.terminationStatus == 0 { + completion(true, "麦克风权限已重置") + return + } + + // 如果普通模式失败,尝试用 sudo + let sudoTask = Process() + sudoTask.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") + sudoTask.arguments = ["/usr/bin/tccutil", "reset", "Microphone", bundleId] + + let sudoPipe = Pipe() + sudoTask.standardOutput = sudoPipe + sudoTask.standardError = sudoPipe + + do { + try sudoTask.run() + sudoTask.waitUntilExit() + + let sudoData = sudoPipe.fileHandleForReading.readDataToEndOfFile() + let sudoOutput = String(data: sudoData, encoding: .utf8) ?? "" + + print("[PermissionSignatureChecker] sudo 模式 tccutil 返回: \(sudoTask.terminationStatus), 输出: \(sudoOutput)") + + if sudoTask.terminationStatus == 0 { + completion(true, "麦克风权限已重置(使用 sudo)") + } else { + completion(false, "重置失败:\n普通模式: \(output)\nsudo模式: \(sudoOutput)") + } + } catch { + print("[PermissionSignatureChecker] sudo 执行失败: \(error)") + completion(false, "sudo 执行失败: \(error.localizedDescription)") + } + } catch { + print("[PermissionSignatureChecker] 执行失败: \(error)") + completion(false, "执行失败: \(error.localizedDescription)") + } + } + } + + /// 检查并处理签名变化 + /// - Returns: true 如果签名变化且需要处理 + static func checkAndHandleSignatureChange() -> Bool { + if hasSignatureChanged() { + saveCurrentSignature() + return true + } + return false + } + + /// 检查签名变化并自动重置麦克风权限 + static func checkAndResetOnSignatureChange(completion: @escaping (Bool) -> Void) { + if hasSignatureChanged() { + saveCurrentSignature() + resetMicrophonePermission { success, _ in + completion(success) + } + } else { + completion(false) + } + } +} \ No newline at end of file diff --git a/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift new file mode 100644 index 00000000..87770396 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift @@ -0,0 +1,935 @@ +import AppKit +import ApplicationServices +import Carbon +import Combine +import CoreGraphics +import Foundation +import os.log + +private let voiceRelayLog = Logger(subsystem: "lab.jawa.ahakeyconfig", category: "VoiceRelay") + +private struct VoiceTriggerBinding: Hashable { + let keyCode: CGKeyCode + let modifiers: Set + + var displayLabel: String { + let modifierLabel = ShortcutModifier.displayOrder + .filter { modifiers.contains($0) } + .map(\.symbol) + .joined() + return modifierLabel + macKeyName(for: keyCode) + } +} + +private enum VoiceRouteAction: Hashable { + case macOSDictation + case functionRelay(appName: String) + case doubaoPassThrough + + var title: String { + switch self { + case .macOSDictation: + "macOS 原生语音" + case let .functionRelay(appName): + appName + case .doubaoPassThrough: + "豆包输入法" + } + } + + var isFunctionRelay: Bool { + if case .functionRelay = self { return true } + return false + } +} + +private struct VoiceRoute: Hashable { + let binding: VoiceTriggerBinding + let action: VoiceRouteAction + let mode: AhaKeyModeSlot + let compatibilityLabel: String? +} + +final class VoiceRelayService: ObservableObject { + static let shared = VoiceRelayService() + + @Published private(set) var isListening = false + @Published private(set) var inputMonitoringGranted = false + @Published private(set) var accessibilityGranted = false + @Published private(set) var statusMessage = "等待语音路由初始化。" + @Published private(set) var activeRouteSummary = "未配置语音软件。" + @Published var showsPermissionOnboarding = false + @Published private(set) var lastPermissionCheckSummary = "尚未检查权限。" + @Published private(set) var lastInspectorSimulateHint: String? + + private let routeQueue = DispatchQueue(label: "lab.jawa.ahakeyconfig.voiceRelay.routes") + private var routes: [VoiceRoute] = [] + /// 与键盘物理档位一致,用于多个 Mode 共用同一触发键(如 F18 / F19)时选对路由。 + private var keyboardWorkMode: AhaKeyModeSlot = .mode0 + + /// 我们用 CGEventTap(不是 NSEvent.addGlobalMonitor),因为只有 CGEventTap 能真正 + /// "吞掉"键盘事件,防止硬件语音键漏到前台 App(比如 Claude Code CLI / iTerm 等终端 + /// 会把 F17/F18 翻译成 xterm CSI 转义序列,用户看起来就是"乱码")。 + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + private var suppressPermissionOnboardingUntil: Date? + + private var shadowSuppressUntil: TimeInterval = 0 + + /// 当 route.action 是 .functionRelay 时用来模拟「按住 Fn/Globe」;触发键可为 F18/F19 等,与物理键 keyDown/keyUp 跟手。 + private var holdingRoute: VoiceRoute? + /// 硬件语音键常为极短脉冲(down/up 间隔几毫秒);若立刻跟手 Fn up,Typeless/微信往往来不及进入「按住说话」。满足最短「物理按下时长」后再 Fn up(长按则仍立即跟手)。 + private var functionRelayKeyDownUptime: TimeInterval? + private var pendingFnReleaseWorkItem: DispatchWorkItem? + /// 当前是否已向系统发出尚未配对的 Fn keyDown(用于短脉冲 cancel 延后 release 后避免重复 keyDown)。 + private var syntheticFnRelayHeld: Bool = false + + private let syntheticEventUserData: Int64 = 0x4148414B + private let fnKeyCode: CGKeyCode = 63 + private let emojiShadowKeyCode: CGKeyCode = 179 + private let shadowSuppressSeconds: TimeInterval = 0.06 + /// 物理按下若短于此值,则 Fn keyUp 延后到整段不少于该时长(IME 启动「按住说话」往往需要更长的合成 Fn)。 + private let minFunctionRelayPhysicalHoldSeconds: TimeInterval = 0.45 + + private init() { + NotificationCenter.default.addObserver( + forName: .ahaKeyKeyboardWorkModeChanged, + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let raw = note.userInfo?["workMode"] as? Int else { return } + let slot = AhaKeyModeSlot(rawValue: raw) ?? .mode0 + self.routeQueue.async { + self.keyboardWorkMode = slot + } + self.appendDiagnostic("keyboard work mode (hardware) → \(slot.rawValue) (\(slot.name))") + } + } + + // MARK: - Public + + func start() { + refreshPermissions(requestIfNeeded: false) + } + + /// - Parameter deferredTCCRequery: 用户点「重新检查」时置 true。仅 Preflight 在刚改完系统设置、仍停留本 App 时可能仍读到旧值;改为稍后使用 Request API 再读一次,并略延长等待。 + func refreshPermissions(requestIfNeeded: Bool = false, deferredTCCRequery: Bool = false) { + if requestIfNeeded { + if Thread.isMainThread { + performPermissionRead(requestIfNeeded: true) + } else { + DispatchQueue.main.async { [weak self] in + self?.performPermissionRead(requestIfNeeded: true) + } + } + return + } + if deferredTCCRequery { + DispatchQueue.main.async { + self.lastPermissionCheckSummary = "正在检查系统权限…" + } + let firstDelay: TimeInterval = 0.45 + let followUpDelay: TimeInterval = 0.85 + DispatchQueue.main.asyncAfter(deadline: .now() + firstDelay) { [weak self] in + guard let self else { return } + self.performPermissionRead(requestIfNeeded: false, preferRequestAPI: true) + DispatchQueue.main.asyncAfter(deadline: .now() + followUpDelay) { [weak self] in + guard let self else { return } + if !self.inputMonitoringGranted || !self.accessibilityGranted { + self.performPermissionRead(requestIfNeeded: false, preferRequestAPI: true) + self.appendDiagnostic("permissions follow-up recheck (after system settings)") + } + } + } + return + } + performPermissionRead(requestIfNeeded: false) + } + + private func performPermissionRead(requestIfNeeded: Bool, preferRequestAPI: Bool = false) { + let inputMonitoring: Bool + let postEventAccess: Bool + if requestIfNeeded || preferRequestAPI { + // Request 会走当前 TCC 判决;用户刚从「隐私与安全性」返回时,Preflight 有时仍短暂为 false。 + inputMonitoring = CGRequestListenEventAccess() + postEventAccess = CGRequestPostEventAccess() + } else { + inputMonitoring = CGPreflightListenEventAccess() + postEventAccess = CGPreflightPostEventAccess() + } + + let accessibility: Bool + if requestIfNeeded { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + accessibility = AXIsProcessTrustedWithOptions(options) + } else { + accessibility = AXIsProcessTrusted() + } + + let timeLabel = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + let lastCheckSummary = + "输入监控 \(inputMonitoring ? "已开启" : "未开启") · 辅助功能 \((accessibility && postEventAccess) ? "已开启" : "未开启") · 检查于 \(timeLabel)" + + DispatchQueue.main.async { + self.inputMonitoringGranted = inputMonitoring + self.accessibilityGranted = accessibility && postEventAccess + self.lastPermissionCheckSummary = lastCheckSummary + self.showsPermissionOnboarding = !(inputMonitoring && accessibility && postEventAccess) && !self.isPermissionOnboardingSuppressed + self.refreshStatusMessage() + } + + let exePath = Bundle.main.executablePath ?? "unknown" + let bundleID = Bundle.main.bundleIdentifier ?? "none" + appendDiagnostic("permissions inputMonitoring=\(inputMonitoring) accessibility=\(accessibility) postEvent=\(postEventAccess) exe=\(exePath) bundle=\(bundleID)") + + if inputMonitoring && accessibility && postEventAccess { + DispatchQueue.main.async { + self.ensureMonitorsIfPossible() + } + } else { + DispatchQueue.main.async { + self.stopMonitors() + } + } + } + + func dismissPermissionOnboarding() { + showsPermissionOnboarding = false + } + + func requestInputMonitoringPermission() { + _ = CGRequestListenEventAccess() + performPermissionRead(requestIfNeeded: false, preferRequestAPI: true) + } + + func requestAccessibilityPermission() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + performPermissionRead(requestIfNeeded: false, preferRequestAPI: true) + } + + func suppressPermissionOnboarding(for seconds: TimeInterval = 4.0) { + suppressPermissionOnboardingUntil = Date().addingTimeInterval(seconds) + showsPermissionOnboarding = false + } + + var isPermissionOnboardingSuppressed: Bool { + if let until = suppressPermissionOnboardingUntil, until > Date() { + return true + } + suppressPermissionOnboardingUntil = nil + return false + } + + /// Inspector 调试:模拟当前模式下按一次实体语音键(Fn/Globe = 切换 Fn 按住;macOS 原生 = 切换系统转写)。 + func simulateInspectorVoiceKeyTap(for mode: AhaKeyModeSlot) { + let route: VoiceRoute? = routeQueue.sync { + routes.first { $0.mode == mode } + } + guard let route else { + appendDiagnostic("inspector simulate: no route for mode=\(mode.rawValue)") + Task { @MainActor in + lastInspectorSimulateHint = "当前模式没有语音路由:Fn 请选 Fn/Globe,或把「自定义快捷键」设为 F19。" + } + return + } + switch route.action { + case .macOSDictation: + Task { @MainActor in + NativeSpeechTranscriptionService.shared.toggleRecordingFromVoiceKey() + lastInspectorSimulateHint = "已切换「苹果原生转写」录制状态(与界面「开始录音」相同)。" + } + case .functionRelay: + toggleFunctionRelayHold(for: route) + Task { @MainActor in + lastInspectorSimulateHint = "已切换 Fn 按住状态;请在 Typeless/微信语音/豆包输入法内把快捷键设为 Fn/Globe(本 Studio 监听 F19,旧版 F18 兼容)。再点一次为松开。" + } + case .doubaoPassThrough: + configureDoubaoVoiceShortcutIfNeeded() + ensureInputSource(id: Self.doubaoInputSourceID, label: route.action.title) + Task { @MainActor in + lastInspectorSimulateHint = "豆包需要真实 F18 长按事件;已切到豆包输入源并配置长按 F18,请用实体语音键测试。" + } + } + appendDiagnostic("inspector simulate mode=\(mode.rawValue) action=\(route.action.title)") + } + + func updateRoutes(from draft: AhaKeyStudioDraft) { + let builtRoutes = Self.buildRoutes(from: draft) + let needsDoubaoPreparation = builtRoutes.contains { $0.action == .doubaoPassThrough } + + // 只有路由集合真的变化时才释放"按住"状态,避免 SwiftUI 频繁重建/无关 onChange + // 间接把 functionRelay 的 hold 状态冲掉(典型表现:微信按住说话过几秒自动结束)。 + let routesChanged: Bool = routeQueue.sync { self.routes != builtRoutes } + if routesChanged { + releaseFunctionRelayHoldIfNeeded() + } + + routeQueue.async { + self.routes = builtRoutes + let summary = builtRoutes.isEmpty + ? "未配置语音软件。" + : builtRoutes.map { route in + let fallback = route.compatibilityLabel.map { " · \($0)" } ?? "" + return "\(route.mode.title) \(route.action.title) ← \(route.binding.displayLabel)\(fallback)" + }.joined(separator: " / ") + + DispatchQueue.main.async { + self.activeRouteSummary = summary + self.refreshStatusMessage() + } + } + + if needsDoubaoPreparation { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.configureDoubaoVoiceShortcutIfNeeded() + self.ensureInputSource(id: Self.doubaoInputSourceID, label: "豆包输入法") + } + } + } + + // MARK: - Event Monitoring (CGEventTap) + + private func ensureMonitorsIfPossible() { + guard eventTap == nil else { + isListening = true + refreshStatusMessage() + return + } + + guard inputMonitoringGranted, accessibilityGranted else { + isListening = false + refreshStatusMessage() + return + } + + // 关注 keyDown / keyUp。flagsChanged 不需要,因为 voice key 都映射为非 modifier 键。 + let mask: CGEventMask = + (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.keyUp.rawValue) + + let refcon = Unmanaged.passUnretained(self).toOpaque() + let callback: CGEventTapCallBack = { _, type, cgEvent, refcon in + guard let refcon else { return Unmanaged.passUnretained(cgEvent) } + let service = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return service.handleTappedEvent(type: type, event: cgEvent) + } + + guard let tap = CGEvent.tapCreate( + tap: .cghidEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: callback, + userInfo: refcon + ) else { + appendDiagnostic("event tap create failed (缺辅助功能或输入监控权限?)") + isListening = false + refreshStatusMessage() + return + } + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + + self.eventTap = tap + self.runLoopSource = source + isListening = true + appendDiagnostic("cg event tap started") + voiceRelayLog.info("Voice relay CG event tap started") + refreshStatusMessage() + } + + private func stopMonitors() { + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + self.eventTap = nil + } + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + self.runLoopSource = nil + } + releaseFunctionRelayHoldIfNeeded() + isListening = false + appendDiagnostic("cg event tap stopped") + refreshStatusMessage() + } + + /// CGEventTap 回调。返回 `nil` 表示吞掉事件(不让它抵达前台 App);返回 passUnretained + /// 表示放行。务必只在成功 match 到 route 时才吞,避免误杀普通按键。 + private func handleTappedEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { + // 1. 系统可能因为我们耗时过长临时禁用了 tap,这里补救重启一下。 + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: true) + appendDiagnostic("cg event tap re-enabled after \(type == .tapDisabledByTimeout ? "timeout" : "user input")") + } + return Unmanaged.passUnretained(event) + } + + // 2. 自己合成出来的事件(functionRelay 注入的 Fn)必须放行,不然会死循环。 + if event.getIntegerValueField(.eventSourceUserData) == syntheticEventUserData { + return Unmanaged.passUnretained(event) + } + + // 3. 只关心 keyDown / keyUp;别的类型直接放行。 + guard type == .keyDown || type == .keyUp else { + return Unmanaged.passUnretained(event) + } + + let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) + let flags = normalizedModifierSet(from: event.flags) + + // 4. Emoji 面板影子键 179 抑制:Fn 注入之后 macOS 会连带发一个影子 keyDown, + // 把它吞掉避免 Emoji 面板闪一下。 + let now = Date().timeIntervalSinceReferenceDate + if keyCode == emojiShadowKeyCode, + now <= routeQueue.sync(execute: { shadowSuppressUntil }) + { + appendDiagnostic("shadow suppress keyCode=\(keyCode) type=\(type.rawValue)") + return nil + } + + // 5. 匹配语音路由。没匹配就一律放行。 + guard let route = matchingRoute(forKeyCode: keyCode, flags: flags) else { + return Unmanaged.passUnretained(event) + } + + let isAutoRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 + appendDiagnostic("matched keyCode=\(keyCode) type=\(type.rawValue) autorepeat=\(isAutoRepeat) route=\(route.action.title) mode=\(route.mode.rawValue)") + + switch route.action { + case .macOSDictation: + if isAutoRepeat { return nil } + if type == .keyDown { + Task { @MainActor in + NativeSpeechTranscriptionService.shared.handleVoiceKeyDown() + } + } else if type == .keyUp { + Task { @MainActor in + NativeSpeechTranscriptionService.shared.handleVoiceKeyUp() + } + } + // keyDown/keyUp 都吞掉,避免硬件发出的 F17/F18 漏到前台 App(比如 Claude CLI + // 所在的终端会把它翻译成 \e[...~ 字样)。 + return nil + + case .doubaoPassThrough: + if type == .keyDown, !isAutoRepeat { + configureDoubaoVoiceShortcutIfNeeded() + ensureInputSource(id: Self.doubaoInputSourceID, label: route.action.title) + appendDiagnostic("doubao pass-through keyDown → let physical event reach IME") + } else if type == .keyUp { + appendDiagnostic("doubao pass-through keyUp → let physical event reach IME") + } + return Unmanaged.passUnretained(event) + + case .functionRelay: + // 与微信 / Typeless「按住说话」一致:跟手硬件 keyDown;keyUp 若过短则略延长 Fn 按住,避免脉冲键无反应。 + if isAutoRepeat { + return nil + } + if type == .keyDown { + routeQueue.sync { + cancelPendingFnReleaseLocked() + if holdingRoute != nil { return } + holdingRoute = route + functionRelayKeyDownUptime = ProcessInfo.processInfo.systemUptime + if !syntheticFnRelayHeld { + postFunctionKey(isKeyDown: true) + syntheticFnRelayHeld = true + appendDiagnostic("function relay keyDown → hold (\(route.action.title))") + } else { + appendDiagnostic("function relay keyDown → hold (already Fn down, \(route.action.title))") + } + } + } else if type == .keyUp { + let releasePlan: (title: String, delay: TimeInterval, elapsed: TimeInterval)? = routeQueue.sync { + cancelPendingFnReleaseLocked() + guard holdingRoute == route else { return nil } + holdingRoute = nil + let downUptime = functionRelayKeyDownUptime ?? ProcessInfo.processInfo.systemUptime + functionRelayKeyDownUptime = nil + let elapsed = ProcessInfo.processInfo.systemUptime - downUptime + let delay = max(0, minFunctionRelayPhysicalHoldSeconds - elapsed) + return (route.action.title, delay, elapsed) + } + guard let releasePlan else { return nil } + if releasePlan.delay < 0.001 { + routeQueue.sync { syntheticFnRelayHeld = false } + postFunctionKey(isKeyDown: false) + appendDiagnostic("function relay keyUp → release (\(releasePlan.title))") + } else { + appendDiagnostic( + "function relay keyUp → schedule Fn release in \(String(format: "%.3f", releasePlan.delay))s (\(releasePlan.title), physical_down=\(String(format: "%.3f", releasePlan.elapsed))s)" + ) + let title = releasePlan.title + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.postFunctionKey(isKeyDown: false) + // 已在 routeQueue 上执行,禁止再 routeQueue.sync,否则同队列嵌套会死锁并在调试下 EXC_BREAKPOINT。 + self.syntheticFnRelayHeld = false + self.pendingFnReleaseWorkItem = nil + self.appendDiagnostic("function relay delayed Fn release (\(title))") + } + routeQueue.sync { + pendingFnReleaseWorkItem = work + } + routeQueue.asyncAfter(deadline: .now() + releasePlan.delay, execute: work) + } + } + return nil + } + } + + private func cancelPendingFnReleaseLocked() { + pendingFnReleaseWorkItem?.cancel() + pendingFnReleaseWorkItem = nil + } + + private func toggleFunctionRelayHold(for route: VoiceRoute) { + routeQueue.sync { + cancelPendingFnReleaseLocked() + } + let shouldRelease: Bool = routeQueue.sync { + if holdingRoute != nil || syntheticFnRelayHeld { + holdingRoute = nil + functionRelayKeyDownUptime = nil + return true + } else { + holdingRoute = route + return false + } + } + if shouldRelease { + postFunctionKey(isKeyDown: false) + routeQueue.sync { syntheticFnRelayHeld = false } + appendDiagnostic("function relay toggle → release (\(route.action.title))") + } else { + postFunctionKey(isKeyDown: true) + routeQueue.sync { syntheticFnRelayHeld = true } + appendDiagnostic("function relay toggle → hold (\(route.action.title))") + } + } + + /// 在服务停止监听、路由变化或权限失效时,保证不会把 Fn「按住」悬挂在系统键盘里。 + private func releaseFunctionRelayHoldIfNeeded() { + let needsFnUp: Bool = routeQueue.sync { + cancelPendingFnReleaseLocked() + holdingRoute = nil + functionRelayKeyDownUptime = nil + let wasHeld = syntheticFnRelayHeld + syntheticFnRelayHeld = false + return wasHeld + } + if needsFnUp { + postFunctionKey(isKeyDown: false) + appendDiagnostic("function relay force release") + } + } + + private func matchingRoute(forKeyCode keyCode: CGKeyCode, flags: Set) -> VoiceRoute? { + routeQueue.sync { + let candidates = routes.filter { $0.binding.keyCode == keyCode && $0.binding.modifiers == flags } + if candidates.isEmpty { return nil } + if let hit = candidates.first(where: { $0.mode == keyboardWorkMode }) { + return hit + } + return candidates.first + } + } + + // MARK: - Posting + + private func postFunctionKey(isKeyDown: Bool) { + appendDiagnostic("post fn keyDown=\(isKeyDown)") + let flags: CGEventFlags = isKeyDown ? .maskSecondaryFn : [] + postFnRelayKeyboardEvents(keyCode: fnKeyCode, keyDown: isKeyDown, flags: flags) + routeQueue.async { + self.shadowSuppressUntil = Date().timeIntervalSinceReferenceDate + self.shadowSuppressSeconds + } + } + + /// Typeless 等 IME 有时只从 session 或 HID 一侧吃全 Fn;分两路投递(各用独立 CGEvent),提高「长按 Fn」被识别的概率。 + private func postFnRelayKeyboardEvents(keyCode: CGKeyCode, keyDown: Bool, flags: CGEventFlags) { + if let event = CGEvent( + keyboardEventSource: CGEventSource(stateID: .combinedSessionState), + virtualKey: keyCode, + keyDown: keyDown + ) { + event.flags = flags + event.setIntegerValueField(.eventSourceUserData, value: syntheticEventUserData) + event.post(tap: .cgSessionEventTap) + } + if let event = CGEvent( + keyboardEventSource: CGEventSource(stateID: .hidSystemState), + virtualKey: keyCode, + keyDown: keyDown + ) { + event.flags = flags + event.setIntegerValueField(.eventSourceUserData, value: syntheticEventUserData) + event.post(tap: .cghidEventTap) + } + } + + private func ensureInputSource(id inputSourceID: String, label: String) { + guard currentInputSourceID() != inputSourceID else { return } + if selectInputSource(id: inputSourceID) { + appendDiagnostic("input source selected id=\(inputSourceID) for \(label)") + } else { + appendDiagnostic("input source select failed id=\(inputSourceID) for \(label)") + } + } + + private func currentInputSourceID() -> String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { + return nil + } + return inputSourceID(from: source) + } + + private func selectInputSource(id targetID: String) -> Bool { + let sources = TISCreateInputSourceList(nil, true).takeRetainedValue() as NSArray + for item in sources { + let source = item as! TISInputSource + guard inputSourceID(from: source) == targetID else { continue } + return TISSelectInputSource(source) == noErr + } + return false + } + + private func inputSourceID(from source: TISInputSource) -> String? { + guard let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { + return nil + } + return Unmanaged.fromOpaque(raw).takeUnretainedValue() as String + } + + private func configureDoubaoVoiceShortcutIfNeeded() { + let defaults = UserDefaults.standard + var global = defaults.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + var changed = false + + func set(_ key: String, _ value: Any) { + if !NSDictionary(dictionary: global).isEqual(to: [key: value]) && "\(global[key] ?? "")" != "\(value)" { + global[key] = value + changed = true + } + } + + set("isStartASRShortcutEnable", true) + set("isGloableASRShortcutEnable", true) + set("asrShortcutKeyCode", Int(Self.fnTriggerMacKeyCode)) + set("asrShortcutModifierFlags", 0) + set("asrShortcutKeyDisplay", "F18") + set("asrLongPressShortcutKeyCode", Int(Self.fnTriggerMacKeyCode)) + set("asrLongPressShortcutModifierFlags", 0) + set("asrLongPressShortcutKeyDisplay", "F18") + + if changed { + defaults.setPersistentDomain(global, forName: UserDefaults.globalDomain) + defaults.synchronize() + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("DoubaoImeSettings.asrLongPressShortcutKeyNotification"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("DoubaoImeSettings.enableStartASRShortcutNotification"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("DoubaoImeSettings.enableGloableASRShortcutNotification"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + appendDiagnostic("doubao shortcut configured: longPress F18") + } + } + + // MARK: - Helpers + + private func refreshStatusMessage() { + if !inputMonitoringGranted || !accessibilityGranted { + statusMessage = "还缺系统权限:请为 AhaKey Studio 打开“输入监控”和“辅助功能”,授权后回到软件点“重新检查权限”。" + return + } + + guard isListening else { + statusMessage = "语音键后台监听准备中。关闭窗口后,AhaKey Studio 也会继续驻留后台。" + return + } + + if activeRouteSummary == "未配置语音软件。" { + statusMessage = "后台监听已启动,但当前没有可接管的语音软件。" + return + } + + statusMessage = "后台监听已启动。Fn/Globe 语音走 F19;macOS 原生与旧版 F18 会继续兼容。" + } + + private static func buildRoutes(from draft: AhaKeyStudioDraft) -> [VoiceRoute] { + var orderedRoutes: [VoiceRoute] = [] + let factoryF18 = VoiceTriggerBinding(keyCode: 79, modifiers: []) + let fnF19 = VoiceTriggerBinding(keyCode: 80, modifiers: []) + + for mode in AhaKeyModeSlot.allCases { + let voiceKey = draft.draft(for: mode).key(for: .voice) + guard let preset = voiceKey.voicePreset, + preset.availableInV1, + let action = action(for: preset, shortcut: voiceKey.shortcut), + let binding = macBinding(for: voiceKey.shortcut) + else { continue } + + orderedRoutes.append( + VoiceRoute( + binding: binding, + action: action, + mode: mode, + compatibilityLabel: nil + ) + ) + + if action.isFunctionRelay, binding != fnF19 { + orderedRoutes.append( + VoiceRoute( + binding: fnF19, + action: action, + mode: mode, + compatibilityLabel: "Fn F19 兼容" + ) + ) + } + + if mode == .mode0, binding != factoryF18 { + orderedRoutes.append( + VoiceRoute( + binding: factoryF18, + action: action, + mode: .mode0, + compatibilityLabel: "旧版 F18 兼容" + ) + ) + } + } + + return orderedRoutes + } + + private static func action(for preset: VoicePreset, shortcut: ShortcutBinding) -> VoiceRouteAction? { + switch preset { + case .macOSNative: + .macOSDictation + case .typeless: + .functionRelay(appName: "Fn/Globe") + case .wechat: + .functionRelay(appName: "Fn/Globe") + case .claudeCode: + // Claude Code preset 复用 macOS 原生 ASR:录音 → 识别 → ⌘V 粘到当前光标。 + // 这样按键会被我们的 monitor 吃掉,不会漏到 Claude CLI 终端里变成 CSI 乱码。 + .macOSDictation + case .kimiCode: + .macOSDictation + case .doubao: + .functionRelay(appName: "Fn/Globe") + case .custom: + shortcut.keyCode == HIDUsage.f19 && shortcut.modifiers.isEmpty + ? .functionRelay(appName: "Fn / Globe") + : nil + case .codex: + nil + } + } + + private static let doubaoInputSourceID = "com.bytedance.inputmethod.doubaoime.pinyin" + private static let fnTriggerMacKeyCode: CGKeyCode = 79 + + private func appendDiagnostic(_ message: String) { + let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n" + let url = diagnosticLogURL + routeQueue.async { + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: url.path) { + try line.data(using: .utf8)?.write(to: url) + } else if let handle = try? FileHandle(forWritingTo: url) { + try handle.seekToEnd() + try handle.write(contentsOf: Data(line.utf8)) + try handle.close() + } + } catch { + voiceRelayLog.error("voice relay diagnostic write failed: \(error.localizedDescription, privacy: .public)") + } + } + } + + private var diagnosticLogURL: URL { + let directory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) + return directory.appendingPathComponent("voice-relay.log") + } +} + +private func normalizedModifierSet(from flags: CGEventFlags) -> Set { + var modifiers = Set() + if flags.contains(.maskControl) { + modifiers.insert(.control) + } + if flags.contains(.maskAlternate) { + modifiers.insert(.option) + } + if flags.contains(.maskShift) { + modifiers.insert(.shift) + } + if flags.contains(.maskCommand) { + modifiers.insert(.command) + } + return modifiers +} + +private func normalizedModifierSet(from flags: NSEvent.ModifierFlags) -> Set { + var modifiers = Set() + if flags.contains(.control) { + modifiers.insert(.control) + } + if flags.contains(.option) { + modifiers.insert(.option) + } + if flags.contains(.shift) { + modifiers.insert(.shift) + } + if flags.contains(.command) { + modifiers.insert(.command) + } + return modifiers +} + +private func macBinding(for shortcut: ShortcutBinding) -> VoiceTriggerBinding? { + guard let keyCode = macKeyCode(forHIDUsage: shortcut.keyCode) else { return nil } + return VoiceTriggerBinding(keyCode: keyCode, modifiers: Set(shortcut.modifiers)) +} + +private func macKeyName(for keyCode: CGKeyCode) -> String { + switch keyCode { + case 122: return "F1" + case 120: return "F2" + case 99: return "F3" + case 118: return "F4" + case 96: return "F5" + case 97: return "F6" + case 98: return "F7" + case 100: return "F8" + case 101: return "F9" + case 109: return "F10" + case 103: return "F11" + case 111: return "F12" + case 105: return "F13" + case 107: return "F14" + case 113: return "F15" + case 106: return "F16" + case 64: return "F17" + case 79: return "F18" + case 80: return "F19" + case 36: return "Return" + case 53: return "Escape" + case 51: return "Delete" + case 48: return "Tab" + case 49: return "Space" + case 57: return "CapsLock" + case 117: return "ForwardDelete" + case 124: return "→" + case 123: return "←" + case 125: return "↓" + case 126: return "↑" + case 0 ... 25: + let letters: [CGKeyCode: String] = [ + 0: "A", 11: "B", 8: "C", 2: "D", 14: "E", 3: "F", 5: "G", + 4: "H", 34: "I", 38: "J", 40: "K", 37: "L", 46: "M", 45: "N", + 31: "O", 35: "P", 12: "Q", 15: "R", 1: "S", 17: "T", 32: "U", + 9: "V", 13: "W", 7: "X", 16: "Y", 6: "Z", + ] + return letters[keyCode] ?? "Key \(keyCode)" + default: + return "Key \(keyCode)" + } +} + +private func macKeyCode(forHIDUsage hidCode: UInt8) -> CGKeyCode? { + switch hidCode { + case HIDUsage.f1: return 122 + case HIDUsage.f2: return 120 + case HIDUsage.f3: return 99 + case HIDUsage.f4: return 118 + case HIDUsage.f5: return 96 + case HIDUsage.f6: return 97 + case HIDUsage.f7: return 98 + case HIDUsage.f8: return 100 + case HIDUsage.f9: return 101 + case HIDUsage.f10: return 109 + case HIDUsage.f11: return 103 + case HIDUsage.f12: return 111 + case HIDUsage.f13: return 105 + case HIDUsage.f14: return 107 + case HIDUsage.f15: return 113 + case HIDUsage.f16: return 106 + case HIDUsage.f17: return 64 + case HIDUsage.f18: return 79 + case HIDUsage.f19: return 80 + case HIDUsage.enter: return 36 + case HIDUsage.escape: return 53 + case HIDUsage.backspace: return 51 + case HIDUsage.tab: return 48 + case HIDUsage.space: return 49 + case HIDUsage.capsLock: return 57 + case HIDUsage.deleteForward: return 117 + case HIDUsage.rightArrow: return 124 + case HIDUsage.leftArrow: return 123 + case HIDUsage.downArrow: return 125 + case HIDUsage.upArrow: return 126 + case 0x04: return 0 + case 0x05: return 11 + case 0x06: return 8 + case 0x07: return 2 + case 0x08: return 14 + case 0x09: return 3 + case 0x0A: return 5 + case 0x0B: return 4 + case 0x0C: return 34 + case 0x0D: return 38 + case 0x0E: return 40 + case 0x0F: return 37 + case 0x10: return 46 + case 0x11: return 45 + case 0x12: return 31 + case 0x13: return 35 + case 0x14: return 12 + case 0x15: return 15 + case 0x16: return 1 + case 0x17: return 17 + case 0x18: return 32 + case 0x19: return 9 + case 0x1A: return 13 + case 0x1B: return 7 + case 0x1C: return 16 + case 0x1D: return 6 + case 0x1E: return 18 + case 0x1F: return 19 + case 0x20: return 20 + case 0x21: return 21 + case 0x22: return 23 + case 0x23: return 22 + case 0x24: return 26 + case 0x25: return 28 + case 0x26: return 25 + case 0x27: return 29 + default: + return nil + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift new file mode 100644 index 00000000..08290012 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift @@ -0,0 +1,160 @@ +import AppKit +import SwiftUI + +@MainActor +final class VoiceStatusHUDController { + static let shared = VoiceStatusHUDController() + + private var panel: NSPanel? + private var hostingView: NSHostingView? + private var hideWorkItem: DispatchWorkItem? + + private init() {} + + func show(_ state: VoiceStatusHUDState, autoHideAfter delay: TimeInterval? = nil) { + hideWorkItem?.cancel() + let view = VoiceStatusHUDView(state: state) + if let hostingView { + hostingView.rootView = view + } else { + let hostingView = NSHostingView(rootView: view) + hostingView.frame = NSRect(x: 0, y: 0, width: 260, height: 58) + self.hostingView = hostingView + } + + let panel = ensurePanel() + if let hostingView, hostingView.superview == nil { + panel.contentView = hostingView + } + position(panel) + panel.orderFrontRegardless() + + if let delay { + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor in + self?.hide() + } + } + hideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + + func hide() { + hideWorkItem?.cancel() + hideWorkItem = nil + panel?.orderOut(nil) + } + + private func ensurePanel() -> NSPanel { + if let panel { return panel } + + let panel = NonActivatingVoiceHUDPanel( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 58), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] + panel.hidesOnDeactivate = false + panel.ignoresMouseEvents = true + self.panel = panel + return panel + } + + private func position(_ panel: NSPanel) { + let screen = NSScreen.main ?? NSScreen.screens.first + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let size = panel.frame.size + let x = visibleFrame.midX - size.width / 2 + let y = visibleFrame.minY + 44 + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} + +final class NonActivatingVoiceHUDPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +struct VoiceStatusHUDState: Equatable { + enum Kind { + case recording + case processing + case success + case warning + } + + let kind: Kind + let title: String + let subtitle: String + + static let recording = VoiceStatusHUDState(kind: .recording, title: "录音中", subtitle: "再次按语音键结束") + static let recognizing = VoiceStatusHUDState(kind: .processing, title: "本地识别中", subtitle: "正在整理语音文本") + static let ahaType = VoiceStatusHUDState(kind: .processing, title: "AhaType 整理中", subtitle: "云端正在优化文本") + static let pasting = VoiceStatusHUDState(kind: .processing, title: "准备粘贴", subtitle: "正在写入当前光标") + static let done = VoiceStatusHUDState(kind: .success, title: "已写入", subtitle: "语音文本已完成") + static let empty = VoiceStatusHUDState(kind: .warning, title: "未识别到内容", subtitle: "请靠近麦克风重试") + static let failed = VoiceStatusHUDState(kind: .warning, title: "写入失败", subtitle: "请检查输入权限") +} + +private struct VoiceStatusHUDView: View { + let state: VoiceStatusHUDState + + var body: some View { + HStack(spacing: 12) { + indicator + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 2) { + Text(state.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + Text(state.subtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .frame(width: 260, height: 58) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.92)) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.primary.opacity(0.10), lineWidth: 1) + ) + } + + @ViewBuilder + private var indicator: some View { + switch state.kind { + case .recording: + ZStack { + Circle() + .fill(Color.red.opacity(0.16)) + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + } + case .processing: + ProgressView() + .controlSize(.small) + case .success: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(.green) + case .warning: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(.orange) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift new file mode 100644 index 00000000..e2c7eb80 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift @@ -0,0 +1,5045 @@ +import AppKit +import CoreImage +import Darwin +import SwiftUI +import UniformTypeIdentifiers + +struct AhaKeyStudioView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @StateObject private var voiceRelay = VoiceRelayService.shared + @StateObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + @StateObject private var ahaType = AhaTypeTextOptimizer.shared + @StateObject private var cloudAccount = CloudAccountManager.shared + @StateObject private var agentManager = AgentManager.shared + + @State private var studioDraft: AhaKeyStudioDraft + @State private var lastSyncedDraft: AhaKeyStudioDraft + @State private var selectedMode: AhaKeyModeSlot + @State private var selectedPart: AhaKeyStudioPart + @State private var lightBarPreview: IDEState + @State private var modeCustomNames: [Int: String] = [:] + @State private var lastSyncDate: Date? + @State private var syncStatusMessage = "修改会先保存在本地,连接设备后再同步。" + @State private var isSyncing = false + // AhaKeyStudio 交还蓝牙给 Agent 的过渡期:保持"已连接"显示,直到 Agent 接管或超时。 + @State private var isTransitioningToKeyboardControl = false + @State private var showsOLEDPlaybackPreview = false + @State private var showsDeviceInfo = false + @State private var showsCloudAccount = false + @State private var showsAhaTypeLoginRequiredToast = false + @AppStorage(UnifiedOnboardingStorage.completedKey) private var unifiedOnboardingCompleted = false + @State private var isEditingInspector = false + @State private var showsDiagnostics = false + @State private var showsKeyHelp = false + @State private var selectedTriggerTab: Int = 0 + /// 每次主 App 自占 BLE 连接成功只跑一次默认 LCD 自动同步。 + /// .onChange(of: isConnected) 在断开时重置;下次重连时再触发一次。 + @State private var oledAutoSyncDoneForConnection: Bool = false + @State private var showsHelpCenter = false + @State private var showsGuidanceDetail = false + @State private var editingModeSlot: AhaKeyModeSlot? + @State private var editingModeName: String = "" + @FocusState private var modeNameFieldFocused: Bool + @State private var showsWriteResultAlert = false + @State private var writeResultAlertMessage = "" + + init(bleManager: AhaKeyBLEManager) { + self.bleManager = bleManager + let initialDraft = AhaKeyStudioStore.load() ?? .default + // 注意:不要在这里调用 VoiceRelayService.updateRoutes —— SwiftUI 会因 bleManager + // 的 @Published 属性(workMode/电量/连接状态等)频繁重建 view,init 会跟着多次执行。 + // 任何在 init 里调用 updateRoutes 都会重置 functionRelay 的 holdingRoute(按住状态), + // 导致微信等"按住说话"过几秒就自动结束。正确入口在下面的 .onAppear。 + _studioDraft = State(initialValue: initialDraft) + _lastSyncedDraft = State(initialValue: initialDraft) + let initialMode = AhaKeyModeSlot(rawValue: bleManager.workMode) ?? .mode0 + _selectedMode = State(initialValue: initialMode) + _selectedPart = State(initialValue: .key1) + _lightBarPreview = State(initialValue: .preToolUse) + _modeCustomNames = State(initialValue: AhaKeyModeNameStore.load()) + } + + var body: some View { + VStack(spacing: 0) { + topBar + Divider() + HStack(spacing: 0) { + canvasPane + Divider() + inspectorPane + } + Divider() + statusBar + } + .frame(minWidth: 1180, minHeight: 680) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { + agentManager.applyStoredBluetoothPreferenceOnLaunch(bleManager: bleManager) + voiceRelay.start() + nativeSpeech.start() + bleManager.refreshBluetoothAuthorization() + applyCursorRejectMacroSelfHealIfNeeded() + voiceRelay.updateRoutes(from: studioDraft) + SwitchStateNotifier.shared.bind(to: bleManager) + NotificationCenter.default.post( + name: .ahaKeyKeyboardWorkModeChanged, + object: nil, + userInfo: ["workMode": bleManager.workMode] + ) + scheduleStartupPermissionOnboarding() + } + .onChange(of: studioDraft) { newValue in + AhaKeyStudioStore.save(newValue) + voiceRelay.updateRoutes(from: newValue) + } + // 键盘物理档位变化(BLE 查询/通知上报)→ 自动切到对应 Mode 标签, + // 这样 LCD 预览、快捷键草稿、发出去的 updateState 三者一致。 + .onChange(of: bleManager.workMode) { newValue in + if let slot = AhaKeyModeSlot(rawValue: newValue), slot != selectedMode { + selectedMode = slot + } + } + .onChange(of: selectedMode) { newValue in + guard bleManager.isConnected, + bleManager.commandCharReady, + bleManager.workMode != newValue.rawValue else { return } + bleManager.setWorkMode(UInt8(newValue.rawValue)) + syncStatusMessage = "已通知键盘切换到 \(newValue.title)。" + } + .onChange(of: bleManager.isConnected) { connected in + if !connected { oledAutoSyncDoneForConnection = false } + } + .onChange(of: bleManager.keyboardPictureStates) { _ in + guard !oledAutoSyncDoneForConnection else { return } + // 四个 mode 都查回来才动手 + guard bleManager.keyboardPictureStates.count == AhaKeyModeSlot.allCases.count else { return } + oledAutoSyncDoneForConnection = true + Task { await autoSyncDefaultOLEDsIfNeeded() } + } + .onChange(of: bleManager.bluetoothPermissionGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: bleManager.bluetoothPoweredOn) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: voiceRelay.inputMonitoringGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: voiceRelay.accessibilityGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.microphoneGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.speechRecognitionGranted) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.siriEnabled) { _ in + refreshStartupPermissionOnboarding() + } + .onChange(of: nativeSpeech.dictationEnabled) { _ in + refreshStartupPermissionOnboarding() + } + .alert("Agent", isPresented: Binding( + get: { agentManager.agentUserAlert != nil }, + set: { if !$0 { agentManager.agentUserAlert = nil } } + )) { + Button("好", role: .cancel) { + agentManager.agentUserAlert = nil + } + } message: { + Text(agentManager.agentUserAlert ?? "") + } + .alert("AhaType 未注册登录", isPresented: $showsAhaTypeLoginRequiredToast) { + Button("知道了", role: .cancel) {} + Button("注册登录") { + showsCloudAccount = true + } + } message: { + Text("请先注册登录 AhaType 后再开启云端整理。") + } + .sheet(isPresented: $showsOLEDPlaybackPreview) { + OLEDMotionPreviewSheet( + modeTitle: selectedMode.title, + assetPath: currentModeDraft.oled.localAssetPath + ) + } + .sheet(isPresented: $showsDeviceInfo) { + DeviceInfoSheetContainer(bleManager: bleManager) + .frame(width: 720, height: 720) + } + .sheet(isPresented: $showsCloudAccount) { + CloudAccountView() + .frame(width: 520, height: 620) + } + } + + private var topBar: some View { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 0) { + Text("AhaKey Studio") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + } + .layoutPriority(1) + + HStack(spacing: 8) { + infoPill( + title: isEffectivelyConnected ? "已连接" : (bleManager.isScanning ? "扫描中" : "未连接"), + subtitle: bleManager.deviceName ?? "等待设备", + accent: isEffectivelyConnected ? .green : .orange, + width: 118 + ) + infoPill( + title: "电量", + subtitle: isEffectivelyConnected ? "\(bleManager.batteryLevel)%" : "—", + accent: .blue + ) + infoPill( + title: "拨杆", + subtitle: currentSwitchTitle, + accent: currentSwitchTitle == "自动批准" ? .mint : .indigo + ) + } + .layoutPriority(2) + + Spacer(minLength: 0) + + if !bleManager.isConnected, agentManager.bluetoothConnectionOwner == .ahaKeyStudio { + Button(bleManager.isScanning ? "扫描中…" : "连接设备") { + bleManager.userInitiatedConnect() + } + .buttonStyle(.bordered) + .disabled(bleManager.isScanning) + } + + ahaTypeModeStatus + + configurationModeStatus + + if shouldShowTopBarInstallStartButton { + Button("安装启动") { + installStartAgentFromTopBar() + } + .buttonStyle(.borderedProminent) + .disabled(agentManager.isAgentOperationInProgress) + .help("安装/修复 Agent 与 Hook,并启动 Agent 控制键盘。") + } + + Button { + NSPasteboard.general.clearContents() + } label: { + Image(systemName: "arrow.counterclockwise.circle") + .imageScale(.medium) + } + .buttonStyle(.bordered) + .help("清空剪贴板") + + Menu { + Button("恢复当前模式默认值") { + restoreCurrentModeDefaults() + } + Button("重新连接设备") { + bleManager.disconnect() + bleManager.userInitiatedConnect() + } + Button("设备信息 · Agent…") { + showsDeviceInfo = true + } + Divider() + Button("云端账号 · AhaType…") { + showsCloudAccount = true + } + Button("刷新 AhaType 状态") { + ahaType.refreshFromDisk() + } + Divider() + Button("隐藏到后台") { + NSApp.keyWindow?.close() + } + Button("退出 AhaKey Studio") { + NSApp.terminate(nil) + } + } label: { + Image(systemName: "ellipsis.circle") + .imageScale(.large) + } + .menuStyle(.borderlessButton) + .frame(width: 32, height: 28) + .help("更多") + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + .background(chromeBarBackground) + } + + private var configurationModeStatus: some View { + HStack(spacing: 8) { + Circle() + .fill(isEditingConfiguration ? Color.blue : Color.green) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(isEditingConfiguration ? "编辑配置中" : "键盘控制中") + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + Text(configurationModeDetail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(width: 138, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .help("日常使用由 Agent 控制键盘;需要改键、LCD 或同步时,进入编辑配置后由 AhaKey Studio 临时接管蓝牙。") + } + + private var ahaTypeModeStatus: some View { + HStack(spacing: 7) { + Circle() + .fill(ahaType.isEnabled ? Color.green : Color.gray.opacity(0.55)) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(ahaType.isEnabled ? "AhaType 开启" : "AhaType 关闭") + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + Text(ahaType.isEnabled ? "云端整理已启用" : "语音结果直接粘贴") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Toggle("", isOn: Binding( + get: { ahaType.isEnabled }, + set: { enabled in + if enabled, !cloudAccount.isLoggedIn { + showsAhaTypeLoginRequiredToast = true + } else { + ahaType.setEnabled(enabled) + } + } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + .frame(width: 150, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .help("开启后,macOS 原生语音转写会先经过 AhaType 云端整理,再粘贴到当前光标。") + } + + private var canvasPane: some View { + VStack(alignment: .leading, spacing: 18) { + modeEditorHeader + + VStack(alignment: .leading, spacing: 8) { + AhaKeyKeyboardCanvasView( + modeDraft: currentModeDraft, + selectedPart: selectedPart, + lightBarPreview: lightBarPreview, + switchTitle: currentSwitchTitle, + dirtyParts: dirtyPartsForCurrentMode(), + onSelect: { selectedPart = $0 }, + onModeSwitch: { cycleModeForward() }, + onSwitchToggle: { toggleVirtualSwitch() }, + liveLightMode: liveCanvasLightMode, + liveIDEStateValue: liveCanvasIDEStateValue, + switchState: liveCanvasSwitchState, + keyboardPictureFrameCount: bleManager.keyboardPictureStates[selectedMode.rawValue]?.frameCount + ) + .aspectRatio(109.0 / 54.0, contentMode: .fit) + .frame(maxWidth: .infinity) + + Text("点按灯条、屏幕、四个按键或拨杆即可进入对应配置。") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + .frame(maxWidth: .infinity) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func modeTabItem(_ mode: AhaKeyModeSlot) -> some View { + let isSelected = selectedMode == mode + let isEditing = editingModeSlot == mode + + if isEditing { + TextField("", text: $editingModeName, onCommit: { commitModeNameEdit() }) + .textFieldStyle(.plain) + .font(.callout.weight(.medium)) + .multilineTextAlignment(.center) + .focused($modeNameFieldFocused) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .padding(.horizontal, 4) + .background(Color.accentColor.opacity(0.15)) + .onExitCommand { commitModeNameEdit() } + .onAppear { modeNameFieldFocused = true } + .onChange(of: modeNameFieldFocused) { focused in + if !focused { commitModeNameEdit() } + } + } else { + Text(modeCustomNames[mode.rawValue] ?? mode.defaultName) + .font(.callout.weight(isSelected ? .semibold : .regular)) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .padding(.horizontal, 4) + .background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + commitModeNameEdit() + editingModeName = modeCustomNames[mode.rawValue] ?? mode.defaultName + editingModeSlot = mode + selectedMode = mode + } + .onTapGesture(count: 1) { + commitModeNameEdit() + selectedMode = mode + } + } + } + + private func commitModeNameEdit() { + guard let slot = editingModeSlot else { return } + let capped = String(editingModeName.prefix(30)) + if capped.isEmpty || capped == slot.defaultName { + modeCustomNames.removeValue(forKey: slot.rawValue) + } else { + modeCustomNames[slot.rawValue] = capped + } + AhaKeyModeNameStore.save(modeCustomNames) + editingModeSlot = nil + } + + private var modeEditorHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text("Keyboard Mode") + .font(.system(size: 17, weight: .semibold)) + + HStack(spacing: 0) { + ForEach(AhaKeyModeSlot.allCases) { mode in + modeTabItem(mode) + } + } + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)) + .frame(width: 480) + + Spacer(minLength: 0) + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(selectedMode.guidance) + .font(.callout) + .foregroundStyle(.secondary) + if let detail = selectedMode.guidanceHoverDetail { + Button { + showsGuidanceDetail.toggle() + } label: { + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + .imageScale(.small) + } + .buttonStyle(.borderless) + .help(detail) + .onHover { showsGuidanceDetail = $0 } + .popover(isPresented: $showsGuidanceDetail, arrowEdge: .top) { + Text(detail) + .font(.callout) + .padding(14) + .frame(width: 320) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var inspectorPane: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if isEditingInspector { + Label(selectedPart.title, systemImage: selectedPart.systemImage) + .font(.system(size: 18, weight: .semibold)) + + Group { + switch selectedPart { + case .key1, .key2, .key3, .key4: keyInspector + case .oledDisplay: oledInspector + case .lightBar: lightBarInspector + case .toggleSwitch: switchInspector + } + } + + } else { + inspectorHeader + + VStack(alignment: .leading, spacing: 0) { + partSummaryContent + } + .padding(.horizontal, 14) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.black.opacity(0.07), lineWidth: 1) + ) + ) + + HStack { + Spacer() + Button { + enterEditingConfiguration() + withAnimation(.easeInOut(duration: 0.2)) { isEditingInspector = true } + } label: { + Label("修改", systemImage: "pencil") + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .keyboardShortcut("e", modifiers: .command) + } + .padding(.top, 6) + } + } + .padding(24) + } + + if isEditingInspector { + Divider() + HStack(spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isEditingInspector = false + returnToKeyboardControl() + } + } label: { + Label("返回", systemImage: "chevron.left") + .font(.callout.weight(.medium)) + } + .buttonStyle(.bordered) + .controlSize(.regular) + + Spacer() + + if selectedPart == .lightBar { + Button { + previewLightEffect(for: lightBarPreview) + } label: { + Label("预览到键盘", systemImage: "play.fill") + .font(.callout.weight(.medium)) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(isSyncing || !bleManager.isConnected || !bleManager.commandCharReady) + } + + Button { + writeToKeyboard() + } label: { + Label(isSyncing ? "写入中…" : "写入键盘", systemImage: isSyncing ? "arrow.trianglehead.2.clockwise" : "square.and.arrow.down") + .font(.callout.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(isSyncing || !bleManager.isConnected) + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + } + } + .frame(width: 390) + .frame(maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.35)) + .onChange(of: selectedPart) { _ in + commitModeNameEdit() + withAnimation(.easeInOut(duration: 0.18)) { isEditingInspector = false } + } + .onChange(of: selectedMode) { _ in + if editingModeSlot != nil && editingModeSlot != selectedMode { + commitModeNameEdit() + } + } + .alert("写入结果", isPresented: $showsWriteResultAlert) { + Button("继续编辑", role: .cancel) {} + Button("完成编辑") { + if writeResultAlertMessage.contains("成功") { + completeEditingAfterSuccessfulWrite() + } + } + .keyboardShortcut(.defaultAction) + } message: { + Text(writeResultAlertMessage) + } + } + + private var inspectorHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + Label(selectedPart.title, systemImage: selectedPart.systemImage) + .font(.system(size: 20, weight: .semibold)) + Spacer() + if partIsDirty(selectedPart) { + Label("未同步", systemImage: "circle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + if selectedPart.isKey { + Button { + showsKeyHelp.toggle() + } label: { + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + .imageScale(.medium) + } + .buttonStyle(.borderless) + .onHover { showsKeyHelp = $0 } + .popover(isPresented: $showsKeyHelp, arrowEdge: .leading) { + VStack(alignment: .leading, spacing: 8) { + Text("如何使用") + .font(.headline) + Divider() + Text("1. 点击虚拟键盘对应按键选中它。") + Text("2. 语音键先选预设;其他键按需选单键或宏。") + Text("3. 配置完成后点「写入键盘」同步到键盘。") + Text("4. 切模式时 LCD 先显示描述,再回到该模式动图。") + } + .font(.callout) + .padding(16) + .frame(width: 270) + } + } + } + Text(selectedPart.subtitle) + .font(.callout) + .foregroundStyle(.secondary) + } + } + + // MARK: - 权限诊断弹窗 + + private var diagnosticsSheet: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("权限诊断") + .font(.system(size: 20, weight: .semibold)) + Spacer() + Button("关闭") { showsDiagnostics = false } + .buttonStyle(.bordered) + } + + GroupBox("后台语音桥") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle() + .fill(voiceRelay.isListening ? Color.green : Color.orange) + .frame(width: 10, height: 10) + Text(voiceRelay.isListening ? "后台监听中" : "等待系统权限") + .font(.callout.weight(.semibold)) + Spacer() + } + HStack(spacing: 10) { + permissionBadge(title: "输入监控", granted: voiceRelay.inputMonitoringGranted) + permissionBadge(title: "辅助功能", granted: voiceRelay.accessibilityGranted) + } + Text(voiceRelay.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Text(voiceRelay.lastPermissionCheckSummary) + .font(.caption) + .foregroundStyle(.secondary) + Text(voiceRelay.activeRouteSummary) + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Button("再次申请权限") { + requestPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + } + .buttonStyle(.borderedProminent) + Button("重新检查权限") { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) + RestartToApplyPermissionsButton() + } + } + .padding(.top, 4) + } + + GroupBox("苹果原生转写") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle() + .fill(nativeSpeech.isRecording ? Color.red : (nativeSpeech.microphoneGranted && nativeSpeech.speechRecognitionGranted ? Color.green : Color.orange)) + .frame(width: 10, height: 10) + Text(nativeSpeech.isRecording ? "录音转写中" : "等待触发") + .font(.callout.weight(.semibold)) + Spacer() + } + HStack(spacing: 10) { + permissionBadge(title: "麦克风", granted: nativeSpeech.microphoneGranted) + permissionBadge(title: "语音转写", granted: nativeSpeech.speechRecognitionGranted) + permissionBadge(title: "Siri", granted: nativeSpeech.siriEnabled) + permissionBadge(title: "听写", granted: nativeSpeech.dictationEnabled) + } + Text(nativeSpeech.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Text(nativeSpeech.lastPermissionCheckSummary) + .font(.caption) + .foregroundStyle(.secondary) + Divider() + + HStack(spacing: 10) { + Circle() + .fill(nativeSpeech.isRecording ? Color.red : Color.clear) + .frame(width: 8, height: 8) + Text(nativeSpeech.isRecording ? "录音中" : "转写测试") + .font(.callout.weight(.semibold)) + Spacer() + if !nativeSpeech.transcriptPreview.isEmpty { + Text(nativeSpeech.transcriptPreview) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else if !nativeSpeech.lastCommittedText.isEmpty { + Text("最近写入:\(nativeSpeech.lastCommittedText)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + HStack(spacing: 8) { + Button(nativeSpeech.isRecording ? "结束并写入" : "开始录音") { + nativeSpeech.toggleRecordingFromVoiceKey() + } + .buttonStyle(.borderedProminent) + Button("重新检查权限") { + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) + RestartToApplyPermissionsButton() + if !nativeSpeechPermissionsReady { + Button("打开系统设置") { openNativeSpeechPrivacySettings() } + .buttonStyle(.bordered) + } + } + } + .padding(.top, 4) + } + + let voiceKey = currentModeDraft.key(for: .voice) + if let preset = voiceKey.voicePreset, preset == .typeless { + GroupBox("Fn 语音输入法") { + VStack(alignment: .leading, spacing: 8) { + Text("Typeless / 微信语音 / 豆包输入法使用 F19 触发,并注入 Fn 按住/松开。") + .font(.caption) + .foregroundStyle(.secondary) + Text("排查请看 voice-relay.log(matched · function relay · post fn)。路径:~/Library/Application Support/AhaKeyConfig/diagnostics/") + .font(.caption2) + .foregroundStyle(.tertiary) + Button("模拟按一次语音键") { + voiceRelay.simulateInspectorVoiceKeyTap(for: selectedMode) + } + .buttonStyle(.borderedProminent) + if let hint = voiceRelay.lastInspectorSimulateHint { + Text(hint) + .font(.caption) + .foregroundStyle(.orange) + } + } + .padding(.top, 4) + } + } + + GroupBox("AhaType 状态") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Toggle(isOn: Binding( + get: { ahaType.isEnabled }, + set: { ahaType.setEnabled($0) } + )) { + Text("AhaType 云端整理") + .font(.callout.weight(.semibold)) + } + .toggleStyle(.switch) + Spacer() + Button("刷新") { ahaType.refreshFromDisk() } + .buttonStyle(.borderless) + .font(.caption) + } + Text(ahaType.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Text(ahaType.lastQuotaSummary) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.top, 4) + } + } + .padding(24) + } + .frame(width: 500, height: 620) + } + + // MARK: - Inspector Level 1 Summary + + private func summaryRow(_ title: String, value: String, dot: Color? = nil) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(title) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .leading) + HStack(spacing: 5) { + if let dot { + Circle() + .fill(dot) + .frame(width: 7, height: 7) + .offset(y: -1) + } + Text(value) + .font(.callout) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.06)) + .frame(height: 0.5) + } + } + + @ViewBuilder + private var partSummaryContent: some View { + switch selectedPart { + case .key1: voiceKeySummary + case .key2, .key3, .key4: actionKeySummary + case .oledDisplay: oledSummary + case .lightBar: lightBarSummary + case .toggleSwitch: switchSummary + } + } + + @ViewBuilder + private var voiceKeySummary: some View { + let key = currentSelectedKey + let preset = key.voicePreset ?? .custom + summaryRow("输入方式", value: preset.title) + summaryRow("快捷键", value: key.displaySummary) + if preset.isMacOSNativeFamily { + summaryRow("触发方式", value: "短按 + 长按") + let permCount = [nativeSpeech.microphoneGranted, nativeSpeech.speechRecognitionGranted, + nativeSpeech.siriEnabled, nativeSpeech.dictationEnabled].filter { $0 }.count + summaryRow("转写权限", value: "\(permCount)/4 已授权", + dot: permCount == 4 ? .green : .orange) + } + summaryRow("语音桥", value: voiceRelay.isListening ? "运行中" : "等待权限", + dot: voiceRelay.isListening ? .green : .orange) + summaryRow("按键描述", value: key.description.isEmpty ? "—" : key.description) + } + + @ViewBuilder + private var actionKeySummary: some View { + let key = currentSelectedKey + summaryRow("绑定", value: key.displaySummary) + summaryRow("类型", value: key.usesMacro ? "固件宏(\(key.macro.count) 步)" : "单键 / 组合键") + summaryRow("按键描述", value: key.description.isEmpty ? "—" : key.description) + } + + @ViewBuilder + private var oledSummary: some View { + let oled = currentModeDraft.oled + summaryRow("动图", value: oled.localAssetPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "默认动图") + summaryRow("播放速度", value: "\(oled.framesPerSecond) FPS") + summaryRow("状态行", value: oled.statusLine.isEmpty ? "—" : String(oled.statusLine.prefix(32))) + } + + @ViewBuilder + private var lightBarSummary: some View { + let lb = currentModeDraft.lightBar + ForEach(IDEState.allCases) { state in + summaryRow(state.shortLabel, value: lb.effect(for: state).title) + } + summaryRow("亮度", value: "\(lb.brightness)%") + } + + @ViewBuilder + private var switchSummary: some View { + let agentReady = agentManager.isInstalled && agentManager.isRunning && agentManager.hooksInstalled + summaryRow("当前档位", value: currentSwitchTitle, + dot: currentSwitchTitle == "自动批准" ? .green : .indigo) + summaryRow("Agent", value: agentReady ? "就绪" : "未就绪", + dot: agentReady ? .green : .orange) + summaryRow("作用范围", value: "Claude · Cursor · Codex · Kimi") + } + + // MARK: - Inspector Level 2 Detail + + private var keyInspector: some View { + let key = currentSelectedKey + return VStack(alignment: .leading, spacing: 16) { + GroupBox("按键描述") { + VStack(alignment: .leading, spacing: 8) { + TextField("例如 Record / Accept / Reject / Backspace", text: selectedKeyDescriptionBinding) + .textFieldStyle(.roundedBorder) + if currentSelectedKey.description.containsNonASCII { + Text("设备 LCD 只稳定支持 ASCII。中文、emoji 和全角字符会在写入时被自动过滤,避免乱码。") + .font(.caption) + .foregroundStyle(.orange) + } + Text("设备实际写入:\(currentSelectedKeySanitizedDescription.isEmpty ? "空白" : currentSelectedKeySanitizedDescription)") + .font(.caption) + .foregroundStyle(.secondary) + Text("同步到键盘后,短按实体键切换模式时,LCD 会先短暂显示这里的描述,然后回到该模式的动图。") + .font(.caption) + .foregroundStyle(.secondary) + if selectedMode == .mode0 { + Text("Mode 1 默认文案:Record / Accept / Reject / Backspace") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.top, 4) + } + + if key.role == .voice { + GroupBox("语音输入方式") { + VStack(alignment: .leading, spacing: 12) { + VoicePresetPicker( + selectedPreset: key.voicePreset ?? .custom, + onSelect: applyVoicePreset + ) + if (key.voicePreset ?? .custom).isMacOSNativeFamily { + Text("只要 AhaKey Studio 在后台运行,Mode 1 出厂语音键发出的 F18 就会被直接接管到苹果原生转写。现在不再依赖系统听写快捷键。") + .font(.caption) + .foregroundStyle(.secondary) + } + Text("语音键的输入方式独立于当前 Mode,在任意 Mode 下都可使用相同的语音输入设置。") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.top, 4) + } + } else { + GroupBox("按键职责") { + VStack(alignment: .leading, spacing: 8) { + Text(key.role.manualText) + .font(.callout) + Text("当前会把快捷键和按键描述一起写入键盘。切换模式时,设备会先显示描述,再回到该模式的 LCD 动图。") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // ── 触发方式(短按 / 长按 Tab)────────────────────────────── + GroupBox("触发方式") { + VStack(alignment: .leading, spacing: 12) { + Picker("", selection: $selectedTriggerTab) { + Text("短按").tag(0) + Text("长按").tag(1) + } + .pickerStyle(.segmented) + .labelsHidden() + + Divider() + + if key.role == .voice { + // ── 语音键触发方式 ────────────────────────────── + if selectedTriggerTab == 0 { + VStack(alignment: .leading, spacing: 10) { + Label("按一下开始,再按一下结束", systemImage: "hand.tap.fill") + .font(.callout.weight(.semibold)) + Text("录音结束后根据下方开关决定是否经 AhaType 整理,再写入光标。") + .font(.caption) + .foregroundStyle(.secondary) + Toggle(isOn: $nativeSpeech.shortPressAhaTypeEnabled) { + HStack(spacing: 6) { + Text("使用 AhaType 整理") + .font(.callout) + if !ahaType.isEnabled { + Text("(AhaType 总开关已关闭)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(.switch) + .disabled(!ahaType.isEnabled) + + Divider() + + // 绑定摘要(短按 = 语音键 HID 绑定) + HStack { + Text(key.displaySummary) + .font(.system(.callout, design: .rounded).weight(.semibold)) + .lineLimit(1) + Spacer() + Text(key.usesMacro ? "固件宏" : "底层 HID") + .font(.caption) + .foregroundStyle(.secondary) + } + Picker("", selection: selectedKeyBindingModeBinding) { + Text("单键 / 组合键").tag(KeyBindingMode.shortcut) + Text("宏").tag(KeyBindingMode.macro) + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled((key.voicePreset ?? .custom) != .custom) + if key.usesMacro { + macroEditor(for: key) + } else { + ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) + } + Text(voicePresetDetail) + .font(.caption) + .foregroundStyle(.secondary) + if (key.voicePreset ?? .custom) != .custom { + Text("语音键预设会固定使用单键绑定;如需录制宏,请先把预设改为自定义快捷键。") + .font(.caption) + .foregroundStyle(.orange) + } + } + } else { + // 长按 Tab(语音键)— 始终开启,仅配置 AhaType 与阈值 + VStack(alignment: .leading, spacing: 10) { + Label("按住录音,松手即发送", systemImage: "hand.draw.fill") + .font(.callout.weight(.semibold)) + Text("按住键盘录音键不松手开始录音,松手后直接将 ASR 结果写入,响应更快。") + .font(.caption) + .foregroundStyle(.secondary) + Toggle(isOn: $nativeSpeech.longPressAhaTypeEnabled) { + HStack(spacing: 6) { + Text("使用 AhaType 整理") + .font(.callout) + if !ahaType.isEnabled { + Text("(AhaType 总开关已关闭)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(.switch) + .disabled(!ahaType.isEnabled) + HStack(spacing: 10) { + Text("触发阈值") + .font(.callout) + Slider( + value: Binding( + get: { Double(nativeSpeech.longPressThresholdMs) }, + set: { nativeSpeech.longPressThresholdMs = Int($0) } + ), + in: 200...1000, + step: 50 + ) + Text("\(nativeSpeech.longPressThresholdMs) ms") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 58, alignment: .trailing) + } + } + } + } else { + // ── 普通键触发方式 ────────────────────────────── + if selectedTriggerTab == 0 { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(key.displaySummary) + .font(.system(.callout, design: .rounded).weight(.semibold)) + .lineLimit(2) + Spacer() + Text(key.usesMacro ? "固件宏" : "底层 HID") + .font(.caption) + .foregroundStyle(.secondary) + } + Picker("", selection: selectedKeyBindingModeBinding) { + Text("单键 / 组合键").tag(KeyBindingMode.shortcut) + Text("宏").tag(KeyBindingMode.macro) + } + .pickerStyle(.segmented) + .labelsHidden() + if key.usesMacro { + macroEditor(for: key) + } else { + ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) + } + } + } else { + VStack(alignment: .leading, spacing: 8) { + Label("需要固件 v2+ 支持", systemImage: "exclamationmark.triangle") + .font(.callout.weight(.semibold)) + .foregroundStyle(.orange) + Text("长按绑定不同快捷键需固件升级后生效,当前仅短按绑定会写入设备。") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.top, 4) + } + .onChange(of: selectedPart) { _ in selectedTriggerTab = 0 } + + } + } + + // MARK: - 宏编辑器视图 + + @ViewBuilder + private func macroEditor(for key: AhaKeyKeyDraft) -> some View { + let stepCount = key.macro.count + let byteCount = stepCount * 2 + let overLimit = byteCount > 98 // 固件 payload 上限 + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text("步骤(依次执行)") + .font(.callout.weight(.semibold)) + Spacer() + Text("\(stepCount) 步 · \(byteCount) / 98 字节") + .font(.caption) + .foregroundStyle(overLimit ? .red : .secondary) + } + + if key.macro.isEmpty { + Text("空宏。点下方「添加步骤」开始录制。") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(spacing: 6) { + ForEach(Array(key.macro.enumerated()), id: \.element.id) { index, step in + macroStepRow( + index: index, + step: step, + totalCount: key.macro.count + ) + } + } + } + + HStack(spacing: 10) { + Button { + appendMacroStep() + } label: { + Label("添加步骤", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + .disabled(overLimit) + + Button(role: .destructive) { + updateSelectedKey { $0.macro = [] } + } label: { + Label("清空", systemImage: "trash") + } + .buttonStyle(.bordered) + .disabled(key.macro.isEmpty) + } + + if overLimit { + Text("超过固件单键宏 98 字节 / 49 步上限,同步时会被拒绝。") + .font(.caption) + .foregroundStyle(.red) + } + + Text("固件按顺序串行发送;延时单位 3ms(最大 765ms)。需要更长延时请叠加多个延时步骤。") + .font(.caption) + .foregroundStyle(.secondary) + + if !key.macro.isEmpty { + Text("预览:\(key.macro.displaySummary)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func macroStepRow(index: Int, step: MacroStep, totalCount: Int) -> some View { + HStack(spacing: 8) { + Text("\(index + 1).") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 24, alignment: .trailing) + + Picker("", selection: macroStepActionBinding(id: step.id)) { + ForEach(MacroAction.allCases) { action in + Text(action.title).tag(action) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 96) + + if step.action.takesKeycodeParam { + Picker("", selection: macroStepKeycodeBinding(id: step.id)) { + Text("未设置").tag(UInt8(0)) + ForEach(HIDUsage.allOptions, id: \.code) { option in + Text(option.name).tag(option.code) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(minWidth: 96) + } else if step.action.takesDelayParam { + // 勿对带标题的 Stepper 用 labelsHidden(),否则连「15 ms」一并被藏掉。 + HStack(spacing: 8) { + Text("\(max(1, Int(step.param)) * 3) ms") + .font(.caption.monospacedDigit()) + .foregroundStyle(.primary) + .frame(minWidth: 44, alignment: .trailing) + Stepper( + "", + value: macroStepDelayBinding(id: step.id), + in: 1...255 + ) + .labelsHidden() + } + .frame(minWidth: 120) + } else { + Color.clear.frame(minWidth: 96, maxHeight: 1) + } + + Spacer(minLength: 0) + + Button { + moveMacroStep(from: index, by: -1) + } label: { + Image(systemName: "arrow.up") + } + .buttonStyle(.borderless) + .disabled(index == 0) + + Button { + moveMacroStep(from: index, by: 1) + } label: { + Image(systemName: "arrow.down") + } + .buttonStyle(.borderless) + .disabled(index >= totalCount - 1) + + Button(role: .destructive) { + removeMacroStep(at: index) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6)) + ) + } + + private func macroStepActionBinding(id: UUID) -> Binding { + Binding( + get: { + currentSelectedKey.macro.first { $0.id == id }?.action ?? .noOp + }, + set: { newAction in + updateMacroStep(id: id) { step in + let previous = step.action + step.action = newAction + // 动作换类别后清零 param,避免把 "Enter 的 HID 码 0x28" 当成延时值 ×3ms 解读。 + if previous.takesKeycodeParam != newAction.takesKeycodeParam + || previous.takesDelayParam != newAction.takesDelayParam + { + switch newAction { + case .delay: + step.param = 5 // 默认 15ms,比较通用 + case .downKey, .upKey: + step.param = HIDUsage.enter + case .noOp, .upAllKeys: + step.param = 0 + } + } + } + } + ) + } + + private func macroStepKeycodeBinding(id: UUID) -> Binding { + Binding( + get: { + currentSelectedKey.macro.first { $0.id == id }?.param ?? 0 + }, + set: { newValue in + updateMacroStep(id: id) { $0.param = newValue } + } + ) + } + + private func macroStepDelayBinding(id: UUID) -> Binding { + Binding( + get: { + currentSelectedKey.macro.first { $0.id == id }?.param ?? 0 + }, + set: { newValue in + updateMacroStep(id: id) { $0.param = newValue } + } + ) + } + + private var oledInspector: some View { + VStack(alignment: .leading, spacing: 16) { + GroupBox("当前模式的 LCD 动图") { + VStack(alignment: .leading, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.9)) + .frame(height: 140) + + if let image = currentOLEDPreviewImage { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 112) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + VStack(spacing: 10) { + Image(systemName: "photo.artframe") + .font(.system(size: 28)) + .foregroundStyle(.white.opacity(0.8)) + Text("当前仅支持动图") + .foregroundStyle(.white.opacity(0.85)) + Text("文字、token、模型状态显示开发中") + .font(.caption) + .foregroundStyle(.white.opacity(0.55)) + } + } + } + + HStack(spacing: 10) { + Button("选择 GIF 或图片") { + selectOLEDGIF() + } + .buttonStyle(.bordered) + + Button("预览动图") { + showsOLEDPlaybackPreview = true + } + .buttonStyle(.bordered) + .disabled(currentModeDraft.oled.localAssetPath == nil) + + Button("清空") { + clearCurrentOLED() + } + .buttonStyle(.bordered) + + Spacer() + + Text("当前目标:\(selectedMode.title)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Stepper(value: oledFramesPerSecondBinding, in: 1 ... 30) { + Text("播放速度 \(currentModeDraft.oled.framesPerSecond) FPS") + } + + Text("硬性限制:源文件 ≤ 2 MB,FPS 1–30,单模式最多 70 帧;Mode 1/2/3/4 固定写入 slot 10/80/150/220。") + .font(.caption) + .foregroundStyle(.secondary) + + Text(currentModeDraft.oled.statusLine) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 4) + } + + GroupBox("显示逻辑") { + VStack(alignment: .leading, spacing: 8) { + Text("切换到当前模式时,LCD 会先显示该模式的按键描述,约 1 秒后回到该模式动图。") + Text("后续会继续增加文字状态、token 用量、模型环境等信息显示能力。") + } + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var lightBarInspector: some View { + VStack(alignment: .leading, spacing: 16) { + GroupBox("状态灯效映射") { + VStack(alignment: .leading, spacing: 10) { + ForEach(IDEState.workflowOrder) { state in + HStack { + Text(state.shortLabel) + .font(.callout.weight(.medium)) + .frame(width: 80, alignment: .leading) + Picker("", selection: lightEffectBinding(for: state)) { + ForEach(LightEffectStyle.allCases) { effect in + Text(effect.title).tag(effect) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + } + } + } + .padding(.top, 4) + } + + GroupBox("亮度") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Slider(value: brightnessBinding, in: 1...100, step: 1) + Text("\(currentModeDraft.lightBar.brightness)%") + .font(.callout.monospacedDigit()) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 4) + } + + GroupBox("状态预览") { + VStack(alignment: .leading, spacing: 12) { + Text(lightBarPreview.shortLabel) + .font(.system(.title3, design: .rounded).weight(.semibold)) + + Text("画布预览:\(currentModeDraft.lightBar.effect(for: lightBarPreview).title)") + .font(.callout) + .foregroundStyle(.secondary) + Text("点击状态会在虚拟键盘预览,并通过 0x91 临时预览到设备;保存请使用底部通用按钮。") + .font(.caption) + .foregroundStyle(.secondary) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 8)], spacing: 8) { + ForEach(IDEState.workflowOrder) { state in + Button { + lightBarPreview = state + previewLightEffect(for: state) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(state.shortLabel) + .font(.caption.weight(.semibold)) + Text(currentModeDraft.lightBar.effect(for: state).title) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(state == lightBarPreview ? Color.accentColor.opacity(0.14) : Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(state == lightBarPreview ? Color.accentColor : Color.black.opacity(0.08), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.top, 4) + } + } + } + + private var switchInspector: some View { + VStack(alignment: .leading, spacing: 16) { + GroupBox("实时档位") { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(currentSwitchTitle) + .font(.system(.title3, design: .rounded).weight(.semibold)) + Spacer() + Circle() + .fill(currentSwitchTitle == "自动批准" ? Color.green : Color.indigo) + .frame(width: 10, height: 10) + } + Text("拨杆是物理档位,不是按下瞬态。0 档显示「自动批准」,1 档显示「手动批准」。这里只读取键盘上报的位置,不模拟物理拨动。") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.top, 4) + } + + switchEffectivenessBox + + if bleManager.switchState == 0 { + GroupBox { + VStack(alignment: .leading, spacing: 8) { + Label("自动批准依赖 Agent 与 Hook,且须蓝牙由 Agent 占用", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.callout.weight(.semibold)) + Text("Claude:PermissionRequest allow。Cursor:preToolUse 等与 cli-config。Codex:PermissionRequest allow。Kimi:安装过 AhaKey Kimi Hooks 后,**拨杆会直接接管当前会话的自动批准**;若刚装完或刚升级 kimi-cli,请**完全关闭并重新打开一次 kimi**。钩子 stdout 只对 **`permissionDecision: deny`** 有特殊拦截语义。Agent 须在跑且蓝牙由其占用。") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + GroupBox("如何理解这个部件") { + VStack(alignment: .leading, spacing: 8) { + Text("拨杆对 Claude / Cursor / Codex / Kimi **同时生效**,与键盘当前所在 Mode 无关。Agent 后台同时监听所有 IDE 的 Hook,拨杆拨动后四个 IDE 的批准行为立即切换。") + Divider() + Text("自动批准:**Claude / Codex PermissionRequest**,**Cursor preToolUse**(含 cli-config)。**Kimi**:安装过 AhaKey Kimi Hooks 后,拨杆会直接接管**当前会话**的自动批准;刚装完或刚升级 kimi-cli 时,重开一次 kimi 即可。") + Text("手动批准:会交回用户/终端确认。若 Cursor、Codex 或 Kimi 仍弹窗,请看 diagnostics 里的 ide 与 diagnostic 字段。") + Text("若仍出现手动:在「设备信息」里打开「工具批准诊断」查看 permission-request.log(含 ide、hookEvent、diagnostic 等)。") + } + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + @ViewBuilder + private var switchEffectivenessBox: some View { + let agentReady = agentManager.isInstalled && agentManager.isRunning && agentManager.hooksInstalled + let hasAnyMissing = !agentManager.isInstalled || !agentManager.isRunning || !agentManager.hooksInstalled + GroupBox(agentReady ? "已生效" : "未生效") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: agentReady ? "checkmark.seal.fill" : "exclamationmark.circle.fill") + .foregroundStyle(agentReady ? .green : .orange) + Text(agentReady + ? "Agent 就绪时 Claude/Cursor/Codex 可随拨杆走批准。**Kimi**:安装过 AhaKey Kimi Hooks 后,拨杆会直接接管当前会话;若刚装完或刚升级 kimi-cli,重开一次 kimi 即可。" + : "拨杆在 IDE 中生效需先安装 Agent 与 Hook,并把蓝牙交给 Agent;否则仅为状态显示。") + .font(.callout) + } + + if hasAnyMissing { + VStack(alignment: .leading, spacing: 4) { + agentChecklistRow(label: "LaunchAgent 已安装", ok: agentManager.isInstalled) + agentChecklistRow(label: "Agent 已连接蓝牙", ok: agentManager.isRunning) + agentChecklistRow(label: "Claude / Cursor / Codex / Kimi Hook 已配置", ok: agentManager.hooksInstalled) + } + .padding(.leading, 4) + + HStack(spacing: 8) { + if !agentManager.isInstalled { + Button("安装 Agent + Hook") { + agentManager.install() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else if !agentManager.isRunning { + // 与「设备信息 · Agent」相同:在 launchd 中 load + start 守护进程。 + // 若当前由本 App 占用蓝牙,此处也应引导先去设备信息把「蓝牙连接」切给 Agent,否则与主流程二选一相冲突(故与 DeviceInfo 同样禁用直接启动)。 + Button("启动 Agent") { + agentManager.start() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(agentManager.bluetoothConnectionOwner == .ahaKeyStudio) + .help( + agentManager.bluetoothConnectionOwner == .ahaKeyStudio + ? "当前由本 App 占用蓝牙。请打开下方「设备信息…」,在「蓝牙连接」里选「由 Agent 占用」后再启 Agent;与设备信息里「启动」按钮规则一致。" + : "与「设备信息 · Agent」中的启动相同,由 launchd 加载并执行 ahakeyconfig-agent。" + ) + } + Button("设备信息(蓝牙 / 启停 Agent)…") { + showsDeviceInfo = true + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .padding(.top, 4) + } + } + + private func agentChecklistRow(label: String, ok: Bool) -> some View { + HStack(spacing: 6) { + Image(systemName: ok ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(ok ? .green : .secondary) + .font(.caption) + Text(label) + .font(.caption) + .foregroundStyle(ok ? .primary : .secondary) + } + } + + + private var statusBar: some View { + HStack(spacing: 16) { + Label("\(selectedPart.title) · \(selectedMode.title)", systemImage: selectedPart.systemImage) + .font(.callout) + Divider() + .frame(height: 14) + Text("未同步改动 \(dirtyCount)") + .font(.callout) + Divider() + .frame(height: 14) + Text(syncStatusMessage) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + if let lastSyncDate { + Text("最近同步 \(Self.timeFormatter.string(from: lastSyncDate))") + .font(.callout) + .foregroundStyle(.secondary) + } + Button("权限诊断") { + showsDiagnostics = true + } + .buttonStyle(.borderless) + .help("查看语音权限状态与诊断日志") + .sheet(isPresented: $showsDiagnostics) { + diagnosticsSheet + } + + Button("新手引导") { + voiceRelay.showsPermissionOnboarding = false + unifiedOnboardingCompleted = false + } + .buttonStyle(.borderless) + .help("重新打开 AhaKey Studio 新手引导") + + Button("帮助中心") { + showsHelpCenter = true + } + .buttonStyle(.borderless) + .help("打开内嵌的帮助中心") + .sheet(isPresented: $showsHelpCenter) { + HelpCenterSheet( + studioDraft: studioDraft, + selectedMode: selectedMode, + bleManager: bleManager + ) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(chromeBarBackground) + } + + private var chromeBarBackground: Color { + Color(nsColor: .controlBackgroundColor) + } + + private var currentModeDraft: AhaKeyModeDraft { + studioDraft.draft(for: selectedMode) + } + + private var currentSelectedKey: AhaKeyKeyDraft { + let role = selectedPart.keyRole ?? .voice + return currentModeDraft.key(for: role) + } + + private var currentSwitchTitle: String { + // 用统一的 liveKeyboardSwitchState:主 App 自占 BLE 时是 bleManager.switchState, + // 否则取 agent 共享文件里的值(含用户拨杆覆盖)。否则点了画布拨杆, + // 因为 bleManager.switchState 一直是初始 0,画布会一直停留在「自动批准」。 + liveKeyboardSwitchState == 0 ? "自动批准" : "手动批准" + } + + /// 取键盘当前实时状态 (lightMode/switchState/workMode): + /// - 主 App 已自连 BLE(编辑配置时)→ 用主 App 自己的 BLE 读数 + /// - 主 App 未连,但 agent 仍占用 BLE 在写共享文件 → 读 agent 发布的缓存 + /// - 两者都没有 → nil(画布回落到模拟) + private var liveKeyboardLightMode: Int? { + if bleManager.isConnected { return bleManager.lightMode } + return bleManager.agentLightMode + } + private var liveKeyboardSwitchState: Int { + // 用户刚点完虚拟拨杆但 agent / BLE 还没回报新值时,优先用乐观值,按下立刻可见 + if let opt = bleManager.optimisticSwitchOverride { return opt } + if bleManager.isConnected { return bleManager.switchState } + return bleManager.agentSwitchState ?? 1 + } + private var liveKeyboardWorkMode: Int? { + if bleManager.isConnected { return bleManager.workMode } + return bleManager.agentWorkMode + } + private var liveCanvasLightMode: Int? { + guard let workMode = liveKeyboardWorkMode, selectedMode.rawValue == workMode else { return nil } + return liveKeyboardLightMode + } + private var liveCanvasIDEStateValue: Int? { + guard let workMode = liveKeyboardWorkMode, selectedMode.rawValue == workMode else { return nil } + return bleManager.liveIDEStateValue + } + private var liveCanvasSwitchState: Int { liveKeyboardSwitchState } + + private func cycleModeForward() { + let all = AhaKeyModeSlot.allCases + let next = all[(all.firstIndex(of: selectedMode)! + 1) % all.count] + selectedMode = next + } + + /// 用户点击虚拟拨杆:在当前 effective switchState 基础上 0↔1 翻转, + /// 只设置软件覆盖;最新固件中 0x91 是灯效预览,不再用于 sw_state。 + private func toggleVirtualSwitch() { + let current = liveKeyboardSwitchState + let next: UInt8 = current == 0 ? 1 : 0 + // 1) 立刻设乐观值 → 画布按钮即时翻转 + bleManager.applyOptimisticSwitchOverride(next) + // 2) 保留调用入口,但 BLEManager 不会再发送旧 0x91,只写诊断日志 + if bleManager.isConnected { + bleManager.setSwitchStateViaBLE(next) + } + // 3) 让 agent 设置软覆盖 + AgentManager.shared.sendSwitchOverride(next) + // 4) 短延迟后强制重读共享文件,确认真实值已对齐(agent 写文件通常 < 100ms) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak bleManager] in + bleManager?.refreshAgentStateFromFileNow() + } + syncStatusMessage = next == 0 + ? "虚拟拨杆 → 自动批准(hook 自动放行;灯效若不变需先刷支持 0x91 的固件)" + : "虚拟拨杆 → 手动批准(hook 交回终端确认)" + } + + private var currentOLEDPreviewImage: NSImage? { + guard let path = currentModeDraft.oled.localAssetPath else { return nil } + return NSImage(contentsOfFile: path) + } + + private var currentOLEDAssetURL: URL? { + guard let path = currentModeDraft.oled.localAssetPath else { return nil } + return URL(fileURLWithPath: path) + } + + private var currentLightEffect: LightEffectStyle { + currentModeDraft.lightBar.effect(for: lightBarPreview) + } + + private var isEditingConfiguration: Bool { + agentManager.bluetoothConnectionOwner == .ahaKeyStudio + } + + // AhaKeyStudio 直连、Agent 已连上键盘、或正在交还蓝牙的过渡期,任一满足即视为设备已连接。 + private var isEffectivelyConnected: Bool { + bleManager.isConnected || agentManager.isAgentBLEConnected || isTransitioningToKeyboardControl + } + + private var shouldShowTopBarInstallStartButton: Bool { + !agentManager.isInstalled || !agentManager.hooksInstalled + } + + private var configurationModeDetail: String { + if isEditingConfiguration { + if bleManager.isConnected { + return "AhaKey Studio 正在配置键盘" + } + return bleManager.isScanning ? "AhaKey Studio 正在连接键盘" : "AhaKey Studio 等待连接键盘" + } + // 蓝牙交给 Agent:若顶栏仍显示「安装启动」,说明 Hook/Agent 未齐备,勿与左侧「已连接」拼成「已可控制」。 + if !isEditingConfiguration && shouldShowTopBarInstallStartButton && isEffectivelyConnected { + if agentManager.isRunning && agentManager.isAgentBLEConnected { + return "Agent 正在控制键盘" + } + return "安装启动后才能控制键盘" + } + // 蓝牙交给 Agent 时:与左侧 infoPill「已连接」口径一致(isEffectivelyConnected),避免出现「已连接」+「等待键盘」的互斥文案。 + if isEffectivelyConnected { + if agentManager.isRunning && agentManager.isAgentBLEConnected { + return "Agent 正在控制键盘" + } + if agentManager.isRunning { + return "键盘已连接;正在同步 Agent 连接状态" + } + return "键盘已连接" + } + if agentManager.isRunning { + return "Agent 运行中,等待键盘连接" + } + if agentManager.isInstalled { + return "Agent 已安装,正在准备控制" + } + return "需要安装 Agent 后才能控制键盘" + } + + private var configurationModeButtonTitle: String { + if isSyncing { + return "同步中…" + } + if isEditingConfiguration { + return "保存配置" + } + return "编辑配置" + } + + private var configurationModeButtonHelp: String { + if isEditingConfiguration { + if hasUnsyncedChanges { + return "将当前草稿同步到键盘,然后把蓝牙交还给 Agent。" + } + return "没有未同步改动,直接把蓝牙交还给 Agent。" + } + return "临时由 AhaKey Studio 接管蓝牙,用于改键、LCD、同步和本机灯效测试。" + } + + private var voicePresetDetail: String { + let preset = currentSelectedKey.voicePreset ?? .custom + return preset.detail + } + + private func permissionBadge(title: String, granted: Bool) -> some View { + HStack(spacing: 6) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(title) + .font(.caption.weight(.semibold)) + Text(granted ? "已开启" : "未开启") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 999) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + + private var currentSelectedKeySanitizedDescription: String { + currentSelectedKey.description.sanitizedASCII(maxLength: 20) + } + + private var selectedKeyDescriptionBinding: Binding { + Binding( + get: { currentSelectedKey.description }, + set: { newValue in + updateSelectedKey { key in + key.description = String(newValue.prefix(20)) + } + } + ) + } + + private var selectedKeyShortcutBinding: Binding { + Binding( + get: { currentSelectedKey.shortcut }, + set: { newValue in + updateSelectedKey { $0.shortcut = newValue } + } + ) + } + + private var oledFramesPerSecondBinding: Binding { + Binding( + get: { currentModeDraft.oled.framesPerSecond }, + set: { newValue in + updateCurrentMode { mode in + mode.oled.framesPerSecond = min(30, max(1, newValue)) + } + } + ) + } + + private var hasUnsyncedChanges: Bool { + dirtyCount > 0 + } + + private var dirtyCount: Int { + AhaKeyModeSlot.allCases.reduce(into: 0) { count, mode in + let current = studioDraft.draft(for: mode) + let baseline = lastSyncedDraft.draft(for: mode) + for role in AhaKeyKeyRole.allCases where current.key(for: role) != baseline.key(for: role) { + count += 1 + } + if current.oled != baseline.oled { + count += 1 + } + if current.lightBar != baseline.lightBar { + count += 1 + } + } + } + + private func restoreCurrentModeDefaults() { + let restored = AhaKeyModeDraft.default(for: selectedMode) + var next = studioDraft + next.updateMode(restored) + studioDraft = next + syncStatusMessage = "\(selectedMode.title) 已恢复默认值,等待同步。" + } + + private func clearCurrentOLED() { + updateCurrentMode { mode in + mode.oled.localAssetPath = nil + mode.oled.statusLine = AhaKeyOLEDDraft.default(for: selectedMode).statusLine + } + } + + private func applyVoicePreset(_ preset: VoicePreset) { + updateSelectedKey { key in + key.voicePreset = preset + if preset != .custom { + key.shortcut = preset.defaultBinding + } + if key.description.isEmpty { + key.description = key.role.defaultDescription + } + } + } + + // MARK: - 宏编辑 + + /// 按键当前处于 "宏" 还是 "快捷键" 录入模式。 + /// 状态仅由 `macro` 是否为空推导,避免多出一个独立 flag。 + private enum KeyBindingMode { + case shortcut + case macro + } + + private var selectedKeyBindingModeBinding: Binding { + Binding( + get: { currentSelectedKey.usesMacro ? .macro : .shortcut }, + set: { newValue in + switch newValue { + case .shortcut: + updateSelectedKey { key in + key.macro = [] + } + case .macro: + updateSelectedKey { key in + guard key.macro.isEmpty else { return } + // Mode 0「No」键的 shortcut 故意为空,实际绑定是固件宏 ↓↓⏎;若仍用「空 shortcut → Enter 种子」, + // 从「单键」切回「宏」时会被误植成只按 Enter,覆盖用户刚配好的三键宏。 + if selectedMode == .mode0, key.role == .reject { + key.macro = AhaKeyModeDraft.claudeNoMacroSteps.map { step in + MacroStep(action: step.action, param: step.param) + } + return + } + // 其它键:用当前 shortcut 的主键作种子(没配就用 Enter),避免空白宏列表。 + let seed: UInt8 = key.shortcut.keyCode == 0 ? HIDUsage.enter : key.shortcut.keyCode + key.macro = [ + MacroStep(action: .downKey, param: seed), + MacroStep(action: .upKey, param: seed), + ] + } + } + } + ) + } + + private func appendMacroStep() { + updateSelectedKey { key in + // 默认追加 "按下 Enter"——多数用户添加步骤都是想按键,延时/松开可以再切。 + key.macro.append(MacroStep(action: .downKey, param: HIDUsage.enter)) + } + } + + private func removeMacroStep(at index: Int) { + updateSelectedKey { key in + guard key.macro.indices.contains(index) else { return } + key.macro.remove(at: index) + } + } + + private func moveMacroStep(from index: Int, by offset: Int) { + updateSelectedKey { key in + let target = index + offset + guard key.macro.indices.contains(index), key.macro.indices.contains(target) else { return } + key.macro.swapAt(index, target) + } + } + + private func updateMacroStep(id: UUID, transform: (inout MacroStep) -> Void) { + updateSelectedKey { key in + guard let idx = key.macro.firstIndex(where: { $0.id == id }) else { return } + transform(&key.macro[idx]) + } + } + + private func updateSelectedKey(_ transform: (inout AhaKeyKeyDraft) -> Void) { + guard let role = selectedPart.keyRole else { return } + updateCurrentMode { mode in + var key = mode.key(for: role) + transform(&key) + mode.updateKey(key) + } + } + + private func updateCurrentMode(_ transform: (inout AhaKeyModeDraft) -> Void) { + updateMode(selectedMode, transform) + } + + private func updateMode(_ modeSlot: AhaKeyModeSlot, _ transform: (inout AhaKeyModeDraft) -> Void) { + var next = studioDraft + var mode = next.draft(for: modeSlot) + transform(&mode) + next.updateMode(mode) + studioDraft = next + } + + private func partIsDirty(_ part: AhaKeyStudioPart) -> Bool { + let current = studioDraft.draft(for: selectedMode) + let baseline = lastSyncedDraft.draft(for: selectedMode) + switch part { + case .key1, .key2, .key3, .key4: + guard let role = part.keyRole else { return false } + return current.key(for: role) != baseline.key(for: role) + case .oledDisplay: + return current.oled != baseline.oled + case .lightBar: + return current.lightBar != baseline.lightBar + case .toggleSwitch: + return false + } + } + + private func dirtyPartsForCurrentMode() -> Set { + Set(AhaKeyStudioPart.allCases.filter(partIsDirty(_:))) + } + + private func selectOLEDGIF() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.gif, .png, .jpeg, .tiff] + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + do { + try OLEDFrameEncoder.validateGIFSourceFileSize(at: url) + try OLEDFrameEncoder.validateFrameCount(at: url) + } catch { + let msg = (error as? LocalizedError)?.errorDescription ?? "图片文件不符合上传限制。" + syncStatusMessage = msg + updateCurrentMode { mode in + mode.oled.statusLine = msg + } + return + } + let frameCount = OLEDFrameEncoder.frameCount(at: url) + updateCurrentMode { mode in + mode.oled.localAssetPath = url.path + mode.oled.statusLine = "已选 \(max(frameCount, 1)) 帧图片预览;写入时将上传到 \(selectedMode.title) 固定分区。" + } + syncStatusMessage = "已更新 \(selectedMode.title) 的 LCD 预览;写入设备请使用底部通用按钮。" + } + } + + private func handleConfigurationModeButton() { + if isEditingConfiguration { + finishEditingConfiguration() + } else { + enterEditingConfiguration() + } + } + + private func installStartAgentFromTopBar() { + if agentManager.bluetoothConnectionOwner != .agentDaemon { + agentManager.setBluetoothConnectionOwner(.agentDaemon, bleManager: bleManager) + } + if !agentManager.isInstalled || !agentManager.hooksInstalled { + agentManager.install() + } else { + agentManager.start() + } + } + + private func enterEditingConfiguration() { + isTransitioningToKeyboardControl = false + agentManager.setBluetoothConnectionOwner(.ahaKeyStudio, bleManager: bleManager) + syncStatusMessage = "已进入编辑配置,AhaKey Studio 将临时接管蓝牙。" + } + + private func finishEditingConfiguration() { + guard hasUnsyncedChanges else { + returnToKeyboardControl() + return + } + + if bleManager.isConnected && bleManager.commandCharReady { + syncAllModesToDevice(returnToKeyboardControlWhenDone: true) + } else { + syncStatusMessage = "设备连接中,连接成功后将自动同步并返回控制模式…" + bleManager.userInitiatedConnect() + waitForConnectionThenSync() + } + } + + private func writeToKeyboard() { + performUnifiedDeviceWrite(returnToKeyboardControlWhenDone: false, showResultAlert: true) + } + + private func completeEditingAfterSuccessfulWrite() { + commitModeNameEdit() + withAnimation(.easeInOut(duration: 0.18)) { + isEditingInspector = false + } + returnToKeyboardControl() + } + + // 轮询等待 BLE 连接且命令通道就绪(最多 10 秒),连接后自动同步并返回键盘控制。 + private func waitForConnectionThenSync() { + Task { @MainActor in + for _ in 0..<20 { + try? await Task.sleep(nanoseconds: 500_000_000) + if bleManager.isConnected && bleManager.commandCharReady { + syncAllModesToDevice(returnToKeyboardControlWhenDone: true) + return + } + } + syncStatusMessage = "连接超时,本次未写入键盘;已释放蓝牙给 Agent,可再次进入编辑后重试保存。" + returnToKeyboardControl() + } + } + + private func returnToKeyboardControl() { + isTransitioningToKeyboardControl = true + agentManager.setBluetoothConnectionOwner(.agentDaemon, bleManager: bleManager) + syncStatusMessage = "正在恢复键盘控制,Agent 正在连接键盘…" + monitorAgentReconnect() + } + + // 返回键盘控制后每 2s 轮询一次 Agent BLE 状态(等待异步 socket 查询完成后再读值), + // 最多等待 20s;超时后尝试重启 Agent。过渡期结束时清除 isTransitioningToKeyboardControl。 + private func monitorAgentReconnect() { + Task { @MainActor in + for i in 0..<10 { + // 第一次等短些,让 Agent 有时间启动 + let waitMs: UInt64 = i == 0 ? 1_500_000_000 : 2_000_000_000 + try? await Task.sleep(nanoseconds: waitMs) + agentManager.refresh() + // 等待 refresh() 内部的异步 socket 查询写回主线程(最多 2.5s timeout) + try? await Task.sleep(nanoseconds: 2_500_000_000) + if agentManager.isAgentBLEConnected { + syncStatusMessage = "已返回键盘控制,Agent 将接管蓝牙。" + isTransitioningToKeyboardControl = false + return + } + // 约 10s 后 Agent 仍未连上,尝试重启 + if i == 2, !agentManager.isAgentBLEConnected { + agentManager.start() + } + } + syncStatusMessage = "已返回键盘控制,Agent 将接管蓝牙。" + isTransitioningToKeyboardControl = false + } + } + + private func syncAllModesToDevice(returnToKeyboardControlWhenDone: Bool = false) { + performUnifiedDeviceWrite(returnToKeyboardControlWhenDone: returnToKeyboardControlWhenDone, showResultAlert: false) + } + + private func performUnifiedDeviceWrite(returnToKeyboardControlWhenDone: Bool, showResultAlert: Bool) { + guard bleManager.isConnected && bleManager.commandCharReady else { + let message = showResultAlert ? "设备未连接,请先连接键盘后重试。" : "设备未连接或命令通道未就绪,当前只保存本地草稿。" + syncStatusMessage = message + if showResultAlert { + writeResultAlertMessage = message + showsWriteResultAlert = true + } + return + } + + applyCursorRejectMacroSelfHealIfNeeded() + isSyncing = true + syncStatusMessage = "正在准备写入设备…" + let returnAgent = returnToKeyboardControlWhenDone + + Task { @MainActor in + do { + let uploadedOLEDCount = try await uploadChangedOLEDsToDevice() + var commands = commandsForModes(AhaKeyModeSlot.allCases) + commands.append((data: AhaKeyCommand.saveConfig(), label: "保存全部配置到设备")) + + let total = commands.count + if uploadedOLEDCount > 0 { + self.syncStatusMessage = "已上传 \(uploadedOLEDCount) 个 LCD 动图,正在写入灯效与键位配置(约 \(total) 条)…" + } else { + self.syncStatusMessage = "正在写入灯效与键位配置(约 \(total) 条)…" + } + self.bleManager.writeCommandsSequentially(commands) { + Task { @MainActor in + // 队列与 50ms 间隔已保证顺序;略等再交还蓝牙,避免固件尚未处理完最后帧。 + try? await Task.sleep(nanoseconds: UInt64(250) * 1_000_000) + self.lastSyncedDraft = self.studioDraft + self.lastSyncDate = Date() + self.isSyncing = false + self.syncStatusMessage = "已全部写入设备并保存。" + if showResultAlert { + self.writeResultAlertMessage = "配置已成功写入键盘。" + self.showsWriteResultAlert = true + } + if returnAgent { + self.returnToKeyboardControl() + } + } + } + } catch { + let message = "写入键盘失败:\(error.localizedDescription)" + self.isSyncing = false + self.syncStatusMessage = message + if showResultAlert { + self.writeResultAlertMessage = message + self.showsWriteResultAlert = true + } + } + } + } + + private func uploadChangedOLEDsToDevice() async throws -> Int { + var uploadCount = 0 + + for mode in AhaKeyModeSlot.allCases { + let draft = studioDraft.draft(for: mode) + guard let assetPath = draft.oled.localAssetPath else { continue } + + let baseline = lastSyncedDraft.draft(for: mode).oled + let deviceFrameCount = bleManager.keyboardPictureStates[mode.rawValue]?.frameCount ?? 0 + guard draft.oled != baseline || deviceFrameCount == 0 else { continue } + + let assetURL = URL(fileURLWithPath: assetPath) + try OLEDFrameEncoder.validateGIFSourceFileSize(at: assetURL) + let frames = try OLEDFrameEncoder.frames(fromGIFAt: assetURL) + + updateMode(mode) { modeDraft in + modeDraft.oled.statusLine = "正在上传动图到 \(mode.title)…" + } + syncStatusMessage = "正在上传 \(mode.title) 的 LCD 动图…" + + let startIndex = try await resolveOLEDUploadStartIndex(for: mode, frameCount: frames.count) + try await bleManager.uploadOLEDFrames( + frames, + fps: draft.oled.framesPerSecond, + mode: UInt8(mode.rawValue), + startIndex: UInt16(startIndex) + ) + + updateMode(mode) { modeDraft in + modeDraft.oled.statusLine = "已上传 \(frames.count) 帧到设备,槽位起点 \(startIndex);切换模式时会先显示描述,再回到当前模式动图。" + } + uploadCount += 1 + } + + return uploadCount + } + + private func resendCurrentModeToDevice() { + guard bleManager.isConnected && bleManager.commandCharReady else { + syncStatusMessage = "设备未连接或命令通道未就绪,当前只保存本地草稿。" + return + } + + applyCursorRejectMacroSelfHealIfNeeded() + var commands = commandsForModes([selectedMode]) + commands.append((data: AhaKeyCommand.saveConfig(), label: "保存 \(selectedMode.title) 当前配置")) + + isSyncing = true + syncStatusMessage = "正在写入 \(selectedMode.title)…" + bleManager.writeCommandsSequentially(commands) { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(150) * 1_000_000) + self.lastSyncDate = Date() + self.isSyncing = false + self.syncStatusMessage = "已重新发送 \(self.selectedMode.title) 当前模式。" + } + } + } + + /// Cursor 档「取消键」若仍为默认 ⌫ 却残留宏,同步会走 0x74 而非单键。清掉误残留宏并与迁移逻辑一致。 + private func applyCursorRejectMacroSelfHealIfNeeded() { + var next = studioDraft + var m1 = next.draft(for: .mode1) + var reject = m1.key(for: .reject) + let defaultR = AhaKeyModeDraft.default(for: .mode1).key(for: .reject) + guard !reject.macro.isEmpty, reject.shortcut == defaultR.shortcut else { return } + reject.macro = [] + m1.updateKey(reject) + next.updateMode(m1) + studioDraft = next + } + + private func commandsForModes(_ modes: [AhaKeyModeSlot]) -> [(data: Data, label: String)] { + var commands: [(data: Data, label: String)] = [] + + for mode in modes { + let draft = studioDraft.draft(for: mode) + for role in AhaKeyKeyRole.allCases { + let key = draft.key(for: role) + let keyIndex = UInt8(role.rawValue) + let modeByte = UInt8(mode.rawValue) + + if key.usesMacro { + // 固件对 0x73 快捷键、0x74 宏是分层存储的;从「快捷键」改「宏」时须先清掉旧快捷键,否则会残留。 + commands.append(( + data: AhaKeyCommand.setKeyMapping( + mode: modeByte, + keyIndex: keyIndex, + hidCodes: [] + ), + label: "清除 \(mode.title) \(key.title) 快捷键层(将写入宏)" + )) + commands.append(( + data: AhaKeyCommand.setKeyMacro( + mode: modeByte, + keyIndex: keyIndex, + macroData: key.macro.flattenedBytes + ), + label: "写入 \(mode.title) \(key.title) 宏: \(key.macro.displaySummary)" + )) + } else { + // 从「宏」改「快捷键 / 无键」时须先发空 0x74,否则设备可能仍走旧宏(Cursor/其它 mode 上表现为改键不生效)。 + commands.append(( + data: AhaKeyCommand.setKeyMacro( + mode: modeByte, + keyIndex: keyIndex, + macroData: [] + ), + label: "清除 \(mode.title) \(key.title) 宏层(将写入快捷键)" + )) + if !key.shortcut.hidCodes.isEmpty { + commands.append(( + data: AhaKeyCommand.setKeyMapping( + mode: modeByte, + keyIndex: keyIndex, + hidCodes: key.shortcut.hidCodes + ), + label: "写入 \(mode.title) \(key.title) 快捷键: \(key.shortcut.displayLabel)" + )) + } else { + commands.append(( + data: AhaKeyCommand.setKeyMapping( + mode: modeByte, + keyIndex: keyIndex, + hidCodes: [] + ), + label: "清除 \(mode.title) \(key.title) 快捷键" + )) + } + } + + let sanitizedDescription = key.description.sanitizedASCII(maxLength: 20) + commands.append(( + data: AhaKeyCommand.setKeyDescription( + mode: UInt8(mode.rawValue), + keyIndex: keyIndex, + text: key.description + ), + label: "写入 \(mode.title) \(key.title) 描述: \(sanitizedDescription.isEmpty ? "空白" : sanitizedDescription)" + )) + } + } + + for mode in modes { + let lb = studioDraft.draft(for: mode).lightBar + let effects = IDEState.allCases.map { lb.effect(for: $0).firmwareIndex } + commands.append(( + AhaKeyCommand.setLightMapping(mode: UInt8(mode.rawValue), stateEffects: effects), + "灯效映射 \(mode.title)" + )) + } + + let brightness = UInt8(studioDraft.draft(for: modes[0]).lightBar.brightness) + commands.append((AhaKeyCommand.setBrightness(brightness), "亮度 \(brightness)%")) + + return commands + } + + /// 首次连接键盘后自动把 bundle 默认 GIF 推到没有上传过的 mode slot。 + /// 触发时机:bleManager.keyboardPictureStates 四个 mode 都查回来之后 + /// (由 .onChange(of: bleManager.keyboardPictureStates) 调度)。 + /// 守卫: + /// - 只上传 picLength==0(slot 完全空)的 mode;非 0 视为用户已自定义或固件出厂图 + /// - 只在 draft 的 localAssetPath 仍指向 bundle 默认(用户没手动换过)时上传 + /// - 每次连接只跑一次(oledAutoSyncDoneForConnection 标志位由 .onChange(isConnected) 重置) + private func autoSyncDefaultOLEDsIfNeeded() async { + guard bleManager.isConnected else { return } + // 四个 mode 全部 0x83 查询回来才动手,避免半截判断把已上传 slot 当成空 + guard bleManager.keyboardPictureStates.count == AhaKeyModeSlot.allCases.count else { return } + + for mode in AhaKeyModeSlot.allCases { + guard let state = bleManager.keyboardPictureStates[mode.rawValue] else { continue } + guard state.frameCount == 0 else { continue } + guard let bundledPath = DefaultOLEDAssets.bundledAssetPath(for: mode) else { continue } + let draft = studioDraft.draft(for: mode) + guard let drafPath = draft.oled.localAssetPath, + DefaultOLEDAssets.isBundledPath(drafPath) else { continue } + + let assetURL = URL(fileURLWithPath: bundledPath) + do { + try OLEDFrameEncoder.validateGIFSourceFileSize(at: assetURL) + let frames = try OLEDFrameEncoder.frames(fromGIFAt: assetURL) + let startIndex = try await resolveOLEDUploadStartIndex(for: mode, frameCount: frames.count) + try await bleManager.uploadOLEDFrames( + frames, + fps: draft.oled.framesPerSecond, + mode: UInt8(mode.rawValue), + startIndex: UInt16(startIndex) + ) + updateMode(mode) { m in + m.oled.statusLine = "已自动同步默认动图(\(frames.count) 帧)。" + } + } catch { + syncStatusMessage = "\(mode.title) 默认动图自动同步失败: \(error.localizedDescription)" + } + } + } + + private func resolveOLEDUploadStartIndex(for targetMode: AhaKeyModeSlot, frameCount: Int) async throws -> Int { + guard frameCount <= AhaKeyCommand.oledMaxFramesPerMode else { + throw OLEDUploadError.tooManyFrames(max: AhaKeyCommand.oledMaxFramesPerMode) + } + + _ = try? await bleManager.readPictureState(mode: UInt8(targetMode.rawValue)) + return Int(AhaKeyCommand.oledStartIndex(forMode: UInt8(targetMode.rawValue))) + } + + private func canPlacePictureRange( + start: Int, + count: Int, + occupiedRegions: [(start: Int, end: Int)], + maxCapacity: Int + ) -> Bool { + let end = start + count + guard start >= 0, end <= maxCapacity else { return false } + return occupiedRegions.allSatisfy { region in + end <= region.start || start >= region.end + } + } + + private func findFreePictureSpace( + occupiedRegions: [(start: Int, end: Int)], + neededCount: Int, + maxCapacity: Int + ) -> Int? { + guard !occupiedRegions.isEmpty else { return 0 } + + if occupiedRegions[0].start >= neededCount { + return 0 + } + + for index in 0 ..< (occupiedRegions.count - 1) { + let gapStart = occupiedRegions[index].end + let gapEnd = occupiedRegions[index + 1].start + if gapEnd - gapStart >= neededCount { + return gapStart + } + } + + let lastEnd = occupiedRegions.last?.end ?? 0 + if lastEnd + neededCount <= maxCapacity { + return lastEnd + } + + return nil + } + + private func lightEffectBinding(for state: IDEState) -> Binding { + Binding( + get: { currentModeDraft.lightBar.effect(for: state) }, + set: { newEffect in + var draft = studioDraft + var mode = draft.draft(for: selectedMode) + if let idx = mode.lightBar.stateMappings.firstIndex(where: { $0.state == state }) { + mode.lightBar.stateMappings[idx].effect = newEffect + } + draft.updateMode(mode) + studioDraft = draft + AhaKeyStudioStore.save(studioDraft) + lightBarPreview = state + previewLightEffect(newEffect) + } + ) + } + + private var brightnessBinding: Binding { + Binding( + get: { Double(currentModeDraft.lightBar.brightness) }, + set: { newValue in + var draft = studioDraft + var mode = draft.draft(for: selectedMode) + mode.lightBar.brightness = Int(newValue) + draft.updateMode(mode) + studioDraft = draft + AhaKeyStudioStore.save(studioDraft) + previewBrightness(Int(newValue)) + } + ) + } + + private func previewLightEffect(for state: IDEState) { + previewLightEffect(currentModeDraft.lightBar.effect(for: state)) + } + + private func previewLightEffect(_ effect: LightEffectStyle) { + guard bleManager.isConnected && bleManager.commandCharReady else { + syncStatusMessage = "已更新虚拟灯效预览;连接键盘后可预览到设备。" + return + } + bleManager.previewLightEffect(effect.firmwareIndex) + syncStatusMessage = "正在预览灯效:\(effect.title)。" + } + + private func previewBrightness(_ value: Int) { + guard bleManager.isConnected && bleManager.commandCharReady else { + syncStatusMessage = "已更新亮度为 \(value)%;连接键盘后可预览到设备。" + return + } + bleManager.setBrightness(UInt8(max(1, min(100, value)))) + syncStatusMessage = "正在预览灯光强度:\(value)% 。" + } + + private func infoPill(title: String, subtitle: String, accent: Color, width: CGFloat = 86) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 6) { + Circle() + .fill(accent) + .frame(width: 8, height: 8) + Text(subtitle) + .font(.callout.weight(.medium)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(width: width, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + + private func manualCallout(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(detail) + .font(.callout) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + }() + + private func openNativeSpeechPrivacySettings() { + openNativeSpeechPrivacySettingsURL() + } + + private var nativeSpeechPermissionsReady: Bool { + nativeSpeech.microphoneGranted && + nativeSpeech.speechRecognitionGranted && + nativeSpeech.siriEnabled && + nativeSpeech.dictationEnabled + } + + private var startupPermissionsReady: Bool { + bleManager.bluetoothPermissionGranted && + bleManager.bluetoothPoweredOn && + voiceRelay.inputMonitoringGranted && + voiceRelay.accessibilityGranted && + nativeSpeech.microphoneGranted && + nativeSpeech.speechRecognitionGranted && + nativeSpeech.siriEnabled && + nativeSpeech.dictationEnabled + } + + private func scheduleStartupPermissionOnboarding() { + voiceRelay.showsPermissionOnboarding = false + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + + private func refreshStartupPermissionOnboarding() { + voiceRelay.showsPermissionOnboarding = false + } +} + +private struct VoicePermissionOnboardingSheet: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @ObservedObject var voiceRelay: VoiceRelayService + @ObservedObject var nativeSpeech: NativeSpeechTranscriptionService + @Environment(\.dismiss) private var dismiss + + @State private var fixInProgress = false + @State private var fixAlertTitle = "" + @State private var fixAlertMessage = "" + @State private var fixAlertIsSuccess = false + @State private var showFixAlert = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("新手权限引导") + .font(.system(size: 24, weight: .semibold)) + + Text("AhaKey Studio 首次使用需要完成几项系统授权:连接键盘需要蓝牙,后台接管语音键需要输入监控与辅助功能,macOS 原生语音需要麦克风、语音转写、Siri 与听写。") + .font(.callout) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 10) { + permissionRow(title: "蓝牙", granted: bleManager.bluetoothPermissionGranted && bleManager.bluetoothPoweredOn, detail: bleManager.bluetoothPermissionGranted ? "打开系统蓝牙,用于发现、连接和同步 AhaKey 键盘。" : "在「隐私与安全性 > 蓝牙」中允许 AhaKey Studio 使用蓝牙。") + permissionRow(title: "麦克风", granted: nativeSpeech.microphoneGranted, detail: "允许 AhaKey Studio 使用苹果原生语音采集。") + permissionRow(title: "语音转写", granted: nativeSpeech.speechRecognitionGranted, detail: "允许 AhaKey Studio 使用苹果原生语音识别。") + permissionRow(title: "Siri", granted: nativeSpeech.siriEnabled, detail: "在「系统设置 > Siri 与聚焦」里开启 Siri,供 macOS 原生语音能力使用。") + permissionRow(title: "听写", granted: nativeSpeech.dictationEnabled, detail: "在「系统设置 > 键盘 > 听写」里开启听写,保证系统语音组件完整可用。") + permissionRow(title: "辅助功能", granted: voiceRelay.accessibilityGranted, detail: "允许 AhaKey Studio 把语音键转换成苹果原生转写或 Fn/Globe。") + permissionRow(title: "输入监控", granted: voiceRelay.inputMonitoringGranted, detail: "允许 AhaKey Studio 在后台监听实体语音键;设置完成后通常需要退出并重新打开。") + } + + VStack(alignment: .leading, spacing: 6) { + Text("授权步骤") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text("1. 点「现在申请权限」,按系统弹窗允许蓝牙、麦克风和语音转写。") + Text("2. 自动打开系统设置后,依次开启 Siri、听写、辅助功能。") + Text("3. 最后开启输入监控;系统提示重启时退出并重新打开。") + Text("4. 回到这里点「我已完成,重新检查」继续体验输入。") + } + .font(.caption) + .foregroundStyle(.secondary) + Text("若系统里已勾选允许,本应用仍显示未开启:请完全退出 AhaKey Studio 并再启动一次。输入监控、辅助功能等常按进程生效,只点「重新检查」或从后台切回,有时读到的仍是旧状态,重启后即可与系统设置一致。") + .font(.caption) + .foregroundStyle(.secondary) + Text("外发 / DMG / Xcode:默认正式包在系统「隐私与安全性」里显示为「AhaKey Studio」;用 Xcode 以 Debug 运行本工程时显示为「AhaKey Studio(调试)」,请按名称分别授权。路径或签名不同也会被系统当成另一款 App。") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("蓝牙 \(bleManager.bluetoothPermissionGranted ? (bleManager.bluetoothPoweredOn ? "已开启" : "已授权但蓝牙关闭") : "未授权")") + Text(voiceRelay.lastPermissionCheckSummary) + Text(nativeSpeech.lastPermissionCheckSummary) + } + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("现在申请权限") { + requestPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + } + .buttonStyle(.borderedProminent) + + Button("我已完成,重新检查") { + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) + + RestartToApplyPermissionsButton(title: "退出并重新打开") + + if !allPermissionsReady { + Button("打开系统设置") { + openCombinedVoicePrivacySettingsURL() + } + .buttonStyle(.bordered) + } + + if DebugSigningFixer.isAvailable { + Button(fixInProgress ? "重置中…" : "⚙️ 重置开发环境签名(通常不需要)") { + runDebugSigningFix() + } + .buttonStyle(.bordered) + .tint(.purple) + .disabled(fixInProgress) + .help("仅在异常情况下使用:证书过期 / 换 Mac / Team ID 变化 / 钥匙串损坏导致权限失效时,点一下会重新签名 app 并重置 TCC 授权。正式发行版(无源码目录)看不到此按钮。") + } + + Spacer() + + Button("稍后再说") { + voiceRelay.dismissPermissionOnboarding() + dismiss() + } + .buttonStyle(.borderless) + } + + if allPermissionsReady { + Text("新手权限已经齐了。关闭这个弹窗后,AhaKey Studio 可以连接键盘、后台监听语音键,macOS 原生语音也可以正常使用。") + .font(.caption) + .foregroundStyle(.green) + } else { + Text("仍有权限未开启。请按上方状态逐项处理,全部变为绿色后再关闭弹窗。") + .font(.caption) + .foregroundStyle(.orange) + } + } + .padding(24) + .frame(width: 560) + .onChange(of: voiceRelay.inputMonitoringGranted) { _ in + closeIfReady() + } + .onChange(of: voiceRelay.accessibilityGranted) { _ in + closeIfReady() + } + .onChange(of: bleManager.bluetoothPermissionGranted) { _ in + closeIfReady() + } + .onChange(of: bleManager.bluetoothPoweredOn) { _ in + closeIfReady() + } + .alert(fixAlertTitle, isPresented: $showFixAlert) { + if fixAlertIsSuccess { + Button("立即退出 App") { NSApp.terminate(nil) } + Button("稍后再退", role: .cancel) {} + } else { + Button("好", role: .cancel) {} + } + } message: { + Text(fixAlertMessage) + } + } + + private func runDebugSigningFix() { + fixInProgress = true + DebugSigningFixer.run { result in + fixInProgress = false + fixAlertIsSuccess = result.success + fixAlertTitle = result.success ? "修复完成" : "修复失败" + fixAlertMessage = result.output + showFixAlert = true + } + } + + private func closeIfReady() { + guard allPermissionsReady else { return } + voiceRelay.dismissPermissionOnboarding() + dismiss() + } + + private var allPermissionsReady: Bool { + bleManager.bluetoothPermissionGranted && + bleManager.bluetoothPoweredOn && + voiceRelay.inputMonitoringGranted && + voiceRelay.accessibilityGranted && + nativeSpeech.microphoneGranted && + nativeSpeech.speechRecognitionGranted && + nativeSpeech.siriEnabled && + nativeSpeech.dictationEnabled + } + + private func permissionRow(title: String, granted: Bool, detail: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 10, height: 10) + .padding(.top, 4) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(title) + .font(.headline) + Text(granted ? "已开启" : "未开启") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } +} + +private struct VoicePresetPicker: View { + let selectedPreset: VoicePreset + let onSelect: (VoicePreset) -> Void + + private let visiblePresets = VoicePreset.visibleCases + private let columns = [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 10) { + ForEach(visiblePresets) { preset in + Button { + if preset.availableInV1 { + onSelect(preset) + } + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(preset.title) + .font(.callout.weight(.semibold)) + Spacer() + if !preset.availableInV1 { + Text("开发中") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(preset.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(cardFill(for: preset)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(cardStroke(for: preset), lineWidth: preset == selectedPreset ? 1.5 : 1) + ) + } + .buttonStyle(.plain) + .disabled(!preset.availableInV1) + } + } + } + + private func cardFill(for preset: VoicePreset) -> Color { + if preset == selectedPreset { + return Color.accentColor.opacity(0.16) + } + if !preset.availableInV1 { + return Color(nsColor: .controlBackgroundColor).opacity(0.65) + } + return Color(nsColor: .controlBackgroundColor) + } + + private func cardStroke(for preset: VoicePreset) -> Color { + if preset == selectedPreset { + return .accentColor + } + return Color.black.opacity(0.08) + } +} + +private struct ShortcutBindingEditor: View { + @Binding var shortcut: ShortcutBinding + @State private var isRecordingPrimaryKey = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("修饰键") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + ForEach(ShortcutModifier.allCases) { modifier in + Toggle(isOn: modifierBinding(modifier)) { + Text(modifier.symbol) + .font(.system(.headline, design: .rounded)) + } + .toggleStyle(.button) + .help(modifier.title) + } + if !shortcut.modifiers.isEmpty { + Button("清除修饰键") { + var next = shortcut + next.modifiers = [] + shortcut = next + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("主键") + .font(.caption) + .foregroundStyle(.secondary) + + PrimaryKeyInputField( + shortcut: $shortcut, + isRecording: $isRecordingPrimaryKey + ) + } + + if !shortcut.modifiers.isEmpty { + Text("当前为组合键(\(shortcut.displayLabel))。若你只想发单键 Enter,勿打开 ⌘/⌃ 等,或点「清除修饰键」后再选 Enter。") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func modifierBinding(_ modifier: ShortcutModifier) -> Binding { + Binding( + get: { shortcut.modifiers.contains(modifier) }, + set: { on in + var next = shortcut + next.setModifier(modifier, enabled: on) + shortcut = next + } + ) + } + +} + +private struct PrimaryKeyInputField: View { + @Binding var shortcut: ShortcutBinding + @Binding var isRecording: Bool + + private var displayText: String { + shortcut.keyCode == 0 ? "直接按下键盘快捷键即可" : HIDUsage.name(for: shortcut.keyCode) + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isRecording ? Color.accentColor : Color.black.opacity(0.12), lineWidth: isRecording ? 1.5 : 1) + ) + + KeyCaptureOverlay( + shortcut: $shortcut, + isRecording: $isRecording, + onActivate: { + isRecording = true + } + ) + .padding(.trailing, 38) + + HStack(spacing: 8) { + Image(systemName: isRecording ? "keyboard.badge.ellipsis" : "keyboard") + .foregroundStyle(isRecording ? Color.accentColor : Color.secondary) + Text(displayText) + .font(.callout) + .foregroundStyle(shortcut.keyCode == 0 && !isRecording ? Color.secondary : Color.primary) + .lineLimit(1) + Spacer() + + Menu { + Button("直接按下键盘快捷键即可") { + shortcut = ShortcutBinding() + isRecording = false + } + Divider() + ForEach(HIDUsage.allOptions, id: \.code) { option in + Button(option.name) { + var next = shortcut + next.keyCode = option.code + shortcut = next + isRecording = false + } + } + } label: { + Image(systemName: "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("展开下拉列表") + } + .padding(.horizontal, 10) + } + .frame(height: 36) + .contentShape(Rectangle()) + .help("直接按键设置主键,点击箭头展开下拉列表。") + } +} + +private struct KeyCaptureOverlay: NSViewRepresentable { + @Binding var shortcut: ShortcutBinding + @Binding var isRecording: Bool + let onActivate: () -> Void + + func makeNSView(context: Context) -> KeyCaptureNSView { + let view = KeyCaptureNSView() + configure(view) + return view + } + + func updateNSView(_ nsView: KeyCaptureNSView, context: Context) { + configure(nsView) + if isRecording { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(nsView) + } + } + } + + private func configure(_ view: KeyCaptureNSView) { + view.onBeginRecording = { + onActivate() + isRecording = true + } + view.onCapture = { event in + guard let hidCode = HIDUsage.hidCode(forMacKeyCode: event.keyCode) else { + NSSound.beep() + isRecording = false + return + } + shortcut = ShortcutBinding( + modifiers: shortcutModifiers(from: event.modifierFlags), + keyCode: hidCode + ) + isRecording = false + } + view.onCaptureModifier = { keyCode in + guard let hidCode = HIDUsage.hidCode(forMacKeyCode: keyCode) else { + NSSound.beep() + isRecording = false + return + } + shortcut = ShortcutBinding(modifiers: [], keyCode: hidCode) + isRecording = false + } + } + + final class KeyCaptureNSView: NSView { + var onBeginRecording: (() -> Void)? + var onCapture: ((NSEvent) -> Void)? + var onCaptureModifier: ((UInt16) -> Void)? + private var pendingModifierCapture: DispatchWorkItem? + + override var acceptsFirstResponder: Bool { true } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + onBeginRecording?() + } + + override func keyDown(with event: NSEvent) { + pendingModifierCapture?.cancel() + pendingModifierCapture = nil + onCapture?(event) + } + + override func flagsChanged(with event: NSEvent) { + onBeginRecording?() + pendingModifierCapture?.cancel() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.control) + || flags.contains(.option) + || flags.contains(.shift) + || flags.contains(.command) + || flags.contains(.capsLock) + || flags.contains(.function) + else { + return + } + + let workItem = DispatchWorkItem { [weak self] in + self?.onCaptureModifier?(event.keyCode) + self?.pendingModifierCapture = nil + } + pendingModifierCapture = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: workItem) + } + } +} + +private func shortcutModifiers(from flags: NSEvent.ModifierFlags) -> [ShortcutModifier] { + var modifiers: [ShortcutModifier] = [] + if flags.contains(.control) { modifiers.append(.control) } + if flags.contains(.option) { modifiers.append(.option) } + if flags.contains(.shift) { modifiers.append(.shift) } + if flags.contains(.command) { modifiers.append(.command) } + return modifiers +} + +private struct CanvasKeyButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1.0) + .animation(.spring(response: 0.12, dampingFraction: 0.65), value: configuration.isPressed) + } +} + +private struct AhaKeyKeyboardCanvasView: View { + let modeDraft: AhaKeyModeDraft + let selectedPart: AhaKeyStudioPart + let lightBarPreview: IDEState + let switchTitle: String + let dirtyParts: Set + let onSelect: (AhaKeyStudioPart) -> Void + let onModeSwitch: () -> Void + var onSwitchToggle: (() -> Void)? = nil + var liveLightMode: Int? = nil + var liveIDEStateValue: Int? = nil + var switchState: Int = 1 // 0=auto, 1=manual; firmware uses for color/effect overrides + /// 0x83 查询出的当前 mode flash 帧数:nil=尚未查询/未连接;0=用户没上传;>0=已上传 N 帧 + var keyboardPictureFrameCount: Int? = nil + + @State private var modeSwitchPressed = false + @State private var leverPressed = false + + private let baseWidth: CGFloat = 109 + private let baseHeight: CGFloat = 54 + + var body: some View { + GeometryReader { proxy in + let drawingWidth = min(proxy.size.width, proxy.size.height * (baseWidth / baseHeight)) + let drawingHeight = drawingWidth * (baseHeight / baseWidth) + + ZStack { + keyboardFrame(width: drawingWidth, height: drawingHeight) + } + .frame(width: drawingWidth, height: drawingHeight) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + } + + @ViewBuilder + private func keyboardFrame(width: CGFloat, height: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.95), Color(red: 0.92, green: 0.95, blue: 0.98)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.black.opacity(0.1), lineWidth: 1.2) + ) + .shadow(color: .black.opacity(0.08), radius: 18, y: 14) + + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.black.opacity(0.06), lineWidth: 1) + .padding(12) + + VStack { + Spacer() + } + + // 螺丝挪到真正的"边角内侧" + 缩小直径 4.8 → 3.6: + // 旧位置 (8,8)/(8,46) 会被按键灰底矩形和灯条/Key1 边线擦边或交叠。 + // 新位置每颗距离灯条/按键灰底/Key 边都留出 ≥ 3 个基线单位。 + ForEach(Array([CGPoint(x: 5.5, y: 5.5), CGPoint(x: 103.5, y: 5.5), CGPoint(x: 5.5, y: 48.5), CGPoint(x: 103.5, y: 48.5)].enumerated()), id: \.offset) { _, point in + Circle() + .stroke(Color.black.opacity(0.14), lineWidth: 1.2) + .background(Circle().fill(Color.white.opacity(0.4))) + .frame(width: scaled(3.6, in: width), height: scaled(3.6, in: width)) + .position(position(point.x, point.y, width: width, height: height)) + } + + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 1) + .frame(width: scaled(4.2, in: width), height: scaled(12, in: width)) + .position(position(3.8, 28, width: width, height: height)) + + // 按键灰底:略收一点尺寸,使它显著低于灯条选中态阴影的影响范围(≥ 5 个基线单位) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.035)) + .frame(width: scaled(67, in: width), height: scaled(21, in: width)) + .position(position(43.8, 38.5, width: width, height: height)) + + ledBarButton(width: width, height: height) + oledButton(width: width, height: height) + keyButton(for: .voice, width: width, height: height) + keyButton(for: .approve, width: width, height: height) + keyButton(for: .reject, width: width, height: height) + keyButton(for: .submit, width: width, height: height) + modeSwitchKey(width: width, height: height) + switchButton(width: width, height: height) + } + } + + // 固件 ws2812_mode_e (psk_ws2812.h) → Swift 灯效样式 + private func lightModeToEffect(_ mode: Int) -> LightEffectStyle { + switch mode { + case 1: return .singleMove + case 2: return .rainbowMove + case 3: return .rainbowWave + case 4: return .rainbowWaveSlow + case 5: return .breathing + case 6: return .middleLight + default: return .off + } + } + + private static let firmwareRed = Color(red: 240 / 255, green: 32 / 255, blue: 41 / 255) + private static let firmwareBlue = Color(red: 32 / 255, green: 80 / 255, blue: 255 / 255) + + private func firmwareLEDState(ideState: IDEState?, modeData: Int, switchState: Int) -> (LightEffectStyle, Color) { + guard let s = ideState else { + return (.off, Self.firmwareRed) + } + let effect = modeDraft.lightBar.effect(for: s) + let color: Color = s == .preToolUse && switchState != 0 ? Self.firmwareBlue : Self.firmwareRed + return (effect, color) + } + + private func ledBarButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.lightBar + // 略向上、宽度往里收:让选中态阴影(radius 10pt)跟键盘内描边、按键灰底、LCD 都有 ≥ 5 个基线单位的余量 + let rect = frame(13.0, 4.5, 53.5, 8.6, width: width, height: height) + let modeData = modeDraft.mode.rawValue + let effect: LightEffectStyle + let baseColor: Color + if let live = liveLightMode { + // BLE 连接且 mode tab 与物理 workMode 一致:直接信任固件回报的 ws2812_mode + claude_state + effect = lightModeToEffect(live) + let liveIDE: IDEState? = liveIDEStateValue.flatMap { IDEState(rawValue: UInt8($0)) } + // 颜色:仅 preToolUse + manual 是蓝,其他均红(与固件 ws2812_single_color 设定一致) + if let s = liveIDE, s == .preToolUse, switchState != 0 { + baseColor = Self.firmwareBlue + } else { + baseColor = Self.firmwareRed + } + } else { + // 离线/查看非物理档位:按固件逻辑模拟 update_claude_ws2812() + let previewIDE = lightBarPreview + (effect, baseColor) = firmwareLEDState(ideState: previewIDE, modeData: modeData, switchState: switchState) + } + return Button { + onSelect(part) + } label: { + VStack(spacing: rect.height * 0.12) { + Text("灯条") + .font(.system(size: max(rect.height * 0.18, 10), weight: .semibold)) + .foregroundStyle(Color.black.opacity(0.72)) + .frame(maxWidth: .infinity, alignment: .center) + + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let colors = ledColors(effect: effect, time: context.date.timeIntervalSince1970, count: 10, baseColor: baseColor) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.12)) + HStack(spacing: rect.width * 0.026) { + ForEach(0..<10, id: \.self) { index in + Capsule() + .fill(colors[index]) + .frame(width: rect.width * 0.072, height: rect.height * 0.26) + .shadow(color: colors[index].opacity(0.65), radius: 2.5) + } + } + .padding(.horizontal, rect.width * 0.04) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: rect.height * 0.48) + } + } + .frame(width: rect.width, height: rect.height) + .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) + } + .buttonStyle(.plain) + .position(x: rect.midX, y: rect.midY) + } + + private func oledButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.oledDisplay + let rect = frame(71.2, 7.7, 24.2, 13.4, width: width, height: height) + return Button { + onSelect(part) + } label: { + ZStack(alignment: .topTrailing) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.92)) + oledInnerContent(rect: rect) + } + // 右上角徽章:反映键盘 flash 真实状态 + pictureStateBadge(rect: rect) + } + .frame(width: rect.width, height: rect.height) + .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) + } + .buttonStyle(.plain) + .position(x: rect.midX, y: rect.midY) + } + + @ViewBuilder + private func pictureStateBadge(rect: CGRect) -> some View { + if let count = keyboardPictureFrameCount { + let isUploaded = count > 0 + let label = isUploaded ? "✓ 已上传 \(count) 帧" : "未上传" + Text(label) + .font(.system(size: max(rect.height * 0.11, 8), weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, rect.width * 0.04) + .padding(.vertical, rect.height * 0.02) + .background( + Capsule() + .fill(isUploaded ? Color.green.opacity(0.85) : Color.gray.opacity(0.85)) + ) + .padding(rect.width * 0.025) + } + } + + /// 真实 LCD 是 160×80(2:1)。在 slot 中央用一个 2:1 的"屏幕区"渲染内容, + /// 周围留键盘黑壳作为外框;图片 / 占位都在屏幕区内 .fit,不会撑出范围、不会被裁切。 + private func screenInnerSize(for rect: CGRect) -> CGSize { + let screenAspect: CGFloat = 2.0 + if rect.width / rect.height >= screenAspect { + let h = rect.height * 0.86 + return CGSize(width: h * screenAspect, height: h) + } else { + let w = rect.width * 0.86 + return CGSize(width: w, height: w / screenAspect) + } + } + + private func oledInnerContent(rect: CGRect) -> some View { + let size = screenInnerSize(for: rect) + return ZStack { + Color.clear + screenBody(screenWidth: size.width, screenHeight: size.height) + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func screenBody(screenWidth: CGFloat, screenHeight: CGFloat) -> some View { + if let gifPath = modeDraft.oled.localAssetPath { + // .id(gifPath) 强制 SwiftUI 在路径切换时销毁并重建视图, + // 否则旧路径的 @State frames/currentFrame/timer 会与新路径错位, + // 导致 Mode 切换瞬间画布渲染上一档 GIF 的某一帧(claude / cursor 互窜)。 + AnimatedGIFView(path: gifPath, fps: modeDraft.oled.framesPerSecond) + .id(gifPath) + } else { + ZStack { + LinearGradient( + colors: [Color.black.opacity(0.6), Color.black.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + VStack(alignment: .center, spacing: 2) { + if modeDraft.mode == .mode0 { + HStack(spacing: 4) { + Image(systemName: "cloud.fill") + .font(.system(size: screenHeight * 0.24, weight: .semibold)) + .foregroundStyle(Color.orange.opacity(0.92)) + Text(modeDraft.mode.title) + .font(.system(size: screenHeight * 0.20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + Text("默认动图") + .font(.system(size: screenHeight * 0.18)) + .foregroundStyle(.white.opacity(0.55)) + } else { + HStack(spacing: 4) { + Image(systemName: { + if #available(macOS 13, *) { "sparkles.rectangle.stack" } else { "rectangle.stack" } + }()) + .font(.system(size: screenHeight * 0.22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.78)) + Text("未上传") + .font(.system(size: screenHeight * 0.20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + Text("等待自定义") + .font(.system(size: screenHeight * 0.18)) + .foregroundStyle(.white.opacity(0.55)) + } + } + .padding(screenWidth * 0.04) + .multilineTextAlignment(.center) + } + } + } + + private func keyButton(for role: AhaKeyKeyRole, width: CGFloat, height: CGFloat) -> some View { + let part = role.part + let keyDraft = modeDraft.key(for: role) + let specs: (x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) + switch role { + case .voice: + specs = (10.2, 29.2, 16.2, 16.8) + case .approve: + specs = (27.2, 29.2, 16.2, 16.8) + case .reject: + specs = (44.2, 29.2, 16.2, 16.8) + case .submit: + specs = (61.2, 29.2, 16.2, 16.8) + } + let rect = frame(specs.x, specs.y, specs.w, specs.h, width: width, height: height) + return Button { + onSelect(part) + } label: { + VStack(spacing: rect.height * 0.07) { + ZStack { + RoundedRectangle(cornerRadius: rect.width * 0.18, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white, Color(red: 0.95, green: 0.96, blue: 0.98)], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, y: 4) + + keyIcon(for: role, size: rect.height * 0.28) + } + .frame(width: rect.width * 0.8, height: rect.height * 0.76) + + Text(keyDraft.description.isEmpty ? keyDraft.displaySummary : keyDraft.description) + .font(.system(size: rect.height * 0.11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity) + } + .frame(width: rect.width, height: rect.height) + .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + } + + @ViewBuilder + private func keyIcon(for role: AhaKeyKeyRole, size: CGFloat) -> some View { + Image(systemName: role.systemImage) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(Color.black.opacity(0.88)) + } + + private func modeSwitchKey(width: CGFloat, height: CGFloat) -> some View { + let rect = frame(78.9, 40.9, 8.0, 10.2, width: width, height: height) + return Button { + onModeSwitch() + } label: { + VStack(spacing: rect.height * 0.08) { + ZStack { + RoundedRectangle(cornerRadius: rect.width * 0.2, style: .continuous) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: rect.width * 0.2, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 10, y: 4) + + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: rect.height * 0.18, weight: .semibold)) + .foregroundStyle(Color.accentColor.opacity(0.72)) + } + .frame(width: rect.width * 0.78, height: rect.height * 0.5) + + Text("Mode") + .font(.system(size: rect.height * 0.1, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(width: rect.width, height: rect.height) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + .help("点击切换 Mode(模拟实体键)") + } + + private func switchButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.toggleSwitch + let rect = frame(87.8, 35.6, 6.8, 10.6, width: width, height: height) + return Button { + onSelect(part) + // 物理拨杆损坏的用户靠这个:点击即翻转 auto/manual。 + // 最新固件 0x91 用于灯效预览,因此这里只改 hook 软件覆盖。 + onSwitchToggle?() + } label: { + VStack(spacing: 6) { + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: rect.width * 0.18, style: .continuous) + .fill(Color.black.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: rect.width * 0.18, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 1) + ) + Capsule() + .fill(Color.white) + .frame(width: rect.width * 0.36, height: rect.height * 0.65) + .overlay(Circle().fill(Color.gray.opacity(0.24)).frame(width: rect.width * 0.28, height: rect.width * 0.28)) + .offset(y: switchTitle == "自动批准" ? -rect.height * 0.08 : rect.height * 0.12) + } + .frame(width: rect.width * 0.58, height: rect.height * 0.78) + + Text(switchTitle) + .font(.system(size: rect.height * 0.12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(width: rect.width, height: rect.height) + .modifier(HotspotChrome(part: part, selectedPart: selectedPart, dirtyParts: dirtyParts)) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + } + + private func frame(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, width: CGFloat, height: CGFloat) -> CGRect { + CGRect( + x: x / baseWidth * width, + y: y / baseHeight * height, + width: w / baseWidth * width, + height: h / baseHeight * height + ) + } + + private func position(_ x: CGFloat, _ y: CGFloat, width: CGFloat, height: CGFloat) -> CGPoint { + CGPoint(x: x / baseWidth * width, y: y / baseHeight * height) + } + + private func scaled(_ value: CGFloat, in width: CGFloat) -> CGFloat { + value / baseWidth * width + } + + private func ledColors(effect: LightEffectStyle, time: TimeInterval, count: Int, + baseColor: Color = Self.firmwareRed) -> [Color] { + switch effect { + case .off: + return Array(repeating: Color.gray.opacity(0.15), count: count) + case .middleLight: + let center = Double(count - 1) / 2.0 + return (0..= 0 ? 0.0 : max(0, 1.0 + dist * 0.25) + let head = dist >= 0 && dist < 1.5 ? max(0, 1.0 - dist * 0.65) : 0.0 + return baseColor.opacity(0.08 + max(tail, head) * 0.88) + } + case .scanBar: + let period = 2.0 + let t = time.truncatingRemainder(dividingBy: period) / period + let pos = t < 0.5 ? t * 2.0 * Double(count - 1) : (1.0 - (t - 0.5) * 2.0) * Double(count - 1) + return (0.. 0 ? 0.9 : 0.1 + let orange = Color(red: 1.0, green: 0.6, blue: 0.0) + return Array(repeating: orange.opacity(blink), count: count) + case .successSweep: + let green = Color(red: 0.1, green: 0.85, blue: 0.3) + let progress = time.truncatingRemainder(dividingBy: 2.0) / 2.0 + let fillPos = progress * Double(count + 2) - 1 + return (0.. 0 ? 1.0 : 0.4 + return (0..= 0, currentFrame < frames.count { + Image(nsImage: frames[currentFrame]) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Color.black + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .onAppear { loadFrames() } + .onDisappear { + gifTimer?.invalidate() + gifTimer = nil + } + } + + private func loadFrames() { + let url = URL(fileURLWithPath: path) + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return } + let count = CGImageSourceGetCount(src) + guard count > 0 else { return } + var images: [NSImage] = [] + for i in 0.. 1 else { return } + let interval = 1.0 / Double(max(fps, 1)) + gifTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + currentFrame = (currentFrame + 1) % max(1, frames.count) + } + } +} + +private func openNativeSpeechPrivacySettingsURL() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition", + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:com.apple.Keyboard-Settings.extension", + "x-apple.systempreferences:com.apple.preference.security?Privacy" + ] + + openFirstAvailableSystemSettingsURL(candidates) +} + +/// 输入监控 / 辅助功能 / 麦克风和语音转写:系统在「已拒绝」或部分版本下不会再弹权限窗。主动申请后打开「隐私与安全性」相关页,保证有可操作反馈。 +@MainActor +private func openCombinedVoicePrivacySettingsURL() { + // 勿用未文档化的 `x-apple.systemsettings` + `.extension` 等组合;在部分系统上会被当成「文稿」, + // 连续弹出「在 App Store 搜索… / 选取应用程序」而非进入设置。 + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition", + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:com.apple.Keyboard-Settings.extension", + "x-apple.systempreferences:com.apple.preference.security?Privacy", + ] + if openFirstAvailableSystemSettingsURL(candidates) { return } + let appPaths = [ + "/System/Applications/System Settings.app", + "/System/Library/CoreServices/Applications/System Settings.app", + "/System/Applications/System Preferences.app", + ] + for path in appPaths where FileManager.default.fileExists(atPath: path) { + if NSWorkspace.shared.open(URL(fileURLWithPath: path)) { + return + } + } +} + +@discardableResult +private func openFirstAvailableSystemSettingsURL(_ candidates: [String]) -> Bool { + for candidate in candidates { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return true + } + } + return false +} + +@MainActor +private func openFirstMissingVoicePermissionSettings( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService +) { + if !bleManager.bluetoothPermissionGranted || !bleManager.bluetoothPoweredOn { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth"]) { return } + } + if !nativeSpeech.microphoneGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"]) { return } + } + if !nativeSpeech.speechRecognitionGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition"]) { return } + } + if !nativeSpeech.siriEnabled { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.Siri-Settings.extension"]) { return } + } + if !nativeSpeech.dictationEnabled { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.Keyboard-Settings.extension"]) { return } + } + if !voiceRelay.accessibilityGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) { return } + } + if !voiceRelay.inputMonitoringGranted { + if openFirstAvailableSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) { return } + } + openCombinedVoicePrivacySettingsURL() +} + +/// 先走系统 API 申请;随后在桌面端打开「隐私与安全性」相关页。输入监控 / 辅助功能在多数 macOS 版本上**不会**像 iOS 那样弹窗,麦克风和语音在「已选择过」后也不再弹窗,因此必须配合系统设置界面。 +@MainActor +private func requestPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService, + delay: TimeInterval = 0.45 +) { + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(requestIfNeeded: true) + nativeSpeech.refreshPermissions(requestIfNeeded: true) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + bleManager.refreshBluetoothAuthorization() + openFirstMissingVoicePermissionSettings(bleManager: bleManager, voiceRelay: voiceRelay, nativeSpeech: nativeSpeech) + } +} + +/// 先启动一个延迟重开助手,再退出当前进程。不要在旧进程仍存活时 `open -n`: +/// AppDelegate 有单实例保护,新实例会发现旧实例还在并立即退出,造成"新程序闪退、旧程序不关"。 +private func relaunchApplicationForPermissionRefresh() { + let bundlePath = Bundle.main.bundleURL.path + let script = "sleep 0.8; /usr/bin/open \(shellQuoted(bundlePath))" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", script] + do { + try process.run() + } catch { + // 即使自动重开助手启动失败,也要让当前进程正常退出;用户可手动再打开。 + } + + NSApp.windows.forEach { $0.close() } + NSApp.terminate(nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if NSApp.isRunning { + exit(0) + } + } +} + +private func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" +} + +@MainActor +private func activateAhaKeyWindowForTextInput() { + NSApp.activate(ignoringOtherApps: true) + NSApp.keyWindow?.makeKeyAndOrderFront(nil) + NSApp.mainWindow?.makeKeyAndOrderFront(nil) +} + +/// 在系统「隐私与安全性」中改完权限后,用确认框引导用户:退出后由 `open -n` 自动拉起同一份 .app。 +private struct RestartToApplyPermissionsButton: View { + var title: String = "退出并重新打开…" + @State private var showConfirm = false + + var body: some View { + Button(title) { showConfirm = true } + .buttonStyle(.bordered) + .help("在系统设置中修改权限后,需重启本应用,检测才会与系统一致。") + .alert("需要重启以刷新权限", isPresented: $showConfirm) { + Button("取消", role: .cancel) {} + Button("立即重启") { relaunchApplicationForPermissionRefresh() } + } message: { + Text("将先退出本应用,再自动重新打开。重新打开后「重新检查权限」会读取最新系统状态。") + } + } +} + +private struct DeviceInfoSheetContainer: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var bleManager: AhaKeyBLEManager + + var body: some View { + VStack(spacing: 0) { + deviceInfoTitleChrome + sheetScrollView + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { + activateAhaKeyWindowForTextInput() + } + } + + @ViewBuilder + private var sheetScrollView: some View { + if #available(macOS 13.0, *) { + ScrollView { + sheetFormContent + } + .scrollIndicators(.visible) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + sheetFormContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var sheetFormContent: some View { + DeviceInfoView(bleManager: bleManager) + .padding(.horizontal, 18) + .padding(.bottom, 18) + .padding(.top, 6) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + } + + private var deviceInfoTitleChrome: some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 12) { + Text("设备信息 · Agent") + .font(.headline) + Spacer(minLength: 0) + Button { + dismiss() + } label: { + Label("关闭", systemImage: "xmark.circle.fill") + } + .labelStyle(.titleAndIcon) + .buttonStyle(.bordered) + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + Divider() + } + .layoutPriority(1) + .fixedSize(horizontal: false, vertical: true) + .frame(minHeight: 48) + .background(Color(nsColor: .windowBackgroundColor)) + } +} + +private struct CloudAccountView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var account = CloudAccountManager.shared + @StateObject private var optimizer = AhaTypeTextOptimizer.shared + @FocusState private var focusedLoginField: LoginField? + + private enum LoginField { + case phone + case password + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("云端账号 · AhaType") + .font(.headline) + Spacer() + Button("关闭") { dismiss() } + } + .padding(16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if account.isLoggedIn { + profileSection + } else { + loginSection + } + + Divider() + + ahaTypeSection + } + .padding(18) + } + } + .alert("云端账号", isPresented: Binding( + get: { account.alertMessage != nil }, + set: { if !$0 { account.alertMessage = nil } } + )) { + Button("好", role: .cancel) { account.alertMessage = nil } + } message: { + Text(account.alertMessage ?? "") + } + .onAppear { + activateAhaKeyWindowForTextInput() + optimizer.refreshFromDisk() + if account.isLoggedIn { + account.refreshProfile() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + activateAhaKeyWindowForTextInput() + focusedLoginField = .phone + } + } + } + } + + private var loginSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("登录后可使用 AhaType 云端大模型整理。") + .font(.callout) + .foregroundStyle(.secondary) + + TextField("手机号", text: $account.phone) + .textFieldStyle(.roundedBorder) + .focused($focusedLoginField, equals: .phone) + .onTapGesture { + activateAhaKeyWindowForTextInput() + focusedLoginField = .phone + } + .onSubmit { focusedLoginField = .password } + + SecureField("密码", text: $account.password) + .textFieldStyle(.roundedBorder) + .focused($focusedLoginField, equals: .password) + .onTapGesture { + activateAhaKeyWindowForTextInput() + focusedLoginField = .password + } + .onSubmit { account.login() } + + Toggle("记住密码", isOn: $account.rememberPassword) + + HStack(spacing: 10) { + Button("登录") { account.login() } + .buttonStyle(.borderedProminent) + .disabled(account.isBusy) + Button("注册") { account.register() } + .buttonStyle(.bordered) + .disabled(account.isBusy) + } + + Text(account.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var profileSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text(account.profileSummary) + .font(.callout) + .textSelection(.enabled) + + VStack(alignment: .leading, spacing: 8) { + quotaRow(title: "每日", value: account.quotaText("daily")) + quotaRow(title: "每周", value: account.quotaText("weekly")) + quotaRow(title: "每月", value: account.quotaText("monthly")) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + + HStack(spacing: 10) { + Button("刷新") { account.refreshProfile() } + .buttonStyle(.borderedProminent) + .disabled(account.isBusy) + Button("切换账号") { + account.prepareForRelogin() + focusedLoginField = .phone + } + .buttonStyle(.bordered) + .disabled(account.isBusy) + Button("退出登录") { account.logout() } + .buttonStyle(.bordered) + .disabled(account.isBusy) + } + + rechargeSection + + HStack(spacing: 10) { + TextField("免费券兑换码", text: $account.couponCode) + .textFieldStyle(.roundedBorder) + Button("兑换") { account.redeemCoupon() } + .buttonStyle(.bordered) + .disabled(account.isBusy) + } + + Text(account.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var rechargeSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("充值订阅") + .font(.callout.weight(.semibold)) + + HStack(spacing: 8) { + ForEach(CloudRechargePlan.allCases) { plan in + Button { + account.createWechatOrder(plan: plan) + } label: { + VStack(spacing: 3) { + Text(plan.title) + .font(.caption.weight(.semibold)) + Text(account.priceText(for: plan)) + .font(.caption2) + .foregroundStyle(.secondary) + Text(plan.subtitle) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.bordered) + .disabled(account.isBusy) + } + } + + if let order = account.paymentOrder { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + if let image = makeQRCodeImage(from: order.paymentURL) { + Image(nsImage: image) + .interpolation(.none) + .resizable() + .frame(width: 132, height: 132) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + VStack(alignment: .leading, spacing: 6) { + Text("\(order.plan.title) · \(order.amountText)") + .font(.caption.weight(.semibold)) + Text("微信扫码完成支付,支付成功后会自动刷新额度。") + .font(.caption) + .foregroundStyle(.secondary) + Text("订单:\(order.outTradeNo)") + .font(.caption2) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + Text("状态:\(order.status)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + HStack(spacing: 8) { + Button("复制支付链接") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(order.paymentURL, forType: .string) + } + .buttonStyle(.bordered) + + Button("刷新到账") { + account.refreshCurrentPaymentOrder() + } + .buttonStyle(.bordered) + .disabled(account.isBusy) + + Button("关闭订单") { + account.clearPaymentOrder() + } + .buttonStyle(.bordered) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + } + } + + private var ahaTypeSection: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle(isOn: Binding( + get: { optimizer.isEnabled }, + set: { optimizer.setEnabled($0) } + )) { + Text("启用 AhaType 云端整理") + .font(.callout.weight(.semibold)) + } + .toggleStyle(.switch) + + Text("开启后,macOS 原生语音转写完成后会先请求云端整理,再粘贴整理后的文本。未登录、过期或网络失败时会自动回退原始转写。") + .font(.caption) + .foregroundStyle(.secondary) + + Text(optimizer.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + + Text(optimizer.lastQuotaSummary) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + private func quotaRow(title: String, value: String) -> some View { + HStack { + Text(title) + .font(.caption.weight(.semibold)) + Spacer() + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func makeQRCodeImage(from text: String) -> NSImage? { + guard let data = text.data(using: .utf8), + let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + let representation = NSCIImageRep(ciImage: scaled) + let image = NSImage(size: representation.size) + image.addRepresentation(representation) + return image + } +} + +private struct HotspotChrome: ViewModifier { + let part: AhaKeyStudioPart + let selectedPart: AhaKeyStudioPart + let dirtyParts: Set + + func body(content: Content) -> some View { + let isSelected = selectedPart == part + content + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + // 非选中时几乎隐形,避免每个 hotspot 都画一圈灰线和邻近元件视觉打架 + .strokeBorder( + isSelected ? Color.accentColor : Color.black.opacity(0.015), + lineWidth: isSelected ? 2 : 1 + ) + ) + .overlay(alignment: .topTrailing) { + if dirtyParts.contains(part) { + Circle() + .fill(Color.orange) + .frame(width: 8, height: 8) + .padding(8) + } + } + // 选中态阴影从 10 收到 6,减少向邻近元件溢出的发光半径 + .shadow(color: isSelected ? Color.accentColor.opacity(0.18) : .clear, radius: 6) + } +} + +private struct OLEDMotionPreviewSheet: View { + let modeTitle: String + let assetPath: String? + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(modeTitle) 动图预览") + .font(.system(size: 20, weight: .semibold)) + Text("这里展示的是你刚选中的 GIF 动图文件。") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + Button("关闭") { + dismiss() + } + .buttonStyle(.bordered) + } + + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.92)) + + if let assetPath { + DraggableAnimatedGIFPreview(path: assetPath) + .padding(12) + } else { + VStack(spacing: 10) { + Image(systemName: { + if #available(macOS 14, *) { "film.stack" } else { "film" } + }()) + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(.secondary) + Text("还没有选择动图") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(minWidth: 480, minHeight: 240) + } + } + .frame(maxWidth: .infinity, minHeight: 240, maxHeight: 460) + .clipped() + } + .padding(24) + .frame(minWidth: 620, minHeight: 380) + } +} + +/// 支持鼠标按住拖拽(上下左右)查看大图,避免仅靠滚轮导致横向浏览困难。 +private struct DraggableAnimatedGIFPreview: View { + let path: String + @State private var imageSize = CGSize(width: 480, height: 240) + @State private var offset: CGSize = .zero + @State private var dragStartOffset: CGSize = .zero + + var body: some View { + GeometryReader { proxy in + let viewportSize = proxy.size + AnimatedGIFPreview(path: path) + .frame(width: imageSize.width, height: imageSize.height) + .position( + x: viewportSize.width / 2 + offset.width, + y: viewportSize.height / 2 + offset.height + ) + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + let proposed = CGSize( + width: dragStartOffset.width + value.translation.width, + height: dragStartOffset.height + value.translation.height + ) + offset = clampOffset(proposed, imageSize: imageSize, viewportSize: viewportSize) + } + .onEnded { _ in + dragStartOffset = offset + } + ) + .onAppear { + reloadImageSizeAndResetOffset() + } + .onChange(of: path) { _ in + reloadImageSizeAndResetOffset() + } + } + } + + private func reloadImageSizeAndResetOffset() { + if let image = NSImage(contentsOfFile: path), image.size.width > 0, image.size.height > 0 { + imageSize = image.size + } else { + imageSize = CGSize(width: 480, height: 240) + } + offset = .zero + dragStartOffset = .zero + } + + private func clampOffset(_ proposed: CGSize, imageSize: CGSize, viewportSize: CGSize) -> CGSize { + let maxX = max(0, (imageSize.width - viewportSize.width) / 2) + let maxY = max(0, (imageSize.height - viewportSize.height) / 2) + return CGSize( + width: min(max(proposed.width, -maxX), maxX), + height: min(max(proposed.height, -maxY), maxY) + ) + } +} + +private struct AnimatedGIFPreview: NSViewRepresentable { + let path: String + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.animates = true + imageView.wantsLayer = true + imageView.layer?.cornerRadius = 12 + imageView.layer?.masksToBounds = true + imageView.imageAlignment = .alignCenter + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = NSImage(contentsOfFile: path) + } +} + +// MARK: - 帮助中心(内嵌弹窗) + +private enum HelpTopic: String, CaseIterable, Identifiable { + case overview = "总览" + case modes = "四个 Mode" + case canvas = "画布与按键" + case toggleSwitch = "虚拟拨杆" + case oled = "LCD 屏幕" + case lightBar = "灯条颜色" + case voice = "语音输入" + case diagnostics = "权限诊断" + case faq = "常见问题" + + var id: String { rawValue } + + var iconName: String { + switch self { + case .overview: return "sparkles" + case .modes: return "square.grid.3x1.below.line.grid.1x2" + case .canvas: return "keyboard" + case .toggleSwitch: return "switch.2" + case .oled: return "play.tv" + case .lightBar: return "rainbow" + case .voice: return "mic.circle" + case .diagnostics: return "stethoscope" + case .faq: return "questionmark.bubble" + } + } +} + +private struct HelpCenterSheet: View { + let studioDraft: AhaKeyStudioDraft + let selectedMode: AhaKeyModeSlot + @ObservedObject var bleManager: AhaKeyBLEManager + @Environment(\.dismiss) private var dismiss + @State private var topic: HelpTopic = .overview + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "book.closed.fill") + .font(.title3) + .foregroundStyle(.tint) + Text("AhaKey Studio 帮助中心") + .font(.title3.weight(.semibold)) + Spacer() + Button("完成") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(.thinMaterial) + + Divider() + + HStack(spacing: 0) { + sidebar + .frame(width: 188) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ScrollView { + contentForTopic + .padding(.horizontal, 28) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .id(topic) + } + } + .frame(width: 880, height: 620) + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(HelpTopic.allCases) { t in + Button { + topic = t + } label: { + HStack(spacing: 10) { + Image(systemName: t.iconName) + .font(.system(size: 13, weight: .medium)) + .frame(width: 18) + Text(t.rawValue) + .font(.callout) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(t == topic ? Color.accentColor.opacity(0.16) : Color.clear) + ) + .foregroundStyle(t == topic ? Color.accentColor : Color.primary) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .padding(12) + } + + @ViewBuilder + private var contentForTopic: some View { + switch topic { + case .overview: OverviewTopicView() + case .modes: ModesTopicView(selectedMode: selectedMode) + case .canvas: CanvasTopicView() + case .toggleSwitch: ToggleSwitchTopicView(bleManager: bleManager) + case .oled: OLEDTopicView(studioDraft: studioDraft, bleManager: bleManager) + case .lightBar: LightBarTopicView() + case .voice: VoiceTopicView() + case .diagnostics: DiagnosticsTopicView() + case .faq: FAQTopicView() + } + } +} + +// MARK: 帮助中心 - 通用排版 + +private struct HelpTitle: View { + let icon: String + let title: String + var subtitle: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.tint) + Text(title).font(.title2.weight(.semibold)) + } + if let subtitle { + Text(subtitle) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(.bottom, 12) + } +} + +private struct HelpSection: View { + let title: String + let text: String + + init(title: String, body text: String) { + self.title = title + self.text = text + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.headline) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 14) + } +} + +private struct HelpNote: View { + let icon: String + let tint: Color + let text: String + + init(_ icon: String, tint: Color = .orange, body text: String) { + self.icon = icon + self.tint = tint + self.text = text + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(tint) + .padding(.top, 2) + Text(text) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(tint.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(tint.opacity(0.25), lineWidth: 1) + ) + ) + .padding(.vertical, 6) + } +} + +private struct HelpSwatch: View { + let color: Color + let label: String + let detail: String + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(color) + .frame(width: 22, height: 22) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 0.5) + ) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.callout.weight(.medium)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } +} + +// MARK: 帮助中心 - 各章节 + +private struct OverviewTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "sparkles", + title: "总览", + subtitle: "AhaKey Studio 是 AhaKey 小键盘的 macOS 配置中心" + ) + + HelpSection( + title: "三件套是怎么协同的", + body: """ + • 主 App(你正在用的)— 看配置、改键位、上传 LCD 动图、查诊断 + • Agent 守护进程 — 后台常驻;监听 IDE 的 Hook(Claude / Cursor / Codex / Kimi),并在 BLE 上向键盘转发当前 AI 状态 + • 键盘固件 — 收到 BLE 状态后驱动灯条颜色、LCD 显示、按键映射 + """ + ) + + HelpSection( + title: "BLE 占用是一道单行道", + body: """ + 同一时刻只有一个进程能持有键盘的 BLE 连接: + • 默认 Agent 占用 → Hook 状态实时上键盘、自动批准链可用 + • 你在画布点「修改」时 → 主 App 临时接管,能上传 LCD 动图、改键位、读图片元信息 + • 点「返回」 → 主 App 释放,Agent 自动接回 + """ + ) + + HelpNote("info.circle.fill", tint: .blue, body: "首次连接,可以先打开「权限诊断」过一遍权限项;任何 Hook 不生效的问题大多在权限里。") + } + } +} + +private struct ModesTopicView: View { + let selectedMode: AhaKeyModeSlot + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "square.grid.3x1.below.line.grid.1x2", + title: "四个 Mode", + subtitle: "硬件物理键码 + 软件配置同步切换" + ) + + ForEach(AhaKeyModeSlot.allCases) { mode in + modeCard(mode) + } + + HelpNote("hand.tap.fill", tint: .accentColor, body: "切换方式:键盘上的 Mode 拨杆,或主 App 顶部 Picker,或点画布上的 Mode 按钮。三处任一改动会同步另外两个。") + } + } + + @ViewBuilder + private func modeCard(_ mode: AhaKeyModeSlot) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(mode.title) + .font(.headline) + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(modeChipColor(mode), in: Capsule()) + Text(mode.name).font(.headline) + if mode == selectedMode { + Text("当前").font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.accentColor.opacity(0.18), in: Capsule()) + } + Spacer(minLength: 0) + } + Text(mode.subtitle).font(.callout).foregroundStyle(.secondary) + Text(mode.guidance).font(.callout).fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 1) + ) + .padding(.bottom, 6) + } + + private func modeChipColor(_ mode: AhaKeyModeSlot) -> Color { + switch mode { + case .mode0: return Color.orange + case .mode1: return Color.purple + case .mode2: return Color.green + case .mode3: return Color.blue + } + } +} + +private struct CanvasTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "keyboard", + title: "画布与按键", + subtitle: "中间那个像键盘的图就是你的小键盘 1:1 镜像,所有元件可点" + ) + + HelpSection(title: "六大热区", body: "灯条、LCD 屏幕、Key1(语音)、Key2、Key3、Key4、拨杆。点哪个就在右侧 Inspector 看到那个元件的配置。") + + VStack(alignment: .leading, spacing: 10) { + hotspotRow("rainbow", "灯条", "点亮键盘顶端 8 颗 WS2812 LED;颜色和效果跟随 IDE Hook 状态。") + hotspotRow("play.tv", "LCD 屏幕", "0.96\" IPS 显示;可上传 GIF 动图(160×80, RGB565)。") + hotspotRow("mic", "Key 1 / 语音键", "macOS 原生语音默认 F18;Typeless / 微信的 Fn 触发使用 F19。") + hotspotRow("checkmark.circle", "Key 2 / 通过键", "依 Mode 默认:Y / ↵ / ↵。可改成宏序列。") + hotspotRow("xmark.circle", "Key 3 / 拒绝键", "依 Mode 默认:N / ⌫ / Esc。可改成宏序列。") + hotspotRow("delete.left", "Key 4 / 删除键", "默认 Backspace,可改任意短按 / 长按。") + hotspotRow("switch.2", "拨杆", "auto 批准 vs manual 批准;详见「虚拟拨杆」章节。") + } + + HelpNote("hand.point.up.left", tint: .accentColor, body: """ + 点完元件 → Inspector 显示「修改」按钮。点「修改」会接管 BLE 进入编辑态;改完点「写入键盘」写入配置,点「返回」退出编辑。 + """) + } + } + + private func hotspotRow(_ icon: String, _ title: String, _ desc: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .frame(width: 22) + .foregroundStyle(.tint) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.medium)) + Text(desc).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(.vertical, 2) + } +} + +private struct ToggleSwitchTopicView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "switch.2", + title: "虚拟拨杆", + subtitle: "物理拨杆坏了?或想软件控制?看这里" + ) + + HelpSection(title: "两档分别管什么", body: """ + • 自动批准(switchState=0):Hook 拦截每次工具调用 / 命令请求时直接放行 + • 手动批准(switchState=1):Hook 把决定交回终端,由你手动按 Key2/Key3 通过或拒绝 + """) + + VStack(alignment: .leading, spacing: 8) { + Text("点画布拨杆触发三件事(不是所有都生效):").font(.subheadline.weight(.medium)) + triggerRow( + num: "1", + title: "乐观更新画布", + desc: "立即翻转画布拨杆位置 + 顶部状态栏;视觉零延迟", + works: true + ) + triggerRow( + num: "2", + title: "通知 Agent 设置 userSwitchOverride", + desc: "Hook 的 auto-approve 立即切换到你选的档位。持久化到 UserDefaults,agent 重启仍生效", + works: true + ) + triggerRow( + num: "3", + title: "软件覆盖拨杆", + desc: "最新固件 0x91 已用于灯效预览;虚拟拨杆只影响 Hook auto-approve,不再写键盘 sw_state。", + works: false, + requiresPatch: false + ) + } + + HelpNote("exclamationmark.triangle.fill", tint: .orange, body: "虚拟拨杆不再占用 0x91,避免与最新固件的灯效预览命令冲突。") + + VStack(alignment: .leading, spacing: 8) { + Text("现状一览").font(.subheadline.weight(.medium)) + stateRow("当前生效值", "\(bleManager.agentSwitchState ?? bleManager.switchState)") + stateRow("Agent 端覆盖", bleManager.agentSwitchState != nil ? "\(bleManager.agentSwitchState!)(覆盖中)" : "未设置(用键盘真实值)") + stateRow("乐观显示中", bleManager.optimisticSwitchOverride != nil ? "是(等待对齐)" : "否") + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + } + } + + private func triggerRow(num: String, title: String, desc: String, works: Bool, requiresPatch: Bool = false) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(num) + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(Circle().fill(works ? Color.green : Color.orange)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(title).font(.callout.weight(.medium)) + if requiresPatch { + Text("需固件支持").font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.orange.opacity(0.18), in: Capsule()) + } + } + Text(desc).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + + private func stateRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.callout).foregroundStyle(.secondary) + Spacer() + Text(value).font(.callout.monospaced()) + } + } +} + +private struct OLEDTopicView: View { + let studioDraft: AhaKeyStudioDraft + @ObservedObject var bleManager: AhaKeyBLEManager + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "play.tv", + title: "LCD 屏幕", + subtitle: "0.96\" IPS · 160×80 · RGB565 · 内置 16 Mbit Flash 存帧" + ) + + HelpSection(title: "默认动图(连接即自动同步)", body: """ + Mode 1 → claude_0.gif(出厂内置) + Mode 2 → cursor.gif + Mode 3 → codex.gif + Mode 4 → 预留/自定义 + + 首次连接键盘且发现某个 Mode 的 flash slot 为空时,主 App 会自动把对应 bundle GIF 推到键盘上。 + """) + + HelpSection(title: "替换成自己的 GIF", body: """ + 1. 画布点 LCD 屏幕 → Inspector 显示「修改」 + 2. 点「修改」进入编辑态(接管 BLE) + 3. 选择你的 .gif(推荐 ≤200 帧、≤2MB),可先在虚拟屏幕里预览 + 4. 确认后点底部「写入键盘」统一写入设备 + """) + + HelpSection(title: "LCD 角标的含义", body: """ + • 绿色「✓ 已上传 N 帧」:键盘 flash 真有 N 帧(你或自动同步推的) + • 灰色「未上传」:键盘 flash 空,正显示固件默认或留空 + • 没有徽章:还没自占 BLE 查到(点过一次「修改」就有了) + """) + + VStack(alignment: .leading, spacing: 6) { + Text("现在键盘 flash 各 Mode 状态").font(.subheadline.weight(.medium)) + ForEach(AhaKeyModeSlot.allCases) { mode in + HStack { + Text(mode.title + " · " + mode.name).font(.callout) + Spacer() + if let s = bleManager.keyboardPictureStates[mode.rawValue] { + if s.frameCount > 0 { + Label("\(s.frameCount) 帧", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + } else { + Label("空", systemImage: "tray").foregroundStyle(.secondary).font(.callout) + } + } else { + Text("尚未查询").font(.callout).foregroundStyle(.tertiary) + } + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + + HelpNote("info.circle.fill", tint: .blue, body: "切换 Mode 时 LCD 会先闪一下当前按键 description 文本(机械感效果),约 1 秒后回到该 Mode 的动图。") + } + } +} + +private struct LightBarTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "rainbow", + title: "灯条颜色", + subtitle: "8 颗 WS2812B,颜色由固件 update_claude_ws2812() 决定,1:1 还原在画布上" + ) + + HelpSection(title: "颜色对照表", body: "下面是 Mode 1(Claude)下,固件按 IDE state 的实际行为:") + + VStack(alignment: .leading, spacing: 8) { + HelpSwatch( + color: Color(red: 240/255, green: 32/255, blue: 41/255), + label: "0xF02029 (红)", + detail: "SessionStart / Stop / PostToolUse / PermissionRequest / UserPromptSubmit" + ) + HelpSwatch( + color: Color(red: 32/255, green: 80/255, blue: 255/255), + label: "0x2050FF (蓝)", + detail: "PreToolUse — 工具开始执行(manual 档专属)" + ) + HelpSwatch( + color: Color.gray.opacity(0.3), + label: "OFF (熄灭)", + detail: "SessionEnd — Claude 会话结束" + ) + } + + HelpSection(title: "Auto 档的彩虹覆盖", body: """ + 当拨杆 = auto (switchState=0) 时,固件把部分 state 强制改成彩虹效果: + • PreToolUse / PermissionRequest → 整条彩虹波浪 + • PostToolUse / UserPromptSubmit → 单点彩虹流水 + 这就是你看到「Cursor 一跑灯条变彩虹」的原因——是 auto 档的视觉提示,不是 Cursor 专属。 + """) + + HelpNote("exclamationmark.triangle.fill", tint: .orange, body: "Mode 1 / Mode 2 时,固件的 update_claude_ws2812() 直接 return,**灯条不再随 IDE state 变**,会停在上一次设定的颜色上。这是固件设计,不是 bug。") + } + } +} + +private struct VoiceTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "mic.circle", + title: "语音输入", + subtitle: "macOS 原生语音走 F18;Fn / Globe 触发走 F19" + ) + + HelpSection(title: "几种预设的差别", body: """ + • macOS 原生转写:在地化语言识别,识别完 ⌘V 写回光标。适合任何输入框 + • Fn/Globe:用于 Typeless、微信语音、豆包输入法,在对应软件内把快捷键设为 Fn/Globe + • 自定义快捷键:只写入键盘,不接管为固定语音预设 + • AhaType:先识别再优化提示词(需登录) + """) + + HelpSection(title: "短按 vs 长按", body: """ + • 短按(Toggle):第一次按开始,第二次按结束 — 适合长段话 + • 长按(Hold-to-speak):按住时录音,松开停 — 适合微信、豆包等需要"按住"的输入法 + + 两种模式在 Key 1 Inspector 的「触发方式」Tab 里切换。 + """) + + HelpNote("hand.raised.fill", tint: .red, body: "麦克风 + 输入监控 + 辅助功能三个权限都得给。打开「权限诊断」可以一键跳到系统设置对应页。") + } + } +} + +private struct DiagnosticsTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "stethoscope", + title: "权限诊断", + subtitle: "点底栏的「权限诊断」按钮打开(不是这里的页面)" + ) + + HelpSection(title: "权限清单", body: """ + • 蓝牙:连接键盘必须 + • 麦克风:苹果原生转写、AhaType、按住说话所有语音功能都需要 + • 输入监控:捕获语音键的按下/松开事件 + • 辅助功能:模拟键盘按键(用于 ⌘V 写回文本、注入 Fn/Globe 等) + • 语音识别:苹果原生转写 + • Siri 与听写(macOS 13+):原生转写依赖项 + """) + + HelpSection(title: "Agent 健康检查", body: """ + 打开「权限诊断」可以看到 Agent 自检结果: + • LaunchAgent 已注册:login item 装好 + • 进程在跑:launchd 拉起了 ahakeyconfig-agent + • Hook 已配置:Claude/Cursor/Codex/Kimi 的 .json / settings 都加好了 ahakey-hook 引用 + """) + + HelpSection(title: "转写测试在哪", body: "权限诊断弹窗里。可以不连键盘就验证 macOS 原生转写是否能识别。如果转写失败,多半是麦克风权限或没装语言模型(系统设置 → Siri 与听写 → 听写语言)。") + } + } +} + +private struct FAQTopicView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HelpTitle( + icon: "questionmark.bubble", + title: "常见问题", + subtitle: "如果下面没你的问题,可以提 issue 到 GitHub 仓库" + ) + + faq( + q: "Hook 拦不住,AI 还是会停下来问我", + a: """ + 按这顺序排查: + 1. Agent 在跑吗?打开「权限诊断」看 + 2. Agent 是否占着蓝牙?画布顶部应显示已连接,且不在编辑态 + 3. 拨杆在 auto 档?看顶部状态栏;不是的话点画布拨杆切到 auto + 4. IDE 的 Hook 文件配了吗?「权限诊断」会列出 Claude/Cursor/Codex/Kimi 各自的 Hook 安装状态 + 5. 装完后是否重启过 IDE?尤其 Kimi 安装/升级后必须完全关闭再重开 + """ + ) + + faq( + q: "画布上灯条不变色", + a: """ + • 检查右上角是否「已连接」 + • 切到正在用的 Mode + • 触发一次工具调用让 Hook 真的发 0x90 给键盘 + • 如果是手动批准档 + Mode 1:preToolUse 是蓝、其他状态是红 + """ + ) + + faq( + q: "LCD 自动同步没触发", + a: """ + 自动同步只在主 App 自占 BLE 时才查图片元信息。流程: + 1. 至少点一次「修改」让主 App 接管 BLE + 2. 四个 Mode 的 0x83 查询完成后才会触发 + 3. 只对 flash 为空(picLength=0)的 Mode 生效 + 4. 如果你曾经手动改过 Inspector 里的「上传 GIF」路径,自动同步会跳过那个 Mode(不覆盖你的选择) + """ + ) + + faq( + q: "拨杆我点了,但键盘灯效没切", + a: """ + 最新固件中 0x91 已用于灯效预览。虚拟拨杆只作为 Hook 软件覆盖,不再写入键盘 sw_state。 + """ + ) + + faq( + q: "OTA 升级有吗?", + a: """ + 规划中,下一版本会做。当前所有固件升级都需要 USB-ISP(拆机短 BOOT + wchisp)。详细方案在仓库 docs 里。 + """ + ) + } + } + + private func faq(q: String, a: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "questionmark.circle.fill") + .foregroundStyle(.tint) + .padding(.top, 1) + Text(q).font(.callout.weight(.medium)) + } + Text(a) + .font(.callout) + .foregroundStyle(.primary) + .padding(.leading, 26) + .fixedSize(horizontal: false, vertical: true) + Divider().padding(.leading, 26).padding(.top, 4) + } + .padding(.vertical, 8) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift b/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift new file mode 100644 index 00000000..f4973a51 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct CompatLabeledContent: View { + let title: String + @ViewBuilder let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .foregroundStyle(.secondary) + Spacer(minLength: 16) + content + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/ContentView.swift b/ahakeyconfig-mac/Sources/Views/ContentView.swift new file mode 100644 index 00000000..6278dc03 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/ContentView.swift @@ -0,0 +1,300 @@ +import AppKit +import SwiftUI + +struct ContentView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @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 { + mainWorkspace + .allowsHitTesting(!shouldShowUnifiedOnboarding) + .accessibilityHidden(shouldShowUnifiedOnboarding) + + if shouldShowUnifiedOnboarding { + UnifiedAhaKeyOnboardingView( + permissionState: onboardingPermissionState, + actions: onboardingActions + ) { _, _ in + unifiedOnboardingCompleted = true + dismissedIncompleteOnboardingThisSession = !onboardingPermissionState.allPermissionsGranted + voiceRelay.suppressPermissionOnboarding(for: 60) + } + .transition(.opacity) + .zIndex(20) + } + } + .onAppear { + if shouldShowUnifiedOnboarding { + voiceRelay.suppressPermissionOnboarding(for: 60) + } else { + voiceRelay.showsPermissionOnboarding = false + } + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .onChange(of: onboardingPermissionState.allPermissionsGranted) { allGranted in + if allGranted { + dismissedIncompleteOnboardingThisSession = false + unifiedOnboardingCompleted = true + voiceRelay.showsPermissionOnboarding = false + } else if shouldShowUnifiedOnboarding { + voiceRelay.suppressPermissionOnboarding(for: 60) + } + } + } + + @ViewBuilder + private var mainWorkspace: some View { + if #available(macOS 14.0, *) { + AhaKeyStudioView(bleManager: bleManager) + .focusEffectDisabled() + } else { + AhaKeyStudioView(bleManager: bleManager) + } + } + + private var onboardingPermissionState: AhaKeyOnboardingPermissionState { + AhaKeyOnboardingPermissionState( + bluetoothPermissionGranted: bleManager.bluetoothPermissionGranted, + bluetoothPoweredOn: bleManager.bluetoothPoweredOn, + inputMonitoringGranted: voiceRelay.inputMonitoringGranted, + accessibilityGranted: voiceRelay.accessibilityGranted, + microphoneGranted: nativeSpeech.microphoneGranted, + speechRecognitionGranted: nativeSpeech.speechRecognitionGranted, + siriEnabled: nativeSpeech.siriEnabled, + dictationEnabled: nativeSpeech.dictationEnabled, + voiceSummary: voiceRelay.statusMessage, + speechSummary: nativeSpeech.statusMessage, + isRecording: nativeSpeech.isRecording, + transcriptPreview: nativeSpeech.transcriptPreview, + lastCommittedText: nativeSpeech.lastCommittedText, + speechStatusMessage: nativeSpeech.statusMessage + ) + } + + private var shouldShowUnifiedOnboarding: Bool { + !unifiedOnboardingCompleted || + (!onboardingPermissionState.allPermissionsGranted && !dismissedIncompleteOnboardingThisSession) + } + + private var onboardingActions: AhaKeyOnboardingActions { + AhaKeyOnboardingActions( + requestPermissions: { + voiceRelay.suppressPermissionOnboarding() + requestOnboardingPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + }, + requestPermission: { kind in + voiceRelay.suppressPermissionOnboarding() + requestSingleOnboardingPermission( + kind, + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + }, + recheckPermissions: { + voiceRelay.suppressPermissionOnboarding() + bleManager.refreshBluetoothAuthorization() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + }, + openSystemSettings: { + voiceRelay.suppressPermissionOnboarding() + openFirstMissingOnboardingPermissionSettings( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + }, + toggleTryExperience: { + voiceRelay.suppressPermissionOnboarding() + nativeSpeech.toggleRecordingFromVoiceKey() + } + ) + } +} + +@MainActor +private func requestSingleOnboardingPermission( + _ kind: AhaKeyOnboardingPermissionKind, + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService +) { + voiceRelay.suppressPermissionOnboarding(for: 60) + + switch kind { + case .bluetooth: + bleManager.ensureCentralManager() + bleManager.refreshBluetoothAuthorization() + bleManager.userInitiatedConnect() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + bleManager.refreshBluetoothAuthorization() + if bleManager.bluetoothAuthorizationDeniedOrRestricted || !bleManager.bluetoothPoweredOn { + openOnboardingPermissionSettings(.bluetooth) + } + } + + case .inputMonitoring: + voiceRelay.requestInputMonitoringPermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + if !voiceRelay.inputMonitoringGranted { + openOnboardingPermissionSettings(.inputMonitoring) + } + } + + case .accessibility: + voiceRelay.requestAccessibilityPermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + if !voiceRelay.accessibilityGranted { + openOnboardingPermissionSettings(.accessibility) + } + } + + case .microphone: + nativeSpeech.requestMicrophonePermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + if !nativeSpeech.microphoneGranted { + openOnboardingPermissionSettings(.microphone) + } + } + + case .speechRecognition: + nativeSpeech.requestSpeechRecognitionPermission() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + if !nativeSpeech.speechRecognitionGranted { + openOnboardingPermissionSettings(.speechRecognition) + } + } + + case .siri, .dictation: + openOnboardingPermissionSettings(kind) + } +} + +@MainActor +private func requestOnboardingPermissionsThenOpenPrivacySettingsIfNeeded( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService, + delay: TimeInterval = 0.45 +) { + bleManager.refreshBluetoothAuthorization() + voiceRelay.suppressPermissionOnboarding() + voiceRelay.refreshPermissions(requestIfNeeded: true) + nativeSpeech.refreshPermissions(requestIfNeeded: true) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + voiceRelay.suppressPermissionOnboarding() + bleManager.refreshBluetoothAuthorization() + openFirstMissingOnboardingPermissionSettings( + bleManager: bleManager, + voiceRelay: voiceRelay, + nativeSpeech: nativeSpeech + ) + } +} + +@MainActor +private func openFirstMissingOnboardingPermissionSettings( + bleManager: AhaKeyBLEManager, + voiceRelay: VoiceRelayService, + nativeSpeech: NativeSpeechTranscriptionService +) { + if !bleManager.bluetoothPermissionGranted || !bleManager.bluetoothPoweredOn { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth"]) { return } + } + if !voiceRelay.inputMonitoringGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) { return } + } + if !voiceRelay.accessibilityGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) { return } + } + if !nativeSpeech.microphoneGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"]) { return } + } + if !nativeSpeech.speechRecognitionGranted { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition"]) { return } + } + if !nativeSpeech.siriEnabled { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.Siri-Settings.extension"]) { return } + } + if !nativeSpeech.dictationEnabled { + if openFirstAvailableOnboardingSystemSettingsURL(["x-apple.systempreferences:com.apple.Keyboard-Settings.extension"]) { return } + } + openCombinedOnboardingPrivacySettingsURL() +} + +@MainActor +private func openOnboardingPermissionSettings(_ kind: AhaKeyOnboardingPermissionKind) { + let candidates: [String] + switch kind { + case .bluetooth: + candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", + "x-apple.systempreferences:com.apple.BluetoothSettings", + ] + case .inputMonitoring: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"] + case .accessibility: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"] + case .microphone: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"] + case .speechRecognition: + candidates = ["x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition"] + case .siri: + candidates = ["x-apple.systempreferences:com.apple.Siri-Settings.extension"] + case .dictation: + candidates = ["x-apple.systempreferences:com.apple.Keyboard-Settings.extension"] + } + if openFirstAvailableOnboardingSystemSettingsURL(candidates) { return } + openCombinedOnboardingPrivacySettingsURL() +} + +@MainActor +private func openCombinedOnboardingPrivacySettingsURL() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth", + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition", + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:com.apple.Keyboard-Settings.extension", + "x-apple.systempreferences:com.apple.preference.security?Privacy", + ] + if openFirstAvailableOnboardingSystemSettingsURL(candidates) { return } + let appPaths = [ + "/System/Applications/System Settings.app", + "/System/Library/CoreServices/Applications/System Settings.app", + "/System/Applications/System Preferences.app", + ] + for path in appPaths where FileManager.default.fileExists(atPath: path) { + if NSWorkspace.shared.open(URL(fileURLWithPath: path)) { + return + } + } +} + +@discardableResult +private func openFirstAvailableOnboardingSystemSettingsURL(_ candidates: [String]) -> Bool { + for candidate in candidates { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return true + } + } + return false +} diff --git a/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift b/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift new file mode 100644 index 00000000..930314d5 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift @@ -0,0 +1,558 @@ +import SwiftUI + +struct DeviceInfoView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @StateObject private var agentManager = AgentManager.shared + @State private var isEditingName = false + @State private var editableName = "" + @State private var showAgentLog = false + @State private var agentLogPanel = 0 + @State private var logPanelContentTick = 0 + @State private var showAgentRequiredForAgentBLE = false + + var body: some View { + Form { + // MARK: - 设备信息 + Section { + HStack(spacing: 0) { + infoCell("电量", value: "\(bleManager.batteryLevel)%") + Divider() + infoCell("固件", value: "v\(bleManager.firmwareMainVersion).\(bleManager.firmwareSubVersion)") + Divider() + infoCell("设备名", value: bleManager.deviceName ?? "—") + } + .frame(height: 50) + + HStack(spacing: 0) { + infoCell("工作模式", value: workModeName(bleManager.workMode)) + Divider() + infoCell("灯光", value: lightModeName(bleManager.lightMode)) + Divider() + infoCell("信号", value: "\(bleManager.signalStrength) dBm") + } + .frame(height: 50) + } header: { + Text("设备信息") + } + + // MARK: - 蓝牙连接(App 与 Agent 二选一) + Section { + VStack(alignment: .leading, spacing: 12) { + Text("同一时间只能由本 App 或 Agent 其中之一连接键盘,请在此切换。") + .font(.callout) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + ForEach(BluetoothConnectionOwner.allCases) { owner in + let selected = agentManager.bluetoothConnectionOwner == owner + let disableAgent = owner == .agentDaemon && !agentManager.isInstalled + Button { + if owner == .agentDaemon && !agentManager.isInstalled { + showAgentRequiredForAgentBLE = true + } else { + agentManager.setBluetoothConnectionOwner(owner, bleManager: bleManager) + } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(owner.title) + .fontWeight(selected ? .semibold : .regular) + Text(owner == .ahaKeyStudio + ? "改键、LCD、同步、本机灯效测试(macOS 暂不支持 USB 有线配置)" + : "Claude/Cursor/Codex/Kimi Hook、灯条状态、拨杆查询") + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selected ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.04)) + ) + } + .buttonStyle(.plain) + .disabled(disableAgent) + } + } + CompatLabeledContent("当前") { + HStack(spacing: 6) { + Text(bleManager.isConnected ? "本 App 已连接蓝牙" : "本 App 未连接") + Text("·") + .foregroundStyle(.tertiary) + Text(agentBluetoothStatusText()) + } + .font(.callout) + } + } + } header: { + Text("蓝牙连接") + } + .alert("需要先安装 Agent", isPresented: $showAgentRequiredForAgentBLE) { + Button("好", role: .cancel) {} + } message: { + Text("将蓝牙交给 `ahakeyconfig-agent` 前,请先在下方完成「安装并启用」,生成 LaunchAgent。") + } + + // MARK: - 拨杆状态 + Section { + HStack { + Text("拨杆档位") + Spacer() + HStack(spacing: 6) { + Circle() + .fill(bleManager.switchState == 0 ? Color.green : Color.indigo) + .frame(width: 10, height: 10) + .animation(.easeInOut(duration: 0.1), value: bleManager.switchState) + Text(switchStateLabel(bleManager.switchState)) + } + } + } header: { + Text("拨杆档位") + } + + // MARK: - LED 状态同步 + Section { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Circle() + .fill(agentManager.isRunning ? Color.green : Color.gray.opacity(0.4)) + .frame(width: 8, height: 8) + Text("LED 跟随 IDE 状态") + Text(agentBluetoothShortLabel()) + .foregroundStyle(.secondary) + } + HStack(spacing: 10) { + hookBadge("Claude", installed: agentManager.claudeHooksInstalled) + hookBadge("Cursor", installed: agentManager.cursorHooksInstalled) + hookBadge("Codex", installed: agentManager.codexHooksInstalled) + hookBadge("Kimi", installed: agentManager.kimiHooksInstalled) + } + .font(.caption) + } + Spacer() + if agentManager.isInstalled { + Button(agentManager.isRunning ? "停止" : "启动") { + if agentManager.isRunning { + agentManager.stop() + } else { + agentManager.start() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(agentManager.bluetoothConnectionOwner == .ahaKeyStudio) + .help(agentManager.bluetoothConnectionOwner == .ahaKeyStudio + ? "当前由本 App 占用蓝牙,Agent 应处于未加载。请先在「蓝牙连接」中选中 Agent 后再启停守护进程。" + : "从 launchd 加载并启动/卸载停止 Agent 进程。") + + Button("卸载", role: .destructive) { + agentManager.uninstall(bleManager: bleManager) + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + HStack(spacing: 8) { + if agentManager.isAgentOperationInProgress { + ProgressView() + .controlSize(.small) + } + Button("安装并启用") { + agentManager.install() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(agentManager.isAgentOperationInProgress) + } + } + } + + if agentManager.isInstalled { + HStack(spacing: 10) { + Button("查看日志") { + showAgentLog.toggle() + } + .buttonStyle(.borderless) + .font(.caption) + + Spacer() + + if agentManager.claudeHooksInstalled { + Button("移除 Claude Hooks") { agentManager.removeClaudeHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } else { + Button("安装 Claude Hooks") { agentManager.installClaudeHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } + if agentManager.cursorHooksInstalled { + Button("移除 Cursor Hooks") { agentManager.removeCursorHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } else { + Button("安装 Cursor Hooks") { agentManager.installCursorHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } + if agentManager.codexHooksInstalled { + Button("移除 Codex Hooks") { agentManager.removeCodexHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } else { + Button("安装 Codex Hooks") { agentManager.installCodexHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } + if agentManager.kimiHooksInstalled { + Button("移除 Kimi Hooks") { agentManager.removeKimiHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } else { + Button("安装 Kimi Hooks") { agentManager.installKimiHooksOnly() } + .buttonStyle(.borderless) + .font(.caption) + } + } + } + } header: { + Text("LED 状态同步 · Hook 联动") + } footer: { + if !agentManager.isAgentBinaryPresentInBundle { + Text("发版包内未包含 ahakeyconfig-agent,无法使用守护进程。请用完整「AhaKey Studio.app」或联系开发者。") + .foregroundStyle(.orange) + } else if agentManager.isInstalled, agentManager.bluetoothConnectionOwner == .ahaKeyStudio, !agentManager.isRunning { + Text("已由本 App 占用蓝牙:要让 Agent 接管,请将「蓝牙连接」选为 ahakeyconfig-agent。") + .foregroundStyle(.secondary) + } + } + .sheet(isPresented: $showAgentLog) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("诊断日志") + .font(.headline) + Spacer() + Button("关闭") { showAgentLog = false } + } + Picker("内容", selection: $agentLogPanel) { + Text("ahakeyconfig-agent 主日志").tag(0) + Text("工具批准(permission-request.log)").tag(1) + Text("~/.cursor/hooks.json").tag(2) + Text("~/.cursor/cli-config.json").tag(3) + Text("~/.codex/config.toml").tag(4) + Text("Codex Hook(codex-hook.log)").tag(5) + Text("~/.kimi/config.toml").tag(6) + } + .labelsHidden() + .pickerStyle(.menu) + HStack { + Button("刷新本页") { + logPanelContentTick += 1 + agentManager.refresh() + } + if agentLogPanel == 3 { + Button("合并 CLI + IDE 终端白名单") { + let a = agentManager.mergeUserCursorCliConfigForShellAutoApprove() + let b = agentManager.mergeUserCursorPermissionsJsonForAgentTUI() + agentManager.agentUserAlert = a + "\n\n——\n\n" + b + } + .help("写 cli-config(CLI)与 permissions.json 的 terminalAllowlist(Agent TUI「Not in allowlist」层);分见官方文档。均先备份为 .ahakey.bak。") + } + Spacer() + } + .font(.caption) + ScrollView { + logPanelContent + .id(logPanelContentTick) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding() + .frame(width: 540, height: 380) + } + + // MARK: - LED 测试 + if bleManager.isConnected { + Section { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 8) { + ForEach(Array(IDEState.allCases.enumerated()), id: \.offset) { _, state in + Button { + bleManager.updateIDEState(state) + } label: { + Text(state.label) + .font(.caption) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + } + } + } header: { + Text("LED 测试") + } footer: { + Text("点击按钮发送对应状态到键盘,观察 LED 变化。") + } + } + + // MARK: - BLE 连接状态 + Section { + CompatLabeledContent("连接") { + HStack(spacing: 6) { + Circle() + .fill(bleManager.isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(bleManager.bleConnectionStatus) + } + } + CompatLabeledContent("设备名") { + if isEditingName { + HStack(spacing: 4) { + TextField("最长 15 字节", text: $editableName) + .textFieldStyle(.roundedBorder) + .frame(width: 160) + .onSubmit { submitNameChange() } + Button("保存") { submitNameChange() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + Button("取消") { isEditingName = false } + .buttonStyle(.bordered) + .controlSize(.small) + } + } else { + HStack(spacing: 6) { + Text(bleManager.deviceName ?? "—") + .textSelection(.enabled) + if bleManager.isConnected { + Button { + editableName = bleManager.deviceName ?? "" + isEditingName = true + } label: { + Image(systemName: "pencil") + .font(.caption) + } + .buttonStyle(.borderless) + } + } + } + } + CompatLabeledContent("UUID") { + Text(bleManager.bleDeviceUUID) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + HStack { + CompatLabeledContent("特征") { + HStack(spacing: 8) { + charBadge("DATA", ready: bleManager.dataCharReady) + charBadge("CMD", ready: bleManager.commandCharReady) + charBadge("NOTIFY", ready: bleManager.notifyCharReady) + } + } + } + } header: { + Text("BLE 连接状态") + } + + // MARK: - 操作 + Section { + HStack { + if !bleManager.isConnected { + Button(bleManager.isScanning ? "扫描中…" : "连接设备") { + bleManager.userInitiatedConnect() + } + .buttonStyle(.bordered) + .disabled(bleManager.isScanning || agentManager.bluetoothConnectionOwner == .agentDaemon) + .help(agentManager.bluetoothConnectionOwner == .agentDaemon + ? "当前选择由 ahakeyconfig-agent 占用蓝牙。请先在上方「蓝牙连接」切到 AhaKey Studio,或点击顶栏「设备信息 · Agent」切换。" + : "本 App 主动连接键盘。") + } else { + Button("查询状态") { + bleManager.queryDeviceStatus() + } + .buttonStyle(.bordered) + .help("发送 AA BB 00 CC DD 查询设备状态") + + Button("探测协议") { + bleManager.sendProbeCommands() + } + .buttonStyle(.bordered) + .help("向设备发送探测命令,观察通信日志中的回调") + + Spacer() + + Button("断开", role: .destructive) { + bleManager.disconnect() + } + .buttonStyle(.bordered) + } + } + } + + // MARK: - 通信日志 + Section { + VStack(alignment: .leading, spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(bleManager.commLog) { entry in + HStack(alignment: .top, spacing: 8) { + Text(entry.formattedTime) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(width: 80, alignment: .leading) + Text(entry.message) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(entry.isError ? .red : .secondary) + .textSelection(.enabled) + } + .id(entry.id) + } + } + .padding(8) + } + .frame(height: 150) + .background(Color.primary.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .onChange(of: bleManager.commLog.count) { _ in + if let last = bleManager.commLog.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + + HStack { + Spacer() + Button("复制全部") { + let text = bleManager.commLog.map { "[\($0.formattedTime)] \($0.message)" }.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .buttonStyle(.borderless) + .font(.caption) + Button("清空") { + bleManager.clearLog() + } + .buttonStyle(.borderless) + .font(.caption) + } + .padding(.top, 4) + } + } header: { + Text("通信日志") + } + } + // 「设备信息」在 sheet 中展示时,父视图的 `.alert` 往往不会置顶显示,导致 Hooks 安装/报错像「无反应」。在此重复绑定以确保可见。 + .alert("Agent", isPresented: Binding( + get: { agentManager.agentUserAlert != nil }, + set: { if !$0 { agentManager.agentUserAlert = nil } } + )) { + Button("好", role: .cancel) { + agentManager.agentUserAlert = nil + } + } message: { + Text(agentManager.agentUserAlert ?? "") + } + + } + + // MARK: - Components + + private func infoCell(_ label: String, value: String) -> some View { + VStack(spacing: 4) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.body.monospacedDigit()) + } + .frame(maxWidth: .infinity) + } + + private func hookBadge(_ label: String, installed: Bool) -> some View { + HStack(spacing: 4) { + Image(systemName: installed ? "checkmark.circle.fill" : "circle") + .foregroundStyle(installed ? .green : .secondary) + Text("\(label) Hooks") + .foregroundStyle(installed ? .primary : .secondary) + } + } + + private func charBadge(_ label: String, ready: Bool) -> some View { + Text(label) + .font(.system(.caption2, design: .monospaced)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(ready ? Color.green.opacity(0.15) : Color.gray.opacity(0.1)) + ) + .foregroundStyle(ready ? Color.green : Color.secondary) + } + + private func switchStateLabel(_ state: Int) -> String { + state == 0 ? "自动批准" : "手动批准" + } + + private func agentBluetoothStatusText() -> String { + if agentManager.isRunning && agentManager.isAgentBLEConnected { return "Agent 已连接蓝牙" } + if agentManager.isRunning { return "Agent 运行中(BLE 未连接)" } + if agentManager.isInstalled { return "Agent 未运行" } + return "Agent 未安装" + } + + private func agentBluetoothShortLabel() -> String { + if agentManager.isRunning && agentManager.isAgentBLEConnected { return "已连蓝牙" } + if agentManager.isRunning { return "BLE 未连接" } + if agentManager.isInstalled { return "未运行" } + return "未装 Agent" + } + + private func workModeName(_ mode: Int) -> String { + switch mode { + case 0: return "Mode 1 / Claude" + case 1: return "Mode 2 / Cursor" + case 2: return "Mode 3 / Codex" + case 3: return "Mode 4 / custom" + default: return "Mode \(mode)" + } + } + + private func lightModeName(_ mode: Int) -> String { + switch mode { + case 0: return "关闭" + case 1: return "常亮" + case 2: return "呼吸" + default: return "\(mode)" + } + } + + @ViewBuilder + private var logPanelContent: some View { + switch agentLogPanel { + case 0: + Text(agentManager.readLog()) + case 1: + Text(agentManager.readPermissionRequestLog()) + case 2: + Text(agentManager.readUserCursorHooksJsonForDisplay()) + case 3: + Text(agentManager.readUserCursorCliConfigForDisplay()) + case 4: + Text(agentManager.readUserCodexConfigForDisplay()) + case 5: + Text(agentManager.readCodexHookLog()) + case 6: + Text(agentManager.readUserKimiConfigForDisplay()) + default: + Text("") + } + } + + private func submitNameChange() { + let name = editableName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + bleManager.changeDeviceName(name) + isEditingName = false + } +} diff --git a/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift b/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift new file mode 100644 index 00000000..92ad9c8b --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift @@ -0,0 +1,193 @@ +import SwiftUI + +/// 单个键的映射配置 +struct KeyConfig: Codable { + var hidCode: UInt8 = 0 + var description: String = "" + + var displayName: String { + hidCode == 0 ? "未设置" : HIDUsage.name(for: hidCode) + } +} + +/// 键位配置持久化 +enum KeyConfigStore { + private static let key = "keyMappingConfig" + + static func save(_ keys: [KeyConfig]) { + if let data = try? JSONEncoder().encode(keys) { + UserDefaults.standard.set(data, forKey: key) + } + } + + static func load() -> [KeyConfig]? { + guard let data = UserDefaults.standard.data(forKey: key), + let configs = try? JSONDecoder().decode([KeyConfig].self, from: data), + configs.count == 4 else { return nil } + return configs + } +} + +struct KeyMappingView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + + @State private var selectedKey = 0 + @State private var keys: [KeyConfig] = KeyConfigStore.load() ?? [ + KeyConfig(hidCode: HIDUsage.capsLock, description: "录音"), + KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), + KeyConfig(hidCode: HIDUsage.escape, description: "取消"), + KeyConfig(hidCode: HIDUsage.backspace, description: "Backspace"), + ] + @State private var showWriteSuccess = false + + private let keyLabels = ["Key 1\n🎤", "Key 2\n✓", "Key 3\n✗", "Key 4\n⌫"] + + var body: some View { + Form { + // MARK: - 按键选择 + Section("按键映射") { + HStack(spacing: 12) { + ForEach(0..<4) { index in + Button { + selectedKey = index + } label: { + VStack(spacing: 4) { + Text(keyLabels[index]) + .font(.system(.body, design: .rounded)) + .multilineTextAlignment(.center) + Text(keys[index].displayName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selectedKey == index + ? Color.accentColor.opacity(0.15) + : Color.primary.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(selectedKey == index + ? Color.accentColor + : Color.primary.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - 编辑选中键 + Section("Key \(selectedKey + 1) 设置") { + Picker("键码", selection: $keys[selectedKey].hidCode) { + Text("未设置").tag(UInt8(0)) + ForEach(HIDUsage.allOptions, id: \.code) { option in + Text("\(option.name) (\(String(format: "0x%02X", option.code)))") + .tag(option.code) + } + } + + CompatLabeledContent("描述") { + TextField("显示在键盘 LCD 上", text: $keys[selectedKey].description) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + } + + // MARK: - 预设方案 + Section { + HStack { + Button("EchoWrite 推荐") { + applyEchoWritePreset() + } + .buttonStyle(.bordered) + .help("Key1=F18(EchoWrite) Key2=Enter Key3=Escape Key4=Enter") + + Button("恢复默认") { + applyDefaultPreset() + } + .buttonStyle(.bordered) + .help("恢复出厂默认键位") + } + } header: { + Text("预设方案") + } footer: { + Text("EchoWrite 推荐:Key1 发送 F18 触发随声写录音,Key2/4 确认,Key3 取消。") + .font(.caption) + } + + // MARK: - 写入设备 + if bleManager.isConnected { + Section { + HStack { + Button("应用全部键位到设备") { + writeAllKeys() + } + .buttonStyle(.borderedProminent) + + if showWriteSuccess { + Label("已发送", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + } + } + } + } else { + Section { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + Text("请先连接 AhaKey 设备") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + + } + + // MARK: - Actions + + private func applyEchoWritePreset() { + keys = [ + KeyConfig(hidCode: HIDUsage.f18, description: "EchoWrite"), + KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), + KeyConfig(hidCode: HIDUsage.escape, description: "Cancel"), + KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), + ] + KeyConfigStore.save(keys) + } + + private func applyDefaultPreset() { + keys = [ + KeyConfig(hidCode: HIDUsage.capsLock, description: "CapsLock"), + KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), + KeyConfig(hidCode: HIDUsage.escape, description: "Escape"), + KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), + ] + KeyConfigStore.save(keys) + } + + private func writeAllKeys() { + for (index, key) in keys.enumerated() { + guard key.hidCode != 0 else { continue } + let keyIndex = UInt8(index) + bleManager.setKeyMapping(keyIndex: keyIndex, hidCodes: [key.hidCode]) + if !key.description.isEmpty { + bleManager.setKeyDescription(keyIndex: keyIndex, text: key.description) + } + } + // 写入完毕后保存到 Flash + 本地持久化 + bleManager.saveConfig() + KeyConfigStore.save(keys) + showWriteSuccess = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Double(3) * 1_000_000_000)) + showWriteSuccess = false + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift b/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift new file mode 100644 index 00000000..85a4096d --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct OLEDManagerView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + + @State private var selectedImage: NSImage? + @State private var selectedGIFURL: URL? + @State private var fps: Int = 30 + @State private var frameCount: Int = 0 + + var body: some View { + Form { + Section("动画管理") { + // 预览区 + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.05)) + .frame(height: 160) + + if let image = selectedImage { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 140) + } else { + VStack(spacing: 8) { + Image(systemName: "photo") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text("无图片") + .foregroundStyle(.tertiary) + } + } + } + + HStack(spacing: 12) { + Button("添加图片") { + selectImage() + } + .buttonStyle(.bordered) + + Button("添加 GIF") { + selectGIF() + } + .buttonStyle(.bordered) + + Spacer() + + Button("清空") { + selectedImage = nil + selectedGIFURL = nil + frameCount = 0 + } + .buttonStyle(.bordered) + .tint(.red) + } + + if frameCount > 0 { + HStack { + Text("FPS:") + Stepper("\(fps)", value: $fps, in: 1...30) + .frame(width: 100) + Spacer() + Text("\(frameCount) 帧") + .foregroundStyle(.secondary) + } + } + } + + if bleManager.isConnected { + Section { + Button("上传到设备") { + // TODO: 通过 BLE 0x7343 分包上传图片/GIF 数据 + // OLED 分辨率和图片格式待逆向确认 + } + .buttonStyle(.borderedProminent) + .disabled(selectedImage == nil && selectedGIFURL == nil) + } + } else { + Section { + Text("请先连接 AhaKey 设备") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + + } + + private func selectImage() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .bmp] + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + selectedImage = NSImage(contentsOf: url) + selectedGIFURL = nil + frameCount = 0 + } + } + + private func selectGIF() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [UTType(filenameExtension: "gif")!] + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + do { + try OLEDFrameEncoder.validateGIFSourceFileSize(at: url) + } catch { + NSSound.beep() + return + } + selectedGIFURL = url + selectedImage = NSImage(contentsOf: url) + // GIF 帧数估算 + if let source = CGImageSourceCreateWithURL(url as CFURL, nil) { + frameCount = CGImageSourceGetCount(source) + } + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift b/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift new file mode 100644 index 00000000..619be4c0 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift @@ -0,0 +1,756 @@ +import SwiftUI + +enum UnifiedOnboardingStorage { + static let completedKey = "AhaKey.UnifiedOnboarding.v2.completed" + static let micGrantedKey = "AhaKey.UnifiedOnboarding.v2.micPreGranted" + static let pasteGrantedKey = "AhaKey.UnifiedOnboarding.v2.pastePreGranted" + static let currentStepKey = "AhaKey.UnifiedOnboarding.v2.currentStep" +} + +struct AhaKeyOnboardingPermissionState: Equatable { + var bluetoothPermissionGranted: Bool + var bluetoothPoweredOn: Bool + var inputMonitoringGranted: Bool + var accessibilityGranted: Bool + var microphoneGranted: Bool + var speechRecognitionGranted: Bool + var siriEnabled: Bool + var dictationEnabled: Bool + var voiceSummary: String + var speechSummary: String + var isRecording: Bool + var transcriptPreview: String + var lastCommittedText: String + var speechStatusMessage: String + + var bluetoothReady: Bool { + bluetoothPermissionGranted && bluetoothPoweredOn + } + + var backgroundPermissionsGranted: Bool { + inputMonitoringGranted && accessibilityGranted + } + + var nativeSpeechPermissionsGranted: Bool { + microphoneGranted && speechRecognitionGranted && siriEnabled && dictationEnabled + } + + var allPermissionsGranted: Bool { + bluetoothReady && backgroundPermissionsGranted && nativeSpeechPermissionsGranted + } + + var canTrySpeechInput: Bool { + microphoneGranted && speechRecognitionGranted + } +} + +struct AhaKeyOnboardingActions { + var requestPermissions: () -> Void + var requestPermission: (AhaKeyOnboardingPermissionKind) -> Void + var recheckPermissions: () -> Void + var openSystemSettings: () -> Void + var toggleTryExperience: () -> Void +} + +enum AhaKeyOnboardingPermissionKind { + case bluetooth + case inputMonitoring + case accessibility + case microphone + case speechRecognition + case siri + case dictation +} + +struct UnifiedAhaKeyOnboardingView: View { + var permissionState: AhaKeyOnboardingPermissionState + var actions: AhaKeyOnboardingActions + var onCompleted: (_ micGranted: Bool, _ pasteGranted: Bool) -> Void + + @State private var step: AhaKeyOnboardingStep = .restoredProgress + @State private var didRunTryExperience = false + @State private var tryInputFieldText = "" + @FocusState private var tryInputFieldFocused: Bool + + var body: some View { + GeometryReader { geometry in + let compact = geometry.size.width < 980 + let contentMinHeight = max(420, geometry.size.height - 116) + VStack(spacing: 0) { + topBar + Divider().opacity(0.45) + if compact { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + mainPanel + guidePanel + } + .padding(24) + .padding(.bottom, 12) + } + } else { + ScrollView { + HStack(alignment: .top, spacing: 0) { + mainPanel + .frame(width: max(500, geometry.size.width * 0.48), alignment: .topLeading) + .padding(.horizontal, 48) + .padding(.vertical, 34) + .background(Color(nsColor: .textBackgroundColor)) + + Divider().opacity(0.45) + + guidePanel + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, 46) + .padding(.vertical, 34) + .background(Color(nsColor: .windowBackgroundColor)) + } + .frame(maxWidth: .infinity, minHeight: contentMinHeight, alignment: .topLeading) + } + } + Divider().opacity(0.45) + bottomNavigationBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .onAppear { + resumeProgressIfReady() + } + .onChange(of: step) { newValue in + UserDefaults.standard.set(newValue.rawValue, forKey: UnifiedOnboardingStorage.currentStepKey) + } + .onChange(of: permissionState) { _ in + resumeProgressIfReady() + } + .onChange(of: permissionState.transcriptPreview) { newValue in + if !newValue.isEmpty { + didRunTryExperience = true + } + } + .onChange(of: permissionState.lastCommittedText) { newValue in + if !newValue.isEmpty { + didRunTryExperience = true + } + } + } + + // MARK: - Top Bar + + private var topBar: some View { + HStack(spacing: 18) { + Spacer(minLength: 0) + stepper + Spacer(minLength: 0) + Button("跳过") { + finish() + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .padding(.trailing, 22) + } + .padding(.vertical, 12) + .background(Color(nsColor: .textBackgroundColor)) + } + + // 顶部步骤导航,所有步骤均可点击跳转 + private var stepper: some View { + HStack(spacing: 12) { + ForEach(AhaKeyOnboardingStep.allCases) { item in + HStack(spacing: 12) { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + moveToStep(item) + } + } label: { + Text(item.title) + .font(.system(size: 15, weight: step == item ? .semibold : .medium)) + .foregroundStyle(step == item ? Color.primary : Color.secondary) + .frame(width: 78, height: 34) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(step == item ? Color.primary : Color.clear) + .frame(height: 2) + } + } + .buttonStyle(.plain) + + if item != AhaKeyOnboardingStep.allCases.last { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + } + } + } + } + + // MARK: - Main Panel + + @ViewBuilder + private var mainPanel: some View { + VStack(alignment: .leading, spacing: 0) { + switch step { + case .welcome: + welcomePanel + case .dialogPermissions: + dialogPermissionsPanel + case .settingsPermissions: + settingsPermissionsPanel + case .tryInput: + tryInputPanel + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var welcomePanel: some View { + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 12) { + Text("在这台 Mac 上设置 AhaKey") + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(.primary) + Text("完成键盘连接、后台语音键接管、macOS 原生语音和一次真实输入体验。") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 14) { + onboardingCard(systemImage: "keyboard", title: "连接与控制", detail: "开启蓝牙后,AhaKey Studio 会接管出厂语音键并同步当前 Mode。") + onboardingCard(systemImage: "lock.shield", title: "分步授权", detail: "先完成蓝牙、麦克风、语音转写等弹窗授权,再依次开启 Siri、听写、辅助功能,最后处理输入监控并重启。") + onboardingCard(systemImage: "mic", title: "体验输入", detail: "最后可以直接口述一句话,确认识别和写入链路都已准备好。") + } + } + } + + private var dialogPermissionsPanel: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader( + title: "第一步:弹窗确认授权", + detail: "以下权限点击「申请」后会弹出系统对话框,直接点允许即可。" + ) + + VStack(spacing: 12) { + PermissionStatusRow( + title: "蓝牙", + detail: bluetoothDetail, + granted: permissionState.bluetoothReady, + actionTitle: permissionState.bluetoothReady ? nil : "申请", + action: { actions.requestPermission(.bluetooth) } + ) + PermissionStatusRow( + title: "麦克风", + detail: "允许 AhaKey Studio 使用苹果原生语音采集。", + granted: permissionState.microphoneGranted, + actionTitle: permissionState.microphoneGranted ? nil : "申请", + action: { actions.requestPermission(.microphone) } + ) + PermissionStatusRow( + title: "语音转写", + detail: "允许 AhaKey Studio 使用苹果原生语音识别。", + granted: permissionState.speechRecognitionGranted, + actionTitle: permissionState.speechRecognitionGranted ? nil : "申请", + action: { actions.requestPermission(.speechRecognition) } + ) + } + + HStack(spacing: 10) { + Button("重新检查") { + actions.recheckPermissions() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + } + + } + } + + private var settingsPermissionsPanel: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader( + title: "第二步:系统设置授权", + detail: "以下权限需要前往系统设置手动开启,点击「打开设置」后在系统设置中操作。" + ) + + VStack(spacing: 12) { + PermissionStatusRow( + title: "Siri", + detail: "在系统设置 > Siri 与聚焦里开启 Siri。", + granted: permissionState.siriEnabled, + actionTitle: permissionState.siriEnabled ? nil : "打开设置", + action: { actions.requestPermission(.siri) } + ) + PermissionStatusRow( + title: "听写", + detail: "在系统设置 > 键盘 > 听写里开启听写。", + granted: permissionState.dictationEnabled, + actionTitle: permissionState.dictationEnabled ? nil : "打开设置", + action: { actions.requestPermission(.dictation) } + ) + PermissionStatusRow( + title: "辅助功能", + detail: "允许 AhaKey Studio 把语音键转换成 macOS 原生转写或 Fn/Globe。", + granted: permissionState.accessibilityGranted, + actionTitle: permissionState.accessibilityGranted ? nil : "打开设置", + action: { actions.requestPermission(.accessibility) } + ) + PermissionStatusRow( + title: "输入监控", + detail: "允许 AhaKey Studio 在后台监听实体语音键;设置完成后通常需要退出并重新打开。", + granted: permissionState.inputMonitoringGranted, + actionTitle: permissionState.inputMonitoringGranted ? nil : "打开设置", + action: { + UserDefaults.standard.set(AhaKeyOnboardingStep.tryInput.rawValue, forKey: UnifiedOnboardingStorage.currentStepKey) + actions.requestPermission(.inputMonitoring) + } + ) + } + + HStack(spacing: 10) { + Button("重新检查") { + actions.recheckPermissions() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + } + + } + } + + private var tryInputPanel: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader( + title: "第三步:体验输入", + detail: "请蓝牙连接小键盘后,将光标放在这里,按下麦克风键开始说话。" + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Circle() + .fill(permissionState.isRecording ? Color.red : (permissionState.canTrySpeechInput ? Color.green : Color.orange)) + .frame(width: 10, height: 10) + Text(permissionState.isRecording ? "录音中" : (permissionState.canTrySpeechInput ? "语音已准备" : "仍缺语音权限")) + .font(.system(size: 15, weight: .semibold)) + } + + ZStack(alignment: .topLeading) { + if tryInputFieldText.isEmpty { + Text("请蓝牙连接小键盘后,将光标放在这里,按下麦克风键开始说话") + .font(.system(size: 16)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + TextEditor(text: $tryInputFieldText) + .font(.system(size: 18, weight: .medium)) + .focused($tryInputFieldFocused) + .modifier(HideScrollContentBackgroundModifier()) + } + .frame(maxWidth: .infinity, minHeight: 110, alignment: .topLeading) + .padding(14) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .onAppear { tryInputFieldFocused = true } + + Text(permissionState.speechStatusMessage) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 10) { + Button(permissionState.isRecording ? "结束并写入" : "开始试说") { + didRunTryExperience = true + actions.toggleTryExperience() + } + .buttonStyle(OnboardingPrimaryButtonStyle()) + .disabled(!permissionState.canTrySpeechInput) + + Button("重新检查") { + actions.recheckPermissions() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + } + + } + } + + // MARK: - Guide Panel(右侧,分组高亮) + + private var guidePanel: some View { + VStack(alignment: .leading, spacing: 22) { + Text(step.guideTitle) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.primary) + Text(step.guideDetail) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 10) { + PermissionGroupSection( + groupLabel: "弹窗授权", + isHighlighted: step == .dialogPermissions, + items: [ + ("蓝牙", permissionState.bluetoothReady), + ("麦克风", permissionState.microphoneGranted), + ("语音转写", permissionState.speechRecognitionGranted), + ] + ) + + PermissionGroupSection( + groupLabel: "系统设置授权", + isHighlighted: step == .settingsPermissions || step == .tryInput, + items: [ + ("Siri", permissionState.siriEnabled), + ("听写", permissionState.dictationEnabled), + ("辅助功能", permissionState.accessibilityGranted), + ("输入监控", permissionState.inputMonitoringGranted), + ] + ) + } + + Divider().opacity(0.45) + + VStack(alignment: .leading, spacing: 8) { + Text("当前状态") + .font(.system(size: 15, weight: .semibold)) + Text(permissionState.voiceSummary) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Text(permissionState.speechSummary) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + } + + // MARK: - Bottom Navigation(文字次级按钮) + + private var bottomNavigationBar: some View { + HStack(spacing: 0) { + Spacer() + + if step != .welcome { + Button("上一步") { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + moveToStep(step.previous) + } + } + .buttonStyle(OnboardingTextButtonStyle()) + } + + Button(bottomNextTitle) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + goForward() + } + } + .buttonStyle(OnboardingPrimaryButtonStyle()) + .padding(.leading, step == .welcome ? 0 : 8) + + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + .padding(.bottom, 20) + .background(Color(nsColor: .textBackgroundColor)) + } + + private var bottomNextTitle: String { + switch step { + case .welcome: return "开始设置" + case .tryInput: return "进入工作台" + default: return "下一步" + } + } + + // MARK: - Helpers + + private var bluetoothDetail: String { + if !permissionState.bluetoothPermissionGranted { + return "允许 AhaKey Studio 扫描并连接 AhaKey 键盘。" + } + if !permissionState.bluetoothPoweredOn { + return "已授权,但系统蓝牙当前关闭,请在控制中心或系统设置中打开。" + } + return "蓝牙可用,可以扫描并连接键盘。" + } + + private var manualSettingsPermissionsGranted: Bool { + permissionState.siriEnabled && + permissionState.dictationEnabled && + permissionState.accessibilityGranted && + permissionState.inputMonitoringGranted + } + + private var tryPreviewText: String { + if !permissionState.transcriptPreview.isEmpty { + return permissionState.transcriptPreview + } + if !permissionState.lastCommittedText.isEmpty { + return permissionState.lastCommittedText + } + return "这里会显示实时识别或最近写入的内容。" + } + + private func sectionHeader(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(.primary) + Text(detail) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func onboardingCard(systemImage: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 32, height: 32) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + Text(detail) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(16) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } + + private func moveToStep(_ next: AhaKeyOnboardingStep) { + step = next + UserDefaults.standard.set(next.rawValue, forKey: UnifiedOnboardingStorage.currentStepKey) + } + + private func resumeProgressIfReady() { + guard step == .settingsPermissions, manualSettingsPermissionsGranted else { return } + moveToStep(.tryInput) + } + + private func goForward() { + if step == .tryInput { + finish() + return + } + moveToStep(AhaKeyOnboardingStep(rawValue: min(AhaKeyOnboardingStep.tryInput.rawValue, step.rawValue + 1)) ?? .tryInput) + } + + private func finish() { + UserDefaults.standard.set(permissionState.microphoneGranted, forKey: UnifiedOnboardingStorage.micGrantedKey) + UserDefaults.standard.set(permissionState.backgroundPermissionsGranted, forKey: UnifiedOnboardingStorage.pasteGrantedKey) + UserDefaults.standard.removeObject(forKey: UnifiedOnboardingStorage.currentStepKey) + onCompleted(permissionState.microphoneGranted, permissionState.backgroundPermissionsGranted) + } +} + +// MARK: - Permission Group Section(右侧分组视窗) + +private struct PermissionGroupSection: View { + var groupLabel: String + var isHighlighted: Bool + var items: [(String, Bool)] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(groupLabel) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isHighlighted ? Color.accentColor : Color.secondary) + .textCase(.uppercase) + .padding(.horizontal, 4) + + VStack(alignment: .leading, spacing: 6) { + ForEach(items, id: \.0) { title, granted in + summaryRow(title: title, granted: granted) + } + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isHighlighted ? Color.accentColor.opacity(0.06) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isHighlighted ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1.5) + ) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isHighlighted) + } + + private func summaryRow(title: String, granted: Bool) -> some View { + HStack(spacing: 9) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 9, height: 9) + Text(title) + .font(.system(size: 14, weight: .medium)) + Spacer() + Text(granted ? "已开启" : "待开启") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(granted ? Color.green : Color.orange) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Permission Status Row + +private struct PermissionStatusRow: View { + var title: String + var detail: String + var granted: Bool + var actionTitle: String? = nil + var action: (() -> Void)? = nil + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 10, height: 10) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + Text(granted ? "已开启" : "待开启") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(granted ? Color.green : Color.orange) + } + Text(detail) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + if let actionTitle, let action { + if granted { + Button(actionTitle) { + action() + } + .buttonStyle(OnboardingSecondaryButtonStyle()) + .disabled(true) + .padding(.top, 1) + } else { + Button(actionTitle) { + action() + } + .buttonStyle(OnboardingPrimaryButtonStyle()) + .padding(.top, 1) + } + } + } + .padding(16) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Onboarding Steps + +private enum AhaKeyOnboardingStep: Int, CaseIterable, Identifiable { + static var restoredProgress: AhaKeyOnboardingStep { + let rawValue = UserDefaults.standard.integer(forKey: UnifiedOnboardingStorage.currentStepKey) + return AhaKeyOnboardingStep(rawValue: rawValue) ?? .welcome + } + + case welcome + case dialogPermissions + case settingsPermissions + case tryInput + + var id: Int { rawValue } + + var title: String { + switch self { + case .welcome: return "欢迎" + case .dialogPermissions: return "弹窗授权" + case .settingsPermissions: return "系统设置" + case .tryInput: return "开始体验" + } + } + + var previous: AhaKeyOnboardingStep { + AhaKeyOnboardingStep(rawValue: max(0, rawValue - 1)) ?? .welcome + } + + var guideTitle: String { + switch self { + case .welcome: return "设置路线" + case .dialogPermissions: return "先完成弹窗确认的权限" + case .settingsPermissions: return "再到系统设置中开启" + case .tryInput: return "最后试一次真实输入" + } + } + + var guideDetail: String { + switch self { + case .welcome: + return "引导分两步授权:先完成系统弹窗确认的权限,再前往系统设置开启其余权限,最后体验输入。" + case .dialogPermissions: + return "蓝牙、麦克风和语音转写可以直接弹窗确认,点击「申请」后在弹窗中允许即可。" + case .settingsPermissions: + return "请依次开启 Siri、听写、辅助功能,最后开启输入监控。输入监控设置后通常需要退出并重新打开,本引导会记住进度。" + case .tryInput: + return "这里使用软件内同一套语音链路测试,不再只是展示授权状态。" + } + } +} + +// MARK: - Button Styles + +private struct OnboardingPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .frame(height: 34) + .background(Color.accentColor.opacity(configuration.isPressed ? 0.82 : 1.0), in: RoundedRectangle(cornerRadius: 7)) + } +} + +private struct OnboardingSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .frame(height: 34) + .background(Color(nsColor: .controlBackgroundColor).opacity(configuration.isPressed ? 0.7 : 1.0), in: RoundedRectangle(cornerRadius: 7)) + } +} + +// 次级文字按钮(底部导航"上一步"使用) +private struct OnboardingTextButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(configuration.isPressed ? Color.secondary : Color.primary) + .padding(.horizontal, 16) + .frame(height: 34) + .contentShape(Rectangle()) + } +} + +private struct HideScrollContentBackgroundModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 13.0, *) { + content.scrollContentBackground(.hidden) + } else { + content + } + } +} diff --git a/ahakeyconfig-mac/ahakeyicon.png b/ahakeyconfig-mac/ahakeyicon.png new file mode 100644 index 00000000..e77b31c1 Binary files /dev/null and b/ahakeyconfig-mac/ahakeyicon.png differ diff --git a/ahakeyconfig-mac/scripts/ahakey-state.sh b/ahakeyconfig-mac/scripts/ahakey-state.sh new file mode 100755 index 00000000..82ba8465 --- /dev/null +++ b/ahakeyconfig-mac/scripts/ahakey-state.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# AhaKey LED 状态同步 hook 脚本 +# 用法: ahakey-state.sh +# 通过 Unix socket 通知 ahakeyconfig-agent 发送 LED 状态到键盘 +# +# Claude Hook 事件 → state 映射: +# Notification=0 PermissionRequest=1 PostToolUse=2 +# PreToolUse=3 SessionStart=4 Stop=5 +# TaskCompleted=6 UserPromptSubmit=7 SessionEnd=8 + +SOCKET="/tmp/ahakey.sock" +STATE="${1:-0}" + +[ -S "$SOCKET" ] && echo "$STATE" | nc -U "$SOCKET" -w 1 2>/dev/null || true diff --git a/ahakeyconfig-mac/scripts/build-debug.sh b/ahakeyconfig-mac/scripts/build-debug.sh new file mode 100755 index 00000000..cf61b3aa --- /dev/null +++ b/ahakeyconfig-mac/scripts/build-debug.sh @@ -0,0 +1,183 @@ +#!/bin/zsh +# 快速 debug 构建 → 直接把产物塞进 dist/AhaKey Studio.app +# 用于 Xcode Scheme Pre-action,每次 Cmd+R 前自动刷新 .app 里的二进制。 +# 111 +# 关键点: +# 1. 不重新生成 icon、Info.plist、entitlements(如果已存在就复用)——省时 +# 2. 保持 .app 路径、Bundle ID、entitlements 三件不变,TCC 授权条目才能匹配 +# 3. 优先使用 AHAKEY_DEBUG_SIGNING_IDENTITY 环境变量指定的签名身份(建议自签证书) +# 没有就 fall back 到 ad-hoc;ad-hoc 情况下改完代码 TCC 可能需要重新授权 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +EXECUTABLE_NAME="AhaKeyConfig" +APP_BUNDLE_NAME="${APP_BUNDLE_NAME:-AhaKey Studio(调试)}" +APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AhaKey Studio(调试)}" +APP_IDENTIFIER="lab.jawa.ahakeyconfig.debug" +OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" +APP_BUNDLE="$OUTPUT_DIR/$APP_BUNDLE_NAME.app" +APP_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/$EXECUTABLE_NAME" +AGENT_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/ahakeyconfig-agent" +INFO_PLIST="$APP_BUNDLE/Contents/Info.plist" +ENTITLEMENTS="$APP_ROOT/.build/AhaKeyConfig.entitlements" +ICON_SOURCE="${ICON_SOURCE:-$APP_ROOT/VibeCodeKeyboard.ico}" +ICONSET_DIR="$APP_ROOT/.build/AhaKeyConfig.iconset" +ICNS_PATH="$APP_ROOT/.build/AhaKeyConfig.icns" +SIGNING_IDENTITY="${AHAKEY_DEBUG_SIGNING_IDENTITY:-${SIGNING_IDENTITY:-}}" + +# 本地 Debug 默认启用稳定自签证书:TCC 会按证书 CN 记授权, +# 避免 ad-hoc 签名每次 build 因 cdhash 变化而掉权限。 +# 若显式设置 AHAKEY_DEBUG_ADHOC=1 则强制回退到 ad-hoc(调试签名问题时用)。 +if [[ -z "$SIGNING_IDENTITY" ]] && [[ "${AHAKEY_DEBUG_ADHOC:-0}" != "1" ]]; then + if [[ -x "$SCRIPT_DIR/ensure-dev-signing.sh" ]]; then + if auto_identity="$("$SCRIPT_DIR/ensure-dev-signing.sh")"; then + SIGNING_IDENTITY="$auto_identity" + else + echo "⚠️ ensure-dev-signing.sh 失败,fall back 到 ad-hoc 签名" + fi + fi +fi + +echo "🐞 Debug building $APP_DISPLAY_NAME..." +cd "$APP_ROOT" +swift build -c debug --arch arm64 --product AhaKeyConfig +swift build -c debug --arch arm64 --product ahakeyconfig-agent + +BUILD_OUTPUT=".build/arm64-apple-macosx/debug/$EXECUTABLE_NAME" +AGENT_OUTPUT=".build/arm64-apple-macosx/debug/ahakeyconfig-agent" +if [[ ! -f "$BUILD_OUTPUT" ]]; then + echo "Build output not found at $BUILD_OUTPUT" + exit 1 +fi + +mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources" "$OUTPUT_DIR" + +# 内置默认 OLED 动图:供 AhaKeyOLEDDraft 在用户未自定义时作为出厂预览/上传素材。 +# 放在 Contents/Resources/DefaultOLED/,代码里通过 Bundle.main 访问。 +if [[ -d "$APP_ROOT/Resources/DefaultOLED" ]]; then + mkdir -p "$APP_BUNDLE/Contents/Resources/DefaultOLED" + # --delete 保证删掉/换名资源也会同步;同时排除 macOS 隐藏文件以免混进 bundle。 + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete \ + --exclude='.DS_Store' --exclude='._*' --exclude='.*.swp' \ + "$APP_ROOT/Resources/DefaultOLED/" \ + "$APP_BUNDLE/Contents/Resources/DefaultOLED/" + else + rm -rf "$APP_BUNDLE/Contents/Resources/DefaultOLED" + mkdir -p "$APP_BUNDLE/Contents/Resources/DefaultOLED" + find "$APP_ROOT/Resources/DefaultOLED" -type f \ + ! -name '.DS_Store' ! -name '._*' ! -name '.*.swp' \ + -exec cp {} "$APP_BUNDLE/Contents/Resources/DefaultOLED/" \; + fi +fi + +# icon:只在缺失时生成,避免每次 Run 都跑一遍 iconutil +if [[ ! -f "$APP_BUNDLE/Contents/Resources/AhaKeyConfig.icns" ]]; then + echo "🎨 Generating app icon (first run)..." + if [[ -f "$ICON_SOURCE" ]]; then + swift "$APP_ROOT/scripts/generate_icons.swift" "$ICONSET_DIR" "$ICON_SOURCE" + else + swift "$APP_ROOT/scripts/generate_icons.swift" "$ICONSET_DIR" + fi + iconutil -c icns "$ICONSET_DIR" -o "$ICNS_PATH" + cp "$ICNS_PATH" "$APP_BUNDLE/Contents/Resources/AhaKeyConfig.icns" +fi + +BUILD_NUMBER="$(git -C "$APP_ROOT" rev-list --count HEAD 2>/dev/null || echo 1)" + +# Info.plist:只在缺失或 identifier 不对时重写,保证 Bundle ID 恒定 → TCC 条目稳定 +NEED_PLIST=1 +if [[ -f "$INFO_PLIST" ]] && /usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$INFO_PLIST" 2>/dev/null | grep -qx "$APP_IDENTIFIER"; then + NEED_PLIST=0 +fi +if [[ "$NEED_PLIST" == "1" ]]; then + cat > "$INFO_PLIST" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${APP_DISPLAY_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${APP_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + AhaKeyConfig + CFBundleName + ${APP_DISPLAY_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0-debug + CFBundleVersion + ${BUILD_NUMBER} + LSMinimumSystemVersion + 15.0 + NSBluetoothAlwaysUsageDescription + AhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescription + AhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescription + AhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + +PLIST +fi + +mkdir -p "$(dirname "$ENTITLEMENTS")" +if [[ ! -f "$ENTITLEMENTS" ]]; then + cat > "$ENTITLEMENTS" <<'ENTITLEMENTS' + + + + + com.apple.security.device.bluetooth + + + +ENTITLEMENTS +fi + +cp "$BUILD_OUTPUT" "$APP_EXECUTABLE" +cp "$AGENT_OUTPUT" "$AGENT_EXECUTABLE" + +if [[ -n "$SIGNING_IDENTITY" ]]; then + # 对 40 位十六进制(SHA-1)用 security 反查一下 CN,方便日志定位 + if [[ "$SIGNING_IDENTITY" =~ ^[0-9A-Fa-f]{40}$ ]]; then + IDENTITY_CN="$(security find-identity -p codesigning "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null \ + | awk -v sha="$SIGNING_IDENTITY" '$2 == sha { sub(/.*"/,""); sub(/".*/,""); print; exit }')" + echo "🔏 Debug signing with: $SIGNING_IDENTITY${IDENTITY_CN:+ ($IDENTITY_CN)}" + else + echo "🔏 Debug signing with: $SIGNING_IDENTITY" + fi + codesign --force --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$APP_EXECUTABLE" + codesign --force --sign "$SIGNING_IDENTITY" "$AGENT_EXECUTABLE" + codesign --force --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" +else + echo "🧪 Ad-hoc signing (TCC may need re-grant after code changes)." + codesign --force --sign - --entitlements "$ENTITLEMENTS" "$APP_EXECUTABLE" + codesign --force --sign - "$AGENT_EXECUTABLE" + codesign --force --sign - --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" +fi + +# 清掉所有 com.apple.quarantine 扩展属性 +# 否则 ad-hoc 签名 + quarantine 会触发 Gatekeeper App Translocation: +# macOS 会把 .app 拷到 /private/var/folders/.../AppTranslocation/<随机UUID>/ 再启动, +# 每次启动路径都不同,TCC 授权永远失配,“输入监控/辅助功能”永远识别不到。 +xattr -rd com.apple.quarantine "$APP_BUNDLE" 2>/dev/null || true + +# 强制 LaunchServices 刷新,避免 macOS 缓存到旧的 bundle 元数据 +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +if [[ -x "$LSREGISTER" ]]; then + "$LSREGISTER" -f "$APP_BUNDLE" >/dev/null 2>&1 || true +fi + +echo "✅ Debug bundle ready: $APP_BUNDLE" diff --git a/ahakeyconfig-mac/scripts/build.sh b/ahakeyconfig-mac/scripts/build.sh new file mode 100755 index 00000000..6c2bbf3f --- /dev/null +++ b/ahakeyconfig-mac/scripts/build.sh @@ -0,0 +1,234 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +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}" +BUILD_ARCHS="${BUILD_ARCHS:-arm64 x86_64}" +OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" +APP_BUNDLE="$OUTPUT_DIR/$APP_BUNDLE_NAME.app" +APP_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/$EXECUTABLE_NAME" +AGENT_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/ahakeyconfig-agent" +INFO_PLIST="$APP_BUNDLE/Contents/Info.plist" +ENTITLEMENTS="$APP_ROOT/.build/AhaKeyConfig.entitlements" +ICON_SOURCE="${ICON_SOURCE:-$APP_ROOT/ahakeyicon.png}" +INSTALL_TO_APPLICATIONS="${INSTALL_TO_APPLICATIONS:-0}" +INSTALL_DIR="${INSTALL_DIR:-/Applications}" +LAUNCH_AFTER_INSTALL="${LAUNCH_AFTER_INSTALL:-0}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +SIGNING_IDENTITY_HINT="${SIGNING_IDENTITY_HINT:-}" +REQUIRE_DEVELOPER_ID="${REQUIRE_DEVELOPER_ID:-0}" +DEST_APP="$INSTALL_DIR/$APP_BUNDLE_NAME.app" + +echo "📦 Building $APP_DISPLAY_NAME..." +cd "$APP_ROOT" + +export MACOSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" + +BUILD_OUTPUTS=() +AGENT_OUTPUTS=() +for ARCH in ${(z)BUILD_ARCHS}; do + echo "🔨 Building $ARCH for macOS $MACOS_DEPLOYMENT_TARGET..." + swift build -c release --arch "$ARCH" --product AhaKeyConfig + swift build -c release --arch "$ARCH" --product ahakeyconfig-agent + + ARCH_BUILD_OUTPUT=".build/$ARCH-apple-macosx/release/$EXECUTABLE_NAME" + ARCH_AGENT_OUTPUT=".build/$ARCH-apple-macosx/release/ahakeyconfig-agent" + if [[ ! -f "$ARCH_BUILD_OUTPUT" ]]; then + echo "Build output not found at $ARCH_BUILD_OUTPUT" + exit 1 + fi + if [[ ! -f "$ARCH_AGENT_OUTPUT" ]]; then + echo "Agent build output not found at $ARCH_AGENT_OUTPUT" + exit 1 + fi + BUILD_OUTPUTS+=("$ARCH_BUILD_OUTPUT") + AGENT_OUTPUTS+=("$ARCH_AGENT_OUTPUT") +done + +echo "🧱 Creating app bundle..." +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" +mkdir -p "$OUTPUT_DIR" + +ICONSET_DIR="$APP_ROOT/.build/AhaKeyConfig.iconset" +ICNS_PATH="$APP_ROOT/.build/AhaKeyConfig.icns" + +echo "🎨 Generating app icon..." +if [[ -f "$ICON_SOURCE" ]]; then + swift "$APP_ROOT/scripts/generate_icons.swift" "$ICONSET_DIR" "$ICON_SOURCE" +else + swift "$APP_ROOT/scripts/generate_icons.swift" "$ICONSET_DIR" +fi +iconutil -c icns "$ICONSET_DIR" -o "$ICNS_PATH" + +if [[ ${#BUILD_OUTPUTS[@]} -gt 1 ]]; then + lipo -create "${BUILD_OUTPUTS[@]}" -output "$APP_EXECUTABLE" + lipo -create "${AGENT_OUTPUTS[@]}" -output "$AGENT_EXECUTABLE" +else + cp "$BUILD_OUTPUTS[1]" "$APP_EXECUTABLE" + cp "$AGENT_OUTPUTS[1]" "$AGENT_EXECUTABLE" +fi +cp "$ICNS_PATH" "$APP_BUNDLE/Contents/Resources/AhaKeyConfig.icns" +if [[ -d "$APP_ROOT/Resources/Help" ]]; then + mkdir -p "$APP_BUNDLE/Contents/Resources/Help" + ditto "$APP_ROOT/Resources/Help" "$APP_BUNDLE/Contents/Resources/Help" +fi +if [[ -d "$APP_ROOT/Resources/DefaultOLED" ]]; then + mkdir -p "$APP_BUNDLE/Contents/Resources/DefaultOLED" + ditto "$APP_ROOT/Resources/DefaultOLED" "$APP_BUNDLE/Contents/Resources/DefaultOLED" +fi + +BUILD_NUMBER="$(git -C "$APP_ROOT/../.." rev-list --count HEAD 2>/dev/null || echo 1)" + +cat > "$INFO_PLIST" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${APP_DISPLAY_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${APP_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + AhaKeyConfig + CFBundleName + ${APP_DISPLAY_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + ${BUILD_NUMBER} + LSMinimumSystemVersion + ${MACOS_DEPLOYMENT_TARGET} + NSBluetoothAlwaysUsageDescription + AhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescription + AhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescription + AhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + +PLIST + +mkdir -p "$(dirname "$ENTITLEMENTS")" +cat > "$ENTITLEMENTS" <<'ENTITLEMENTS' + + + + + com.apple.security.device.bluetooth + + com.apple.security.device.audio-input + + + +ENTITLEMENTS + +find_developer_id() { + local identities + identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$SIGNING_IDENTITY_HINT" ]]; then + echo "$identities" | grep 'Developer ID Application' | grep "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + else + echo "$identities" | grep 'Developer ID Application' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + fi +} + +find_apple_development() { + local identities + identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$SIGNING_IDENTITY_HINT" ]]; then + echo "$identities" | grep 'Apple Development' | grep "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + else + echo "$identities" | grep 'Apple Development' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + fi +} + +if [[ -z "$SIGNING_IDENTITY" ]]; then + SIGNING_IDENTITY="$(find_developer_id)" +fi + +if [[ -z "$SIGNING_IDENTITY" && "$REQUIRE_DEVELOPER_ID" != "1" ]]; then + SIGNING_IDENTITY="$(find_apple_development)" +fi + +if [[ "$REQUIRE_DEVELOPER_ID" == "1" && -z "$SIGNING_IDENTITY" ]]; then + echo "❌ No Developer ID Application identity found in keychain." + echo " Please install a Developer ID Application certificate first." + exit 1 +fi + +if [[ -n "${SIGNING_IDENTITY}" ]]; then + echo "🔏 Signing with: $SIGNING_IDENTITY" + SIGN_ARGS=(--force --sign "$SIGNING_IDENTITY") + APP_SIGN_ARGS=("${SIGN_ARGS[@]}") + + if [[ "$SIGNING_IDENTITY" == Developer\ ID\ Application:* ]]; then + SIGN_ARGS+=(--timestamp --options runtime) + APP_SIGN_ARGS=("${SIGN_ARGS[@]}" --entitlements "$ENTITLEMENTS") + else + APP_SIGN_ARGS+=(--entitlements "$ENTITLEMENTS") + fi + + xattr -cr "$APP_BUNDLE" 2>/dev/null || true + codesign "${SIGN_ARGS[@]}" "$AGENT_EXECUTABLE" + codesign "${APP_SIGN_ARGS[@]}" "$APP_BUNDLE" +else + if [[ "$REQUIRE_DEVELOPER_ID" == "1" ]]; then + echo "❌ RELEASE_DISTRIBUTION requires a valid Developer ID Application identity." + exit 1 + fi + echo "🧪 No signing identity found, using ad-hoc signature for local testing" + xattr -cr "$APP_BUNDLE" 2>/dev/null || true + codesign --force --sign - "$AGENT_EXECUTABLE" + codesign --force --sign - "$APP_BUNDLE" +fi + +echo "🔎 Verifying app signature..." +codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" + +if [[ "$INSTALL_TO_APPLICATIONS" == "1" ]]; then + echo "📥 Installing to $DEST_APP..." + + # 单实例:关闭已运行的实例 + if pgrep -f "$DEST_APP/Contents/MacOS/$EXECUTABLE_NAME" >/dev/null 2>&1; then + osascript -e "tell application id \"$APP_IDENTIFIER\" to quit" 2>/dev/null || true + for _ in {1..20}; do + pgrep -f "$DEST_APP/Contents/MacOS/$EXECUTABLE_NAME" >/dev/null 2>&1 || break + sleep 0.25 + done + pkill -9 -f "$DEST_APP/Contents/MacOS/$EXECUTABLE_NAME" 2>/dev/null || true + sleep 0.3 + fi + + rm -rf "$DEST_APP" + mkdir -p "$INSTALL_DIR" + ditto "$APP_BUNDLE" "$DEST_APP" + xattr -dr com.apple.quarantine "$DEST_APP" 2>/dev/null || true + + LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + if [[ -x "$LSREGISTER" ]]; then + "$LSREGISTER" -f "$DEST_APP" >/dev/null 2>&1 || true + fi + + if [[ "$LAUNCH_AFTER_INSTALL" == "1" ]]; then + open "$DEST_APP" + fi +fi + +echo "✅ Build complete: $APP_BUNDLE" diff --git a/ahakeyconfig-mac/scripts/ensure-dev-signing.sh b/ahakeyconfig-mac/scripts/ensure-dev-signing.sh new file mode 100755 index 00000000..c5164623 --- /dev/null +++ b/ahakeyconfig-mac/scripts/ensure-dev-signing.sh @@ -0,0 +1,158 @@ +#!/bin/zsh +# 本地开发专用:确保 login keychain 里有一张稳定的自签代码签名证书。 +# +# 用途:让 Debug build 的每次 codesign 都用同一个 CN 签名, +# 这样即便代码修改导致 cdhash 变化,macOS TCC 仍然按证书 CN 认 +# "输入监控 / 辅助功能 / 麦克风 / 语音转写" 等授权,无需每次重新勾选。 +# +# stdout: 证书 SHA-1 指纹(供 codesign --sign 使用) +# stderr: 所有进度信息 +# +# 为什么输出 SHA-1 而不是 CN? +# 自签证书没系统 trust,`codesign --sign ""` 会报 "no identity found"; +# 但 `codesign --sign ""` 会绕过 trust 检查、直接用私钥签。 +# 签出来的 Authority 字段仍是 CN "AhaKey Local Dev",TCC 按这个字段认, +# 所以 CN 必须稳定(不同 build 不能换名字)。 +# +# 注意: +# - 不影响正式发布流程;scripts/build.sh 不会调用此脚本。 +# - 证书仅在 login keychain,不向系统引入信任,也不上传任何地方。 +# - 首次创建时 macOS 可能弹一次"允许 codesign 访问密钥"的提示, +# 点"总是允许"即可一劳永逸。 + +set -euo pipefail + +CERT_CN="${AHAKEY_DEV_CERT_CN:-AhaKey Local Dev}" +KEYCHAIN="$HOME/Library/Keychains/login.keychain-db" + +# 按 CN 精确匹配查 SHA-1 +find_valid_sha1() { + local cn="$1" + security find-identity -p codesigning -v "$KEYCHAIN" 2>/dev/null \ + | awk -v cn="\"$cn\"" '$0 ~ cn {print $2; exit}' +} + +# 按正则匹配查第一个有效 identity(用于找 "Apple Development: ...") +find_valid_by_pattern() { + local pat="$1" + security find-identity -p codesigning -v "$KEYCHAIN" 2>/dev/null \ + | awk -v pat="$pat" '$0 ~ pat {print $2; exit}' +} + +# 优先级 1: Apple Development 证书(来自 Xcode 登录 Apple ID 生成) +# 这是 TCC 稳定匹配的最可靠路径 —— Apple root chain 签的 app, +# TCC 严格按 designated requirement 评估,cdhash 变也不会掉权限。 +apple_dev="$(find_valid_by_pattern 'Apple Development: ')" +if [[ -n "$apple_dev" ]]; then + >&2 echo "🍎 [ensure-dev-signing] 使用 Apple Development 证书 $apple_dev" + echo "$apple_dev" + exit 0 +fi + +# 优先级 2: 自签 AhaKey Local Dev(若已存在且受信) +valid="$(find_valid_sha1 "$CERT_CN")" +if [[ -n "$valid" ]]; then + >&2 echo "🔏 [ensure-dev-signing] 使用自签证书 '$CERT_CN' $valid" + >&2 echo " 提示:若之后你在 Xcode 登录 Apple ID 并生成 'Apple Development' 证书," + >&2 echo " 本脚本会自动切换使用它(更稳,无需操作)。" + echo "$valid" + exit 0 +fi + +TMPDIR_LOCAL="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_LOCAL"' EXIT + +# 按 CN 精确匹配查任意状态下的 identity(含 untrusted) +find_any_sha1() { + security find-identity -p codesigning "$KEYCHAIN" 2>/dev/null \ + | awk -v cn="\"$CERT_CN\"" '$0 ~ cn {print $2; exit}' +} + +# 情况 A:证书已在 keychain 但未受信 → 只需补信任即可,无需重新生成 +untrusted="$(find_any_sha1)" +if [[ -n "$untrusted" ]]; then + >&2 echo "🔐 [ensure-dev-signing] 发现 '$CERT_CN' 但未标记为 codesign 受信,补 trust…" + # 导出现有证书的 PEM 供 add-trusted-cert 使用 + security find-certificate -c "$CERT_CN" -p "$KEYCHAIN" > "$TMPDIR_LOCAL/cert.pem" +else + >&2 echo "🔐 [ensure-dev-signing] 首次创建本地自签代码签名证书 '$CERT_CN' …" + + if ! command -v openssl >/dev/null 2>&1; then + >&2 echo "❌ 找不到 openssl,无法创建证书" + exit 1 + fi + + # Apple code signing policy 要求: + # keyUsage=digitalSignature + # extendedKeyUsage=codeSigning + # basicConstraints=CA:FALSE + # 缺任何一条 codesign 就会报 "Invalid Key Usage for policy" + openssl req -x509 -nodes -newkey rsa:2048 \ + -keyout "$TMPDIR_LOCAL/key.pem" \ + -out "$TMPDIR_LOCAL/cert.pem" \ + -days 3650 \ + -subj "/CN=$CERT_CN" \ + -addext "keyUsage=critical,digitalSignature" \ + -addext "extendedKeyUsage=critical,codeSigning" \ + -addext "basicConstraints=critical,CA:FALSE" \ + >/dev/null 2>&1 + + PFX_PASS="ahakey-dev-$(date +%s)" + # OpenSSL 3.x 默认导出现代格式 (AES-256-CBC/PBKDF2), + # 但 macOS `security import` 只支持传统 PKCS#12 (RC2/3DES)。 + # `-legacy` 强制回退到传统格式。 + # LibreSSL / OpenSSL 1.1.x 不识别 -legacy,所以加 fallback。 + if ! openssl pkcs12 -export -legacy \ + -in "$TMPDIR_LOCAL/cert.pem" \ + -inkey "$TMPDIR_LOCAL/key.pem" \ + -out "$TMPDIR_LOCAL/bundle.p12" \ + -name "$CERT_CN" \ + -password "pass:$PFX_PASS" \ + >/dev/null 2>&1; then + openssl pkcs12 -export \ + -in "$TMPDIR_LOCAL/cert.pem" \ + -inkey "$TMPDIR_LOCAL/key.pem" \ + -out "$TMPDIR_LOCAL/bundle.p12" \ + -name "$CERT_CN" \ + -password "pass:$PFX_PASS" \ + >/dev/null 2>&1 + fi + + security import "$TMPDIR_LOCAL/bundle.p12" \ + -k "$KEYCHAIN" \ + -P "$PFX_PASS" \ + -T /usr/bin/codesign \ + -T /usr/bin/security \ + >/dev/null + + # 尝试免密授权 codesign 使用私钥;失败只是"首次签名会弹一次提示", + # 不影响证书本身可用性。 + security set-key-partition-list \ + -S "apple-tool:,apple:,codesign:" \ + -s \ + "$KEYCHAIN" >/dev/null 2>&1 || { + >&2 echo " ⚠️ 未能自动授权钥匙串访问,首次签名时可能弹一次密码框,选'总是允许'即可。" + } +fi + +# 建立用户级 code signing trust —— codesign 只接受通过 policy 评估的 identity。 +# 这一步会弹一次 macOS 对话框让用户用 Touch ID / 登录密码确认, +# 之后永久生效(下次 build 不会再弹)。 +>&2 echo "🔑 [ensure-dev-signing] 正在把证书标记为 codesign 可信;" +>&2 echo " macOS 会弹一次对话框,请用 Touch ID / 登录密码确认。" +if ! security add-trusted-cert -r trustRoot -p codeSign \ + -k "$KEYCHAIN" \ + "$TMPDIR_LOCAL/cert.pem" 2>>/tmp/ahakey-ensure-dev-signing.log; then + >&2 echo "❌ add-trusted-cert 失败(看 /tmp/ahakey-ensure-dev-signing.log)" + >&2 echo " 若刚才取消了弹窗,再次运行本脚本即可。" + exit 1 +fi + +sha1="$(find_valid_sha1 "$CERT_CN")" +if [[ -z "$sha1" ]]; then + >&2 echo "❌ 证书受信后仍无法被 codesign 识别为有效 identity" + exit 1 +fi + +>&2 echo "✅ 证书 '$CERT_CN' 已就绪 (SHA-1 $sha1),后续所有 Debug build 都会用它签名。" +echo "$sha1" diff --git a/ahakeyconfig-mac/scripts/fix-debug-permissions.sh b/ahakeyconfig-mac/scripts/fix-debug-permissions.sh new file mode 100755 index 00000000..3b975843 --- /dev/null +++ b/ahakeyconfig-mac/scripts/fix-debug-permissions.sh @@ -0,0 +1,71 @@ +#!/bin/zsh +# 本地开发专用:一键修复 Debug build 的 TCC 权限失效。 +# +# 作用: +# 1. 确保本地自签证书存在(ensure-dev-signing.sh) +# 2. 用该证书重签 dist/AhaKey Studio.app 里的所有二进制 +# 3. 重置 AhaKey Studio 相关的 TCC 授权条目 +# +# 这样用户下一次启动 App 时: +# - cdhash 变了没关系,TCC 按证书 CN 重新认 +# - 系统会弹权限窗,一次勾选后永久生效 +# +# 使用: +# - 点击 App 里"开发版:修复签名 & 权限"按钮会自动调用 +# - 也可以手动执行:scripts/fix-debug-permissions.sh +# +# 注意:不影响 scripts/build.sh(release 流程)。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_BUNDLE_NAME="${APP_BUNDLE_NAME:-AhaKey Studio}" +APP_BUNDLE="$APP_ROOT/dist/$APP_BUNDLE_NAME.app" +APP_IDENTIFIER="${APP_IDENTIFIER:-lab.jawa.ahakeyconfig}" +ENTITLEMENTS="$APP_ROOT/.build/AhaKeyConfig.entitlements" + +if [[ ! -d "$APP_BUNDLE" ]]; then + echo "❌ 找不到 $APP_BUNDLE" + echo " 请先运行 scripts/build-debug.sh 或通过 Xcode 构建一次。" + exit 1 +fi + +echo "🔐 步骤 1/3 获取/创建本地自签证书" +IDENTITY="$("$SCRIPT_DIR/ensure-dev-signing.sh")" +echo " 使用证书: $IDENTITY" + +echo "🔏 步骤 2/3 用该证书重签 $APP_BUNDLE" + +APP_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/AhaKeyConfig" +AGENT_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/ahakeyconfig-agent" + +if [[ -f "$AGENT_EXECUTABLE" ]]; then + codesign --force --sign "$IDENTITY" "$AGENT_EXECUTABLE" +fi + +if [[ -f "$ENTITLEMENTS" ]]; then + codesign --force --sign "$IDENTITY" --entitlements "$ENTITLEMENTS" "$APP_EXECUTABLE" + codesign --force --sign "$IDENTITY" --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" +else + codesign --force --sign "$IDENTITY" "$APP_EXECUTABLE" + codesign --force --sign "$IDENTITY" "$APP_BUNDLE" +fi + +xattr -rd com.apple.quarantine "$APP_BUNDLE" 2>/dev/null || true + +echo "🧹 步骤 3/3 重置 $APP_IDENTIFIER 的 TCC 授权" + +# Bluetooth 不支持按 bundle id 重置,跳过 +for svc in ListenEvent Accessibility PostEvent Microphone SpeechRecognition; do + if tccutil reset "$svc" "$APP_IDENTIFIER" >/dev/null 2>&1; then + echo " ✓ reset $svc" + else + echo " - skip $svc (无旧条目)" + fi +done + +echo "" +echo "✅ 修复完成。" +echo " 下一步:退出 AhaKey Studio,重新启动后按系统提示重新勾选权限即可。" +echo " 之后再改代码、再 build,TCC 都会按证书 CN 记住授权,不会再掉。" diff --git a/ahakeyconfig-mac/scripts/generate_dmg_background.swift b/ahakeyconfig-mac/scripts/generate_dmg_background.swift new file mode 100644 index 00000000..fcf9cd4b --- /dev/null +++ b/ahakeyconfig-mac/scripts/generate_dmg_background.swift @@ -0,0 +1,100 @@ +import AppKit + +let arguments = CommandLine.arguments +guard arguments.count >= 2 else { + fputs("usage: swift generate_dmg_background.swift \n", stderr) + exit(1) +} + +let outputURL = URL(fileURLWithPath: arguments[1]) + +// Background size must match DMG window content area +// Window bounds in osascript: {100, 100, 960, 580} => content 860 x 480 +let width: CGFloat = 860 +let height: CGFloat = 480 + +let image = NSImage(size: NSSize(width: width, height: height)) +image.lockFocus() + +let bounds = NSRect(x: 0, y: 0, width: width, height: height) + +// ── Background: clean white-to-light-gray, like macOS system dialogs ───── +NSGradient(colors: [ + NSColor(calibratedRed: 0.980, green: 0.982, blue: 0.988, alpha: 1), + NSColor(calibratedRed: 0.940, green: 0.944, blue: 0.955, alpha: 1), +])!.draw(in: bounds, angle: 90) + +// ── Title ───────────────────────────────────────────────────────────────── +let centerStyle = NSMutableParagraphStyle() +centerStyle.alignment = .center + +let titleAttr: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 26, weight: .semibold), + .foregroundColor: NSColor(calibratedWhite: 0.13, alpha: 1), + .paragraphStyle: centerStyle, +] +"拖动 AhaKey Studio 到 Applications".draw( + in: NSRect(x: 60, y: height - 82, width: width - 120, height: 40), + withAttributes: titleAttr +) + +// ── Subtitle ────────────────────────────────────────────────────────────── +let subAttr: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 14, weight: .regular), + .foregroundColor: NSColor(calibratedWhite: 0.48, alpha: 1), + .paragraphStyle: centerStyle, +] +"安装完成后,就能像普通 Mac 应用一样打开使用".draw( + in: NSRect(x: 80, y: height - 114, width: width - 160, height: 22), + withAttributes: subAttr +) + +// ── Arrow: clean filled shape between the two icon positions ────────────── +// App icon centre ~x=170, Applications icon centre ~x=690 (at icon size 100) +// Arrow spans roughly x 260 → 590, y centred at 210 +let arrowColor = NSColor(calibratedRed: 0.20, green: 0.50, blue: 0.95, alpha: 1) +let arrowY: CGFloat = 210 +let arrowX0: CGFloat = 268 +let arrowX1: CGFloat = 588 +let arrowLW: CGFloat = 6 +let headLen: CGFloat = 22 +let headHalf: CGFloat = 14 + +// Shaft +let shaft = NSBezierPath() +shaft.move(to: NSPoint(x: arrowX0, y: arrowY)) +shaft.line(to: NSPoint(x: arrowX1 - headLen * 0.6, y: arrowY)) +shaft.lineWidth = arrowLW +shaft.lineCapStyle = .round +arrowColor.setStroke() +shaft.stroke() + +// Filled arrowhead +let head = NSBezierPath() +head.move(to: NSPoint(x: arrowX1, y: arrowY)) +head.line(to: NSPoint(x: arrowX1 - headLen, y: arrowY + headHalf)) +head.line(to: NSPoint(x: arrowX1 - headLen, y: arrowY - headHalf)) +head.close() +arrowColor.setFill() +head.fill() + +// Arrow caption +let captionAttr: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: arrowColor, + .paragraphStyle: centerStyle, +] +"拖过去安装".draw( + in: NSRect(x: arrowX0, y: arrowY - 36, width: arrowX1 - arrowX0, height: 20), + withAttributes: captionAttr +) + +image.unlockFocus() + +guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { + fputs("failed to render png background\n", stderr) + exit(1) +} +try png.write(to: outputURL) diff --git a/ahakeyconfig-mac/scripts/generate_icons.swift b/ahakeyconfig-mac/scripts/generate_icons.swift new file mode 100644 index 00000000..7a2542f5 --- /dev/null +++ b/ahakeyconfig-mac/scripts/generate_icons.swift @@ -0,0 +1,124 @@ +import AppKit +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count >= 2 else { + fputs("usage: swift generate_icons.swift [source-image]\n", stderr) + exit(1) +} + +let outputDirectory = URL(fileURLWithPath: arguments[1], isDirectory: true) +let sourceImageURL = arguments.count >= 3 ? URL(fileURLWithPath: arguments[2]) : nil +let fileManager = FileManager.default +try? fileManager.removeItem(at: outputDirectory) +try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + +let sizes: [(Int, String)] = [ + (16, "icon_16x16.png"), + (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), + (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), + (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), + (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), + (1024, "icon_512x512@2x.png"), +] + +let sourceImage = sourceImageURL.flatMap { NSImage(contentsOf: $0) } + +for (size, filename) in sizes { + let image = sourceImage.map { renderSourceIcon($0, size: CGFloat(size)) } ?? makeIcon(size: CGFloat(size)) + let destination = outputDirectory.appendingPathComponent(filename) + try pngData(from: image).write(to: destination) +} + +// MARK: - Icon rendering + +func renderSourceIcon(_ source: NSImage, size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + + source.draw( + in: NSRect(x: 0, y: 0, width: size, height: size), + from: NSRect(origin: .zero, size: source.size), + operation: .sourceOver, + fraction: 1.0 + ) + + image.unlockFocus() + return image +} + +func makeIcon(size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + + let s = size + + // ── Background: white ────────────────────────────────────────────────── + NSColor.white.setFill() + NSBezierPath.fill(NSRect(x: 0, y: 0, width: s, height: s)) + + // ── "A" letter: bold geometric dark charcoal ─────────────────────────── + // Use heaviest system font weight to match the bold geometric "A" in reference + let aColor = NSColor(calibratedRed: 0.09, green: 0.09, blue: 0.11, alpha: 1.0) + let aFont = NSFont.systemFont(ofSize: s * 0.86, weight: .black) + let aAttribs: [NSAttributedString.Key: Any] = [ + .font: aFont, + .foregroundColor: aColor, + ] + let aStr = NSAttributedString(string: "A", attributes: aAttribs) + let aSize = aStr.size() + // Center horizontally; nudge slightly below vertical center for optical balance + let aX = (s - aSize.width) / 2 + let aY = (s - aSize.height) / 2 - s * 0.02 + aStr.draw(at: NSPoint(x: aX, y: aY)) + + // ── Lightning bolt: electric blue diagonal slash ──────────────────────── + // Bolt runs from upper-right → lower-left, cutting through the "A". + // All coordinates in 0…1000 space; scaled to actual icon size via `pt()`. + let boltBlue = NSColor(calibratedRed: 0.08, green: 0.38, blue: 0.96, alpha: 1.0) + let sc = s / 1000.0 + func pt(_ x: CGFloat, _ y: CGFloat) -> NSPoint { NSPoint(x: x * sc, y: y * sc) } + + // Classic zigzag lightning bolt outline (8 vertices, clockwise): + // Upper section → step right (jog) → Lower section → tip → back up + // + // P1 ── P2 (top edge) + // | \ + // P8 P3 ── P4 (step: lower section is offset right ~90 units) + // | \ + // P7 ── P6 P5 (tip) + + let bolt = NSBezierPath() + bolt.move(to: pt(502, 880)) // P1 top-left + bolt.line(to: pt(662, 880)) // P2 top-right + bolt.line(to: pt(492, 474)) // P3 mid-right (upper section right edge at step) + bolt.line(to: pt(580, 474)) // P4 step-right (lower section right edge start) + bolt.line(to: pt(406, 78)) // P5 bottom tip right + bolt.line(to: pt(328, 78)) // P6 bottom tip left + bolt.line(to: pt(393, 454)) // P7 mid-left (lower section left edge at step) + bolt.line(to: pt(305, 454)) // P8 step-left (upper section left edge) + bolt.close() // back to P1 + + boltBlue.setFill() + bolt.fill() + + image.unlockFocus() + return image +} + +// MARK: - PNG helper + +func pngData(from image: NSImage) throws -> Data { + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) else { + throw NSError(domain: "IconGeneration", code: 1) + } + return png +} diff --git a/ahakeyconfig-mac/scripts/pack-release.sh b/ahakeyconfig-mac/scripts/pack-release.sh new file mode 100755 index 00000000..ebbe937c --- /dev/null +++ b/ahakeyconfig-mac/scripts/pack-release.sh @@ -0,0 +1,66 @@ +#!/bin/zsh +# 可分发安装包(Developer ID + 公证):对内测试与对外发版用同一流程即可。 +# 需要「快速本地调试、不跑公证」时可直接:zsh scripts/build.sh +# +# 内部调用 package_dmg.sh、build.sh。 +# +# 产出:dist/AhaKey Studio.app +# dist/AhaKey-Studio-macOS-prod-YYYYMMDDHHmmss.dmg(可用 DMG_BASENAME 覆盖) +# +# 用法: +# zsh scripts/pack-release.sh +# zsh /path/to/ahakeyconfig/scripts/pack-release.sh +# +# 可选环境变量:NOTARY_PROFILE、SIGNING_IDENTITY、SIGNING_IDENTITY_HINT、OUTPUT_DIR、DMG_BASENAME + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$APP_ROOT" + +if [[ -f "$SCRIPT_DIR/build.local.env" ]]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/build.local.env" +fi + +NOTARY_PROFILE="${NOTARY_PROFILE:-AhaKeyNotary}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +SIGNING_IDENTITY_HINT="${SIGNING_IDENTITY_HINT:-}" +OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" +if [[ -z "${DMG_BASENAME:-}" ]]; then + DMG_BASENAME="AhaKey-Studio-macOS-prod-$(date +%Y%m%d%H%M%S)" +fi + +echo "🚀 Building formal distribution DMG → $DMG_BASENAME.dmg" + +if [[ -z "$SIGNING_IDENTITY" && -n "$SIGNING_IDENTITY_HINT" ]]; then + SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | grep -F "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true)" +fi + +if [[ -z "$SIGNING_IDENTITY" ]]; then + SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true)" +fi + +if [[ -z "$SIGNING_IDENTITY" ]]; then + echo "❌ Missing Developer ID Application certificate." + echo " Install the certificate in your login keychain, then retry." + exit 1 +fi + +if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1; then + echo "❌ Notary profile '$NOTARY_PROFILE' is not available." + echo " Create it first with:" + echo " xcrun notarytool store-credentials \"$NOTARY_PROFILE\" --apple-id --team-id --password " + exit 1 +fi + +RELEASE_DISTRIBUTION=1 \ +SIGNING_IDENTITY="$SIGNING_IDENTITY" \ +SIGNING_IDENTITY_HINT="$SIGNING_IDENTITY_HINT" \ +NOTARY_PROFILE="$NOTARY_PROFILE" \ +OUTPUT_DIR="$OUTPUT_DIR" \ +DMG_BASENAME="$DMG_BASENAME" \ +zsh "$SCRIPT_DIR/package_dmg.sh" + +echo "✅ Formal distribution package complete." diff --git a/ahakeyconfig-mac/scripts/package_dmg.sh b/ahakeyconfig-mac/scripts/package_dmg.sh new file mode 100755 index 00000000..ac76beeb --- /dev/null +++ b/ahakeyconfig-mac/scripts/package_dmg.sh @@ -0,0 +1,204 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [[ -f "$SCRIPT_DIR/build.local.env" ]]; then + source "$SCRIPT_DIR/build.local.env" +fi + +APP_BUNDLE_NAME="${APP_BUNDLE_NAME:-AhaKey Studio}" +APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AhaKey Studio}" +OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" +DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-AhaKey Studio Installer}" +DMG_BASENAME="${DMG_BASENAME:-AhaKey-Studio-macOS-prod-$(date +%Y%m%d%H%M%S)}" +DMG_PATH="$OUTPUT_DIR/$DMG_BASENAME.dmg" +DMG_STAGING_DIR="$OUTPUT_DIR/.dmg-staging" +RW_DMG_PATH="$OUTPUT_DIR/$DMG_BASENAME-rw.dmg" +DMG_MOUNTPOINT="/Volumes/$DMG_VOLUME_NAME" +APP_BUNDLE_PATH="$OUTPUT_DIR/$APP_BUNDLE_NAME.app" +BACKGROUND_DIR="$DMG_STAGING_DIR/.background" +BACKGROUND_IMAGE="$BACKGROUND_DIR/InstallerBackground.png" +NOTARY_PROFILE="${NOTARY_PROFILE:-}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +SIGNING_IDENTITY_HINT="${SIGNING_IDENTITY_HINT:-}" +RELEASE_DISTRIBUTION="${RELEASE_DISTRIBUTION:-0}" + +if [[ -z "$SIGNING_IDENTITY" ]]; then + IDENTITIES="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$SIGNING_IDENTITY_HINT" ]]; then + SIGNING_IDENTITY="$(echo "$IDENTITIES" | grep 'Developer ID Application' | grep "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true)" + else + SIGNING_IDENTITY="$(echo "$IDENTITIES" | grep 'Developer ID Application' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true)" + fi +fi + +echo "📦 Packaging $APP_DISPLAY_NAME DMG..." + +if [[ "$RELEASE_DISTRIBUTION" == "1" ]]; then + REQUIRE_DEVELOPER_ID=1 "$SCRIPT_DIR/build.sh" +else + "$SCRIPT_DIR/build.sh" +fi + +if [[ ! -d "$APP_BUNDLE_PATH" ]]; then + echo "App bundle not found at $APP_BUNDLE_PATH" + exit 1 +fi + +rm -rf "$DMG_STAGING_DIR" +mkdir -p "$DMG_STAGING_DIR" + +echo "🧱 Preparing DMG staging folder..." +ditto "$APP_BUNDLE_PATH" "$DMG_STAGING_DIR/$APP_BUNDLE_NAME.app" +ln -s /Applications "$DMG_STAGING_DIR/Applications" +mkdir -p "$BACKGROUND_DIR" +swift "$APP_ROOT/scripts/generate_dmg_background.swift" "$BACKGROUND_IMAGE" + +rm -f "$DMG_PATH" "$RW_DMG_PATH" +if [[ -d "$DMG_MOUNTPOINT" ]]; then + hdiutil detach "$DMG_MOUNTPOINT" -force >/dev/null 2>&1 || true +fi + +echo "💽 Creating writable HFS+ DMG..." +# macOS 13+ 上 APFS DMG 的 Finder 元数据持久化不可靠,强制 HFS+。 +# macOS 26 起 hdiutil create -srcfolder 无法处理含签名 .app 的目录(EPERM), +# 改为:先建空 DMG → 挂载 → ditto 拷入文件,再走 AppleScript 布局。 +APP_SIZE_MB=$(du -sm "$APP_BUNDLE_PATH" | awk '{print $1}') +DMG_SIZE_MB=$(( APP_SIZE_MB + 32 )) + +rm -f "$RW_DMG_PATH" +hdiutil create \ + -volname "$DMG_VOLUME_NAME" \ + -size "${DMG_SIZE_MB}m" \ + -ov \ + -fs HFS+ \ + "$RW_DMG_PATH" + +echo "🪟 Applying drag-to-install layout..." +# 关键:不要 -noautoopen,否则 Finder 不会把这个卷加进 visible volume list, +# 后面 AppleScript 用 `tell disk "..."` 直接失败 -1728 (object not found) +hdiutil attach "$RW_DMG_PATH" -mountpoint "$DMG_MOUNTPOINT" -readwrite -noverify + +# 把文件拷进挂载好的卷(避免 -srcfolder 的 EPERM 限制) +ditto "$APP_BUNDLE_PATH" "$DMG_MOUNTPOINT/$APP_BUNDLE_NAME.app" +ln -sf /Applications "$DMG_MOUNTPOINT/Applications" +mkdir -p "$DMG_MOUNTPOINT/.background" +cp "$BACKGROUND_IMAGE" "$DMG_MOUNTPOINT/.background/InstallerBackground.png" + +# 隐藏 .background,不让用户在 Finder 看到目录 +chflags hidden "$DMG_MOUNTPOINT/.background" 2>/dev/null || true + +# 给 Finder 一点时间把卷加进它的内部 list +sleep 3 + +set +e +osascript 2>&1 </dev/null || true + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$MOUNTED_AGENT" + if [[ -f "$ENTITLEMENTS" ]]; then + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$MOUNTED_APP" + else + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$MOUNTED_APP" + echo "⚠️ Entitlements file not found at $ENTITLEMENTS — signed without entitlements." + fi + codesign --verify --deep --strict --verbose=2 "$MOUNTED_APP" +fi + +# 把元数据强制刷盘,再校验 .DS_Store 是否真写进去 +sync; sync; sync +sleep 3 +if [[ -f "$DMG_MOUNTPOINT/.DS_Store" ]]; then + echo "✅ .DS_Store written ($(stat -f%z "$DMG_MOUNTPOINT/.DS_Store") bytes) — installer layout will persist." +else + echo "⚠️ .DS_Store missing — Finder customization didn't persist. 检查 System Settings > Privacy > Automation 给 Terminal 授予 Finder 权限。" +fi + +hdiutil detach "$DMG_MOUNTPOINT" -force + +echo "🗜️ Converting DMG..." +hdiutil convert "$RW_DMG_PATH" -ov -format UDZO -o "$DMG_PATH" +rm -f "$RW_DMG_PATH" + +if [[ -n "$SIGNING_IDENTITY" ]]; then + echo "🔏 Signing DMG with: $SIGNING_IDENTITY" + codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH" +fi + +if [[ -n "$NOTARY_PROFILE" ]]; then + echo "🧾 Notarizing DMG with profile: $NOTARY_PROFILE" + xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait + xcrun stapler staple "$DMG_PATH" +elif [[ "$RELEASE_DISTRIBUTION" == "1" ]]; then + echo "❌ RELEASE_DISTRIBUTION=1 requires NOTARY_PROFILE." + echo " Create one with: xcrun notarytool store-credentials ..." + exit 1 +fi + +echo "🔎 Verifying DMG..." +hdiutil verify "$DMG_PATH" + +if [[ -n "$SIGNING_IDENTITY" ]]; then + echo "🔎 Assessing signed artifacts..." + spctl --assess --type open --context context:primary-signature -vv "$DMG_PATH" +fi + +echo "✅ DMG ready: $DMG_PATH" diff --git a/ahakeyconfig-ubuntu-java/pom.xml b/ahakeyconfig-ubuntu-java/pom.xml new file mode 100644 index 00000000..44687c3c --- /dev/null +++ b/ahakeyconfig-ubuntu-java/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + com.example + ahakey-studio + 1.0.0 + jar + + + 17 + 17.0.10 + ${java.version} + ${java.version} + UTF-8 + + + + + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-fxml + ${javafx.version} + + + org.openjfx + javafx-graphics + ${javafx.version} + + + + + com.fasterxml.jackson.core + jackson-databind + 2.16.0 + + + + + org.slf4j + slf4j-api + 2.0.9 + + + ch.qos.logback + logback-classic + 1.4.11 + + + ch.qos.logback + logback-core + 1.4.11 + + + + + com.microsoft.onnxruntime + onnxruntime + 1.16.3 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + com.example.ahakey.App + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + copy-resources + process-resources + + copy-resources + + + ${project.build.directory}/classes + + + src/main/resources + + **/* + + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.example.ahakey.App + true + lib/ + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.0 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + + \ No newline at end of file diff --git a/ahakeyconfig-ubuntu-java/src/main/java/com/example/ahakey/App.java b/ahakeyconfig-ubuntu-java/src/main/java/com/example/ahakey/App.java new file mode 100644 index 00000000..0e21c8f6 --- /dev/null +++ b/ahakeyconfig-ubuntu-java/src/main/java/com/example/ahakey/App.java @@ -0,0 +1,304 @@ +package com.example.ahakey; + +import com.example.ahakey.app.StudioController; +import com.example.ahakey.service.VoiceInputManager; +import com.example.ahakey.view.CanvasPane; +import com.example.ahakey.view.InspectorPane; +import com.example.ahakey.view.StatusBar; +import com.example.ahakey.view.TopBar; +import javafx.animation.PauseTransition; +import javafx.util.Duration; +import javafx.application.Application; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.Image; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.stage.Screen; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AhaKey Studio 主应用类 (Ubuntu/Linux 版本) + * + * JavaFX 桌面应用的入口,类比于 Java Web 中的 Servlet 或 Spring Boot 的 Application 类。 + * + * JavaFX 应用结构说明(类比 Web 开发): + * - Stage(舞台):相当于浏览器窗口,是整个应用的顶层容器 + * - Scene(场景):相当于 HTML 的 body,包含所有 UI 元素 + * - Pane(面板):相当于 HTML 的 div,用于布局管理 + * - Node(节点):相当于 HTML 的各种标签(button、label 等) + * + * 生命周期方法: + * 1. main() - 程序入口,调用 launch() 启动 JavaFX 运行时 + * 2. init() - 可选,在 start() 之前调用,用于初始化资源(本项目未使用) + * 3. start() - 核心方法,构建 UI 界面 + * 4. stop() - 可选,应用关闭时调用(本项目通过 setOnCloseRequest 处理) + */ +public class App extends Application { + + private static final Logger logger = LoggerFactory.getLogger(App.class); + + /** + * 主控制器,负责协调各个组件之间的通信 + * 类比于 Spring MVC 中的 Controller,管理应用的业务逻辑和状态 + */ + private StudioController controller; + + /** + * 语音输入管理器,负责管理语音识别和键盘注入功能 + */ + private VoiceInputManager voiceInputManager; + private boolean shuttingDown; + + private Stage primaryStage; + + /** + * JavaFX 应用的核心方法,负责初始化和显示主界面 + * @param primaryStage 主舞台(窗口),由 JavaFX 运行时自动创建 + */ + @Override + public void start(Stage primaryStage) { + this.primaryStage = primaryStage; + + // 1. 创建主控制器,作为整个应用的核心协调者 + controller = new StudioController(); + + // 2. 条件初始化语音输入管理器(根据 model.enabled 配置) + if (com.example.ahakey.config.ModelConfig.getInstance().isEnabled()) { + initVoiceInputManager(); + } else { + logger.info("本地模型已禁用 (model.enabled=false),跳过语音输入初始化"); + } + + // 3. 配置主窗口(Stage)属性 + primaryStage.setTitle("AhaKey Studio"); // 窗口标题 + primaryStage.setMinWidth(800); // 最小宽度(分屏/多屏适配) + primaryStage.setMinHeight(600); // 最小高度 + primaryStage.setWidth(1280); // 默认宽度 + primaryStage.setHeight(820); // 默认高度 + primaryStage.getIcons().add(new Image(getClass().getResourceAsStream("/ahakey.jpg"))); + + // 4. 创建根布局容器 + // BorderPane 是一个边界布局,分为五个区域:top、bottom、left、right、center + // 类比于 Web 中的
+
+
+ +
+
+
+
+ 1:1 数字孪生画布 + 点击硬件部位,右侧 Inspector 即时切换 +
+ +
+ 当前选中:Key 1 · 语音键 + Mode 0 · 官方默认编程模式 +
+
+ +
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+ +
+
+ Mode 摘要 + Mode 0 是官方默认 vibecoding 模式 +
+
+ OLED + 当前版本仅支持动图,信息型显示开发中 +
+
+ 灯条 + AI 运行中时显示来回流水灯 +
+
+ 拨杆 + 当前为自动批准 +
+
+
+ + +
+ +
+ 状态 有 3 项改动未同步 + 最近同步:今天 21:40 + 审批重点:看结构是否直观,不看最终像素 +
+ + + + + diff --git a/docs/release-distribution.md b/docs/release-distribution.md new file mode 100644 index 00000000..32d497da --- /dev/null +++ b/docs/release-distribution.md @@ -0,0 +1,127 @@ +# AhaKey Studio Release Distribution + +更新时间:2026-04-16 + +## 1. 目标 + +本文件定义 `AhaKey Studio` 的正式对外分发流程,目标产物为: + +- 已签名的 `AhaKey Studio.app` +- 已签名、已 notarize、已 staple 的 `AhaKey-Studio-macOS-prod-YYYYMMDDHHmmss.dmg`(默认带时间戳;可用 `DMG_BASENAME` 覆盖) + +适用场景: + +- 官网下载 +- 包装盒二维码下载 +- 发给购买硬件的终端用户 + +## 2. 当前仓库已支持的脚本 + +- 构建 `.app`:[ahakeyconfig-mac/scripts/build.sh](../ahakeyconfig-mac/scripts/build.sh) +- **构建可分发 `.dmg`(含公证)**:[ahakeyconfig-mac/scripts/pack-release.sh](../ahakeyconfig-mac/scripts/pack-release.sh) — 对内测试与对外发版可用同一套产物;内部调用 [ahakeyconfig-mac/scripts/package_dmg.sh](../ahakeyconfig-mac/scripts/package_dmg.sh) + +## 3. 你需要先准备的东西 + +正式分发前,必须先在当前 Mac 上准备: + +1. `Developer ID Application` 证书 +2. Apple Developer Program 团队权限 +3. `notarytool` 的 keychain profile + +### 3.1 Developer ID 证书 + +Apple 官方要求: + +- 代码、应用、磁盘镜像这类产物要用 `Developer ID Application` 证书签名 +- `pkg` 才使用 `Developer ID Installer` + +参考: + +- https://developer.apple.com/documentation/security/resolving-common-notarization-issues +- https://developer.apple.com/developer-id/ + +### 3.2 Notary 凭据 + +建议先在本机存一个 `notarytool` profile,例如: + +```bash +xcrun notarytool store-credentials "AhaKeyNotary" \ + --apple-id "<你的 Apple ID>" \ + --team-id "<你的 Team ID>" \ + --password "" +``` + +也可以用 App Store Connect API key 方案,但对当前首发准备来说,上面这条更直观。 + +参考: + +- https://developer.apple.com/documentation/security/customizing-the-notarization-workflow +- https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution + +## 4. 正式出包命令 + +当证书和 `notarytool` profile 都准备好后,执行: + +```bash +cd /path/to/desktop/ahakeyconfig-mac +zsh scripts/pack-release.sh +``` + +默认使用: + +- `NOTARY_PROFILE=AhaKeyNotary` +- 自动寻找本机第一个 `Developer ID Application` 证书 + +如果你要手动指定: + +```bash +cd /path/to/desktop/ahakeyconfig-mac +SIGNING_IDENTITY="Developer ID Application: ()" \ +NOTARY_PROFILE="AhaKeyNotary" \ +zsh scripts/pack-release.sh +``` + +## 5. 脚本会做什么 + +正式脚本会顺序完成: + +1. 用 release 配置构建 `AhaKeyConfig` 与 `ahakeyconfig-agent` +2. 组装 `AhaKey Studio.app` +3. 使用 `Developer ID Application` 签名 app 内主程序、helper、bundle +4. 创建 `AhaKey-Studio-macOS-prod-YYYYMMDDHHmmss.dmg` +5. 对 `dmg` 再做 Developer ID 签名 +6. 用 `notarytool` 提交 `dmg` +7. 对 `dmg` 执行 `stapler staple` +8. 对最终产物做 `hdiutil verify` + +## 6. 本地验收 + +正式出包完成后,至少检查: + +```bash +codesign --verify --deep --strict --verbose=2 "dist/AhaKey Studio.app" +spctl --assess -vv "dist/AhaKey Studio.app" +# 将路径换成 dist/ 里本次生成的 .dmg(例如 AhaKey-Studio-macOS-prod-20260427143000.dmg) +spctl --assess -vv "dist/<本次的>.dmg" +hdiutil verify "dist/<本次的>.dmg" +``` + +再做一轮人工体验: + +1. 双击挂载 `dmg` +2. 拖拽到 `Applications` +3. 启动 App +4. 连接键盘 +5. 做一次真实同步测试 + +## 7. 当前状态说明 + +若本机已具备 `Developer ID Application` 与 `notarytool` profile,执行 `pack-release.sh` 即可得到与终端用户一致的安装包;**测试场景也推荐直接安装该 DMG**,以便验证 Gatekeeper、公证与真实分发路径。 + +若仅有开发证书、不想走公证,可只做应用调试: + +```bash +zsh scripts/build.sh +``` + +这样会得到 `dist/AhaKey Studio.app`(无 DMG、无公证,不适合外发)。 diff --git a/fix-cursor-agent.sh b/fix-cursor-agent.sh new file mode 100755 index 00000000..ce68b585 --- /dev/null +++ b/fix-cursor-agent.sh @@ -0,0 +1,18 @@ +#!/bin/zsh + +# 修复 cursor-agent 配置 +echo '{"version": 1, "editor": {"vimMode": false}, "hasChangedDefaultModel": false, "permissions": {"allow": ["Shell(ls)"], "deny": []}, "approvalMode": "auto", "sandbox": {"mode": "disabled", "networkAccess": "user_config_with_defaults"}, "runEverythingSettingsPromptStreak": 0, "network": {"useHttp1ForAgent": false}, "attribution": {"attributeCommitsToAgent": true, "attributePRsToAgent": true}}' > ~/.cursor/cli-config.json + +echo "已修改 approvalMode 为 auto" +cat ~/.cursor/cli-config.json | grep "approvalMode" + +# 测试 cursor-agent 是否会自动批准命令 +echo "\n测试 cursor-agent 自动批准:" +cat > test-prompt.txt << 'EOF' +请执行 python3 helloworld.py +EOF + +# 使用 --force 选项测试 +cursor-agent --force --print < test-prompt.txt + +rm test-prompt.txt diff --git a/fix-cursor-config.sh b/fix-cursor-config.sh new file mode 100755 index 00000000..fcc87452 --- /dev/null +++ b/fix-cursor-config.sh @@ -0,0 +1,10 @@ +#!/bin/zsh + +# 备份原始配置 +cp ~/.cursor/cli-config.json ~/.cursor/cli-config.json.bak + +# 修改 approvalMode 为 prompt(会提示但使用 hook 结果) +sed -i '' 's/"approvalMode": "allowlist"/"approvalMode": "prompt"/g' ~/.cursor/cli-config.json + +echo "已修改 approvalMode 为 prompt" +cat ~/.cursor/cli-config.json | grep -A 5 "approvalMode" diff --git a/fix-cursor-permissions.sh b/fix-cursor-permissions.sh new file mode 100755 index 00000000..e93caf5d --- /dev/null +++ b/fix-cursor-permissions.sh @@ -0,0 +1,13 @@ +#!/bin/zsh + +# 修复 cursor-agent 权限配置 +echo '{"version": 1, "editor": {"vimMode": false}, "hasChangedDefaultModel": false, "permissions": {"allow": ["Shell(ls)", "Shell(python3)"], "deny": []}, "approvalMode": "auto", "sandbox": {"mode": "disabled", "networkAccess": "user_config_with_defaults"}, "runEverythingSettingsPromptStreak": 0, "network": {"useHttp1ForAgent": false}, "attribution": {"attributeCommitsToAgent": true, "attributePRsToAgent": true}}' > ~/.cursor/cli-config.json + +echo "已将 python3 添加到允许列表" +cat ~/.cursor/cli-config.json | grep -A 5 "permissions" + +# 测试 cursor-agent 是否会自动批准 python3 命令 +echo "\n测试 python3 自动批准:" +cursor-agent --force --print << 'EOF' +请执行 python3 /Users/cjn/Desktop/keyboard1/ahakeyconfig/helloworld.py +EOF diff --git a/fix-hooks-format.sh b/fix-hooks-format.sh new file mode 100755 index 00000000..7da8503c --- /dev/null +++ b/fix-hooks-format.sh @@ -0,0 +1,17 @@ +#!/bin/zsh + +# 修复 hooks.json 格式为正确的对象格式 +echo '{"version": 1, "hooks": {"preToolUse": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "preToolUse"], "enableOnStartup": true}, "beforeShellExecution": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "beforeShellExecution"], "enableOnStartup": true}, "beforeMCPExecution": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "beforeMCPExecution"], "enableOnStartup": true}, "postToolUse": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "postToolUse"], "enableOnStartup": true}, "sessionStart": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "sessionStart"], "enableOnStartup": true}, "sessionEnd": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "sessionEnd"], "enableOnStartup": true}, "stop": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "stop"], "enableOnStartup": true}}}' > ~/.cursor/hooks.json + +echo "已修复 hooks.json 格式" +cat ~/.cursor/hooks.json + +# 创建诊断目录 +mkdir -p ~/Library/Application Support/AhaKeyConfig/diagnostics + +echo "\n测试 hook 执行:" +echo '{"command":"python3 /Users/cjn/Desktop/keyboard1/ahakeyconfig/helloworld.py"}' | "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent" hook beforeShellExecution + +# 检查日志文件 +echo "\n检查诊断日志:" +ls -la ~/Library/Application Support/AhaKeyConfig/diagnostics/ diff --git a/fix-hooks.sh b/fix-hooks.sh new file mode 100755 index 00000000..f4c8b504 --- /dev/null +++ b/fix-hooks.sh @@ -0,0 +1,7 @@ +#!/bin/zsh + +# 修复 hooks.json 配置 +echo '{"version": 1, "hooks": {"preToolUse": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "preToolUse"], "enableOnStartup": true}, "beforeShellExecution": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "beforeShellExecution"], "enableOnStartup": true}, "beforeMCPExecution": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "beforeMCPExecution"], "enableOnStartup": true}, "postToolUse": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "postToolUse"], "enableOnStartup": true}, "sessionStart": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "sessionStart"], "enableOnStartup": true}, "sessionEnd": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "sessionEnd"], "enableOnStartup": true}, "stop": {"command": "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent", "args": ["hook", "stop"], "enableOnStartup": true}}}' > ~/.cursor/hooks.json + +echo "已修复 hooks.json 配置" +cat ~/.cursor/hooks.json diff --git a/platforms/macos/Resources/DefaultOLED/cursor_0.gif b/platforms/macos/Resources/DefaultOLED/cursor_0.gif deleted file mode 100644 index 8e16d0b9..00000000 Binary files a/platforms/macos/Resources/DefaultOLED/cursor_0.gif and /dev/null differ diff --git a/platforms/macos/Sources/Agent/HookClient.swift b/platforms/macos/Sources/Agent/HookClient.swift deleted file mode 100644 index 15752074..00000000 --- a/platforms/macos/Sources/Agent/HookClient.swift +++ /dev/null @@ -1,548 +0,0 @@ -import Foundation - -/// Claude Code / Cursor / Codex hook 客户端 -/// -/// 作为 `ahakeyconfig-agent hook ` 子命令运行,被 IDE exec。 -/// 通过 Unix socket 把事件通知到常驻 agent daemon,并在 **工具批准** 类场景下 -/// 根据键盘拨杆状态向 stdout 输出各 IDE 所需的决策 JSON。 -/// -/// - Claude Code `PermissionRequest`:见 Apple hook 输出示例(`hookSpecificOutput`…)。 -/// - Cursor `preToolUse` / `beforeShellExecution` / `beforeMCPExecution`:stdout 为 -/// `{ "permission": "allow" | "deny" | "ask", ... }`(见 Cursor Hooks 文档)。 -/// 拨杆为 0 时还会同步 `~/.cursor/cli-config.json`(CLI 权限)与 `~/.cursor/permissions.json` 的 `terminalAllowlist`(IDE TUI 白名单,二者分离;非 0 时恢复快照)。 -/// - Codex `PermissionRequest`:Codex 0.125 inline TOML hooks 需要 stdout JSON;自动档输出 -/// `behavior=allow`,手动档只回 hookEventName,让 Codex 继续走自己的确认链(Codex 不支持 Claude 的 `ask`)。 -enum HookClient { - /// 与 LED / 协议 `sendState` 对应;批准类查询统一用 `permissionLedValue`。 - private static let permissionLedValue: UInt8 = 1 - - private enum EventMode { - /// 只发 `cmd: state`(无关批准)。 - case fireAndForgetState(UInt8) - /// Claude:`PermissionRequest` → `hookSpecificOutput` + 拨杆。 - case claudePermissionRequest - /// Cursor:从 stdin 读 JSON,stdout 回 `permission` 字段 + 拨杆。 - case cursorToolPermission - /// Codex:发状态并输出空 JSON,保持 command hook 输出合法。 - case codexState(UInt8) - /// Codex:`PermissionRequest` → 自动档 allow;手动档交回 Codex。 - case codexPermissionRequest - } - - private static let eventMap: [String: EventMode] = [ - "Notification": .fireAndForgetState(0), - "PermissionRequest": .claudePermissionRequest, - "PostToolUse": .fireAndForgetState(2), - "PreToolUse": .fireAndForgetState(3), - "SessionStart": .fireAndForgetState(4), - "Stop": .fireAndForgetState(5), - "TaskCompleted": .fireAndForgetState(6), - "UserPromptSubmit": .fireAndForgetState(7), - "SessionEnd": .fireAndForgetState(8), - - "sessionStart": .fireAndForgetState(4), - "sessionEnd": .fireAndForgetState(8), - "postToolUse": .fireAndForgetState(2), - "stop": .fireAndForgetState(5), - "preToolUse": .cursorToolPermission, - "beforeShellExecution": .cursorToolPermission, - "beforeMCPExecution": .cursorToolPermission, - - "CodexSessionStart": .codexState(4), - "CodexPostToolUse": .codexState(2), - "CodexPreToolUse": .codexState(3), - "CodexUserPromptSubmit": .codexState(7), - "CodexStop": .codexState(5), - "CodexPermissionRequest": .codexPermissionRequest, - ] - - private static let socketPath = "/tmp/ahakey.sock" - private static let stateRequestTimeout: Double = 2.0 - /// 读拨杆 + BLE 可能略慢,批准路径单独放宽。 - private static let permissionRequestTimeout: Double = 5.0 - - /// 诊断日志 `ts`:本地时区,`HH` 为 24 小时制(`en_US_POSIX` 避免随地区变成 12 小时制)。 - private static let diagnosticTimestampFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone.current - f.dateFormat = "yyyy-MM-dd HH:mm:ss" - return f - }() - - /// 返回进程退出码。Hook 子进程以 0 表示成功;Cursor 上 exit 2 等同 deny(我们优先 stdout JSON)。 - static func run(event: String) -> Int32 { - signal(SIGPIPE, SIG_IGN) - guard let mode = eventMap[event] else { - FileHandle.standardError.write( - Data("[ahakey-hook] unknown event: \(event)\n".utf8) - ) - return 0 - } - - switch mode { - case .fireAndForgetState(let v): - handleFireAndForgetState(stateValue: v) - case .claudePermissionRequest: - handleClaudePermissionRequest() - case .cursorToolPermission: - handleCursorToolPermission(hookEvent: event) - case .codexState(let v): - handleCodexState(stateValue: v) - case .codexPermissionRequest: - handleCodexPermissionRequest() - } - return 0 - } - - // MARK: - Event handlers - - private static func handleFireAndForgetState(stateValue: UInt8) { - let request: [String: Any] = ["cmd": "state", "value": Int(stateValue)] - _ = sendJsonRequest(request, timeout: stateRequestTimeout) - } - - private static func handleCodexState(stateValue: UInt8) { - let stdinData = readAllStdinSilently() - let ctx = parseStdinContext(stdinData, label: "Codex") - let request: [String: Any] = ["cmd": "state", "value": Int(stateValue)] - let reply = sendJsonRequest(request, timeout: stateRequestTimeout) - appendCodexHookLog( - hookEvent: ctx["hook_event_name"] as? String, - agentEvent: codexAgentEventName(forStateValue: stateValue), - stateValue: stateValue, - toolContext: ctx, - reply: reply, - switchState: intValue(reply?["switchState"]), - decision: nil - ) - print("{}") - } - - // MARK: Claude PermissionRequest - - private static func handleClaudePermissionRequest() { - let stdinData = readAllStdinSilently() - let ctx = parseStdinContext(stdinData, label: "Claude") - let request: [String: Any] = ["cmd": "permission", "value": Int(permissionLedValue)] - let reply = sendJsonRequest(request, timeout: permissionRequestTimeout) - let switchState = intValue(reply?["switchState"]) - let isAuto = switchState == 0 - let behavior: String - if isAuto { - behavior = "allow" - } else { - emitPermissionStderr( - ide: "Claude", hookName: "PermissionRequest", - reply: reply, switchState: switchState - ) - behavior = "ask" - } - - let out: [String: Any] = [ - "hookSpecificOutput": [ - "hookEventName": "PermissionRequest", - "decision": ["behavior": behavior], - ], - ] - if let data = try? JSONSerialization.data(withJSONObject: out, options: []), - let str = String(data: data, encoding: .utf8) { - print(str) - } - - appendDiagnostic( - ide: "claude", hookEvent: "PermissionRequest", - toolContext: ctx, - reply: reply, switchState: switchState, isAuto: isAuto, - claudeBehavior: behavior, cursorPermission: nil, - cursorDebug: nil - ) - } - - // MARK: Cursor preToolUse / beforeShell* / beforeMCP* - - private static func handleCursorToolPermission(hookEvent: String) { - let stdinData = readAllStdinSilently() - let ctx = parseStdinContext(stdinData, label: "Cursor") - let request: [String: Any] = ["cmd": "permission", "value": Int(permissionLedValue)] - let reply = sendJsonRequest(request, timeout: permissionRequestTimeout) - let switchState = intValue(reply?["switchState"]) - let isAuto = switchState == 0 - let perm: String - if isAuto { - perm = "allow" - } else { - emitPermissionStderr( - ide: "Cursor", hookName: hookEvent, - reply: reply, switchState: switchState - ) - perm = "ask" - } - - // 仅当能读到有效拨杆时:① cli-config(CLI 权限)② permissions.json 的 terminalAllowlist(IDE TUI「Not in allowlist」层,与 cli-config 不共用)。 - if let s = switchState { - let auto = s == 0 - CursorCliLeverSync.apply(switchStateAuto: auto) - CursorPermissionsJsonLeverSync.apply(switchStateAuto: auto) - } - - let out: [String: Any] = [ - "permission": perm, - ] - if let data = try? JSONSerialization.data(withJSONObject: out, options: []), - let str = String(data: data, encoding: .utf8) { - // 单行 JSON,与 Cursor 示例一致 - print(str) - } - - let cursorDebug = buildCursorHookDebug( - stdinData: stdinData, - commandPreview: ctx["commandPreview"] as? String - ) - appendDiagnostic( - ide: "cursor", hookEvent: hookEvent, - toolContext: ctx, - reply: reply, switchState: switchState, isAuto: isAuto, - claudeBehavior: nil, cursorPermission: perm, - cursorDebug: cursorDebug - ) - } - - // MARK: Codex PermissionRequest - - private static func handleCodexPermissionRequest() { - let stdinData = readAllStdinSilently() - let ctx = parseStdinContext(stdinData, label: "Codex") - let request: [String: Any] = ["cmd": "permission", "value": Int(permissionLedValue)] - let reply = sendJsonRequest(request, timeout: permissionRequestTimeout) - let switchState = intValue(reply?["switchState"]) - let isAuto = switchState == 0 - - if !isAuto { - emitPermissionStderr( - ide: "Codex", hookName: "PermissionRequest", - reply: reply, switchState: switchState - ) - } - - var hookOut: [String: Any] = [ - "hookEventName": "PermissionRequest", - ] - if isAuto { - hookOut["decision"] = ["behavior": "allow"] - } - appendCodexHookLog( - hookEvent: "PermissionRequest", - agentEvent: "CodexPermissionRequest", - stateValue: permissionLedValue, - toolContext: ctx, - reply: reply, - switchState: switchState, - decision: isAuto ? "allow" : "pass_through" - ) - let out: [String: Any] = [ - "hookSpecificOutput": hookOut, - ] - if let data = try? JSONSerialization.data(withJSONObject: out, options: []), - let str = String(data: data, encoding: .utf8) { - print(str) - } - - appendDiagnostic( - ide: "codex", hookEvent: "PermissionRequest", - toolContext: ctx, - reply: reply, switchState: switchState, isAuto: isAuto, - claudeBehavior: nil, cursorPermission: nil, - cursorDebug: nil - ) - } - - private static func emitPermissionStderr( - ide: String, hookName: String, - reply: [String: Any]?, switchState: Int? - ) { - if switchState == nil, reply == nil { - let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(/tmp/ahakey.sock 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。" - + "请确认 LaunchAgent 里 ahakeyconfig-agent 在跑、且蓝牙已选「由 Agent 占用」并连上键盘。\n" - FileHandle.standardError.write(Data(msg.utf8)) - } else if switchState == nil, reply != nil { - let msg = "[ahakey-hook] \(ide) \(hookName): 回包无有效 switchState(需 BLE 已连且能读到拨杆 0=自动批准),将按交回用户/终端处理。\n" - FileHandle.standardError.write(Data(msg.utf8)) - } else if let s = switchState, s != 0 { - let msg = "[ahakey-hook] \(ide) \(hookName): 拨杆 switchState=\(s)(非 0),不自动批准。\n" - FileHandle.standardError.write(Data(msg.utf8)) - } - } - - private static func codexAgentEventName(forStateValue stateValue: UInt8) -> String { - switch stateValue { - case 2: return "CodexPostToolUse" - case 3: return "CodexPreToolUse" - case 4: return "CodexSessionStart" - case 5: return "CodexStop" - case 7: return "CodexUserPromptSubmit" - default: return "CodexState\(stateValue)" - } - } - - private static func appendCodexHookLog( - hookEvent: String?, - agentEvent: String, - stateValue: UInt8, - toolContext: [String: Any], - reply: [String: Any]?, - switchState: Int?, - decision: String? - ) { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) - let fileURL = dir.appendingPathComponent("codex-hook.log") - - var lineObj: [String: Any] = [ - "ts": diagnosticTimestampFormatter.string(from: Date()), - "agentEvent": agentEvent, - "hookEvent": hookEvent ?? NSNull(), - "stateValue": Int(stateValue), - "agentReply": reply == nil ? false : true, - "switchState": switchState.map { $0 } ?? NSNull(), - "tool": toolContext, - ] - if let decision { lineObj["decision"] = decision } - - guard let data = try? JSONSerialization.data(withJSONObject: lineObj, options: []), - var line = String(data: data, encoding: .utf8) else { return } - line += "\n" - appendLine(line, to: fileURL) - } - - /// 从各 IDE 经 stdin 传入的 JSON 里取可安全写入日志的短文本(不记录大段 tool_input 以免泄密)。 - private static func parseStdinContext(_ data: Data, label: String) -> [String: Any] { - var out: [String: Any] = [ - "stdinBytes": data.count, - "parser": label, - ] - guard !data.isEmpty, - let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { - return out - } - if let t = obj["tool_name"] as? String { - out["tool_name"] = t - } - if let c = obj["command"] as? String { - out["commandPreview"] = String(c.prefix(120)) - } - if out["tool_name"] == nil, let t = obj["name"] as? String { - out["name"] = t - } - return out - } - - // MARK: Cursor 诊断块(permission-request.log → `cursorDebug`) - - private static let cursorStdinLogKeys: [String] = [ - "cursorVersion", "cursor_version", "appVersion", "app_version", "version", - "workspacePath", "workspace_path", "workspaceFolders", "workspace_folders", "workspaceRoot", - "cwd", "root", "shell", "shell_type", "sessionId", "conversation_id", - ] - - private static func buildCursorHookDebug(stdinData: Data, commandPreview: String?) -> [String: Any] { - var out: [String: Any] = [ - "userCliConfig": CursorCliLeverSync.diagnosticSnapshotForLog(), - "userPermissionsJson": CursorPermissionsJsonLeverSync.diagnosticSnapshotForLog(), - "note": "IDE「Not in allowlist」用 ~/.cursor/permissions.json 的 terminalAllowlist,与 userCliConfig(cli-config=CLI)分离;见 cursor.com/docs/reference/permissions", - "stdinFields": cursorStdinDebugFields(stdinData), - "processEnvCursorVscode": cursorRelatedEnvForLog(), - ] - if let cd = inferredCdPathFromShellCommand(commandPreview) { - out["inferredCdPath"] = cd - let proj = (cd as NSString).appendingPathComponent(".cursor/cli.json") - out["projectCliJsonPath"] = proj - out["projectCliJsonExists"] = FileManager.default.fileExists(atPath: proj) - } - return out - } - - private static func cursorStdinDebugFields(_ data: Data) -> [String: Any] { - guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { - return ["parse": "empty_or_invalid_json"] - } - var o: [String: Any] = [:] - for k in cursorStdinLogKeys { - guard let v = obj[k] else { continue } - o[k] = stringifyDebugValue(v, maxLen: 220) - } - if o.isEmpty { o["note"] = "no_whitelisted_keys_in_stdin" } - return o - } - - private static func stringifyDebugValue(_ v: Any, maxLen: Int) -> String { - if let s = v as? String { return String(s.prefix(maxLen)) } - if let n = v as? NSNumber { return n.stringValue } - if let a = v as? [String] { - return String(a.prefix(12).joined(separator: ", ").prefix(maxLen)) - } - return String(String(describing: v).prefix(maxLen)) - } - - private static func cursorRelatedEnvForLog() -> [String: Any] { - var o: [String: Any] = [:] - for (k, v) in ProcessInfo.processInfo.environment.sorted(by: { $0.key < $1.key }) { - let ku = k.uppercased() - guard ku.contains("CURSOR") || ku.hasPrefix("VSCODE_") || ku == "TERM" else { continue } - o[k] = String(v.prefix(200)) - if o.count >= 32 { break } - } - return o - } - - /// 从 `cd /a/b && ...` 取首段目录,用于判断项目下 `.cursor/cli.json` 是否存在。 - private static func inferredCdPathFromShellCommand(_ command: String?) -> String? { - guard let raw = command?.trimmingCharacters(in: .whitespacesAndNewlines), - raw.hasPrefix("cd ") else { return nil } - var rest = String(raw.dropFirst(3)).trimmingCharacters(in: .whitespaces) - if let r = rest.range(of: " && ") { rest = String(rest[.. Data { - let handle = FileHandle.standardInput - return (try? handle.readToEnd()) ?? Data() - } - - // MARK: - Unix socket client - - private static func sendJsonRequest(_ dict: [String: Any], timeout: Double) -> [String: Any]? { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - defer { close(fd) } - - var tv = timeval( - tv_sec: __darwin_time_t(timeout), - tv_usec: suseconds_t((timeout - Double(Int(timeout))) * 1_000_000) - ) - 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) { sunPath in - let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) - _ = strcpy(dst, src) - } - } - let addrLen = socklen_t(MemoryLayout.size) - let connected = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(fd, $0, addrLen) - } - } - guard connected == 0 else { return nil } - - guard var payload = try? JSONSerialization.data(withJSONObject: dict, options: []) else { - return nil - } - payload.append(0x0A) - let wrote = payload.withUnsafeBytes { ptr -> Int in - guard let base = ptr.baseAddress else { return -1 } - return write(fd, base, ptr.count) - } - guard wrote >= 0 else { return nil } - - var buf = [UInt8](repeating: 0, count: 1024 * 4) - let n = read(fd, &buf, buf.count) - guard n > 0 else { return nil } - let slice = Data(buf[0 ..< Int(n)]) - return (try? JSONSerialization.jsonObject(with: slice)) as? [String: Any] - } - - private static func intValue(_ v: Any?) -> Int? { - switch v { - case let i as Int: - return i - case let n as NSNumber: - return n.intValue - case let d as Double: - return Int(d) - case let s as String: - return Int(s) - default: - return nil - } - } -} diff --git a/platforms/macos/Sources/Views/ContentView.swift b/platforms/macos/Sources/Views/ContentView.swift deleted file mode 100644 index 62a96200..00000000 --- a/platforms/macos/Sources/Views/ContentView.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @ObservedObject var bleManager: AhaKeyBLEManager - - var body: some View { - if #available(macOS 14.0, *) { - AhaKeyStudioView(bleManager: bleManager) - .focusEffectDisabled() - } else { - AhaKeyStudioView(bleManager: bleManager) - } - } -} diff --git a/platforms/macos/scripts/generate_dmg_background.swift b/platforms/macos/scripts/generate_dmg_background.swift deleted file mode 100644 index cbd4605b..00000000 --- a/platforms/macos/scripts/generate_dmg_background.swift +++ /dev/null @@ -1,128 +0,0 @@ -import AppKit - -let arguments = CommandLine.arguments -guard arguments.count >= 2 else { - fputs("usage: swift generate_dmg_background.swift \n", stderr) - exit(1) -} - -let outputURL = URL(fileURLWithPath: arguments[1]) -let width: CGFloat = 1200 -let height: CGFloat = 760 - -let image = NSImage(size: NSSize(width: width, height: height)) -image.lockFocus() - -let bounds = NSRect(x: 0, y: 0, width: width, height: height) - -let background = NSGradient(colors: [ - NSColor(calibratedRed: 0.985, green: 0.99, blue: 1.0, alpha: 1.0), - NSColor(calibratedRed: 0.945, green: 0.965, blue: 0.995, alpha: 1.0), -])! -background.draw(in: bounds, angle: -18) - -let panelRect = NSRect(x: 54, y: 68, width: 1092, height: 610) -let panelPath = NSBezierPath(roundedRect: panelRect, xRadius: 36, yRadius: 36) -NSColor.white.withAlphaComponent(0.86).setFill() -panelPath.fill() -NSColor(calibratedWhite: 0.88, alpha: 1).setStroke() -panelPath.lineWidth = 1 -panelPath.stroke() - -let titleStyle = NSMutableParagraphStyle() -titleStyle.alignment = .center -let titleAttributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 34, weight: .semibold), - .foregroundColor: NSColor(calibratedWhite: 0.16, alpha: 1), - .paragraphStyle: titleStyle, -] -"拖动 AhaKey Studio 到 Applications".draw( - in: NSRect(x: 180, y: 616, width: 840, height: 42), - withAttributes: titleAttributes -) - -let subtitleAttributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 18, weight: .medium), - .foregroundColor: NSColor(calibratedWhite: 0.45, alpha: 1), - .paragraphStyle: titleStyle, -] -"安装完成后,就能像普通 Mac 应用一样打开使用".draw( - in: NSRect(x: 210, y: 584, width: 780, height: 24), - withAttributes: subtitleAttributes -) - -let accentBlue = NSColor(calibratedRed: 0.22, green: 0.52, blue: 0.95, alpha: 1) - -func drawDropZone(_ rect: NSRect, label: String, accent: NSColor) { - let zone = NSBezierPath(roundedRect: rect, xRadius: 28, yRadius: 28) - accent.withAlphaComponent(0.08).setFill() - zone.fill() - - let dashPattern: [CGFloat] = [10, 10] - zone.setLineDash(dashPattern, count: dashPattern.count, phase: 0) - accent.withAlphaComponent(0.35).setStroke() - zone.lineWidth = 3 - zone.stroke() - - let labelStyle = NSMutableParagraphStyle() - labelStyle.alignment = .center - let labelAttributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 22, weight: .semibold), - .foregroundColor: NSColor(calibratedWhite: 0.26, alpha: 1), - .paragraphStyle: labelStyle, - ] - label.draw(in: NSRect(x: rect.minX, y: rect.maxY + 14, width: rect.width, height: 28), withAttributes: labelAttributes) -} - -let appZone = NSRect(x: 110, y: 224, width: 230, height: 230) -let appTargetZone = NSRect(x: 852, y: 224, width: 230, height: 230) -drawDropZone(appZone, label: "把 App 从这里拖出", accent: accentBlue) -drawDropZone(appTargetZone, label: "拖到这里完成安装", accent: accentBlue) - -let arrow = NSBezierPath() -arrow.move(to: NSPoint(x: 376, y: 336)) -arrow.line(to: NSPoint(x: 822, y: 336)) -arrow.lineWidth = 10 -arrow.lineCapStyle = .round -accentBlue.setStroke() -arrow.stroke() - -let arrowHead = NSBezierPath() -arrowHead.move(to: NSPoint(x: 820, y: 336)) -arrowHead.line(to: NSPoint(x: 776, y: 366)) -arrowHead.move(to: NSPoint(x: 820, y: 336)) -arrowHead.line(to: NSPoint(x: 776, y: 306)) -arrowHead.lineWidth = 10 -arrowHead.lineCapStyle = .round -accentBlue.setStroke() -arrowHead.stroke() - -let arrowStyle = NSMutableParagraphStyle() -arrowStyle.alignment = .center -let arrowAttributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 20, weight: .semibold), - .foregroundColor: accentBlue, - .paragraphStyle: arrowStyle, -] -"拖过去安装".draw(in: NSRect(x: 436, y: 368, width: 320, height: 26), withAttributes: arrowAttributes) - -let footerAttributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 18, weight: .medium), - .foregroundColor: NSColor(calibratedWhite: 0.46, alpha: 1), - .paragraphStyle: titleStyle, -] -"打开镜像后,直接把左边的 App 图标拖到右边的 Applications 图标".draw( - in: NSRect(x: 180, y: 118, width: 840, height: 24), - withAttributes: footerAttributes -) - -image.unlockFocus() - -guard let tiff = image.tiffRepresentation, - let rep = NSBitmapImageRep(data: tiff), - let png = rep.representation(using: .png, properties: [:]) else { - fputs("failed to render png background\n", stderr) - exit(1) -} - -try png.write(to: outputURL) diff --git a/platforms/macos/scripts/generate_icons.swift b/platforms/macos/scripts/generate_icons.swift deleted file mode 100644 index d1ead413..00000000 --- a/platforms/macos/scripts/generate_icons.swift +++ /dev/null @@ -1,149 +0,0 @@ -import AppKit -import Foundation - -let arguments = CommandLine.arguments -guard arguments.count >= 2 else { - fputs("usage: swift generate_icons.swift [source-image]\n", stderr) - exit(1) -} - -let outputDirectory = URL(fileURLWithPath: arguments[1], isDirectory: true) -let sourceImageURL = arguments.count >= 3 ? URL(fileURLWithPath: arguments[2]) : nil -let fileManager = FileManager.default - -try? fileManager.removeItem(at: outputDirectory) -try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) - -let sizes: [(Int, String)] = [ - (16, "icon_16x16.png"), - (32, "icon_16x16@2x.png"), - (32, "icon_32x32.png"), - (64, "icon_32x32@2x.png"), - (128, "icon_128x128.png"), - (256, "icon_128x128@2x.png"), - (256, "icon_256x256.png"), - (512, "icon_256x256@2x.png"), - (512, "icon_512x512.png"), - (1024, "icon_512x512@2x.png") -] - -let sourceImage = sourceImageURL.flatMap { NSImage(contentsOf: $0) } - -for (size, filename) in sizes { - let image: NSImage - if let sourceImage { - image = renderSourceIcon(sourceImage, size: CGFloat(size)) - } else { - image = makeDockIcon(size: CGFloat(size)) - } - let destination = outputDirectory.appendingPathComponent(filename) - try pngData(from: image).write(to: destination) -} - -func renderSourceIcon(_ source: NSImage, size: CGFloat) -> NSImage { - let image = NSImage(size: NSSize(width: size, height: size)) - image.lockFocus() - NSGraphicsContext.current?.imageInterpolation = .high - - let rect = NSRect(origin: .zero, size: image.size) - source.draw(in: rect, from: .zero, operation: .copy, fraction: 1.0) - - image.unlockFocus() - return image -} - -func makeDockIcon(size: CGFloat) -> NSImage { - let image = NSImage(size: NSSize(width: size, height: size)) - image.lockFocus() - - NSGraphicsContext.current?.imageInterpolation = .high - let rect = NSRect(origin: .zero, size: image.size) - let r = size * 0.22 - - // 渐变背景——和 EchoWrite 风格相近的冷色调 - let bg = NSBezierPath(roundedRect: rect, xRadius: r, yRadius: r) - NSGradient( - colors: [ - NSColor(calibratedRed: 0.88, green: 0.93, blue: 0.96, alpha: 1), - NSColor(calibratedRed: 0.78, green: 0.86, blue: 0.92, alpha: 1) - ] - )?.draw(in: bg, angle: 90) - - // 柔和光晕 - drawBlurBlob( - color: NSColor(calibratedRed: 0.40, green: 0.65, blue: 0.95, alpha: 0.25), - rect: NSRect(x: size * 0.30, y: size * 0.35, width: size * 0.40, height: size * 0.35), - blur: size * 0.10 - ) - - // 中央:简约键盘轮廓(圆角矩形 + 4 个小方块) - let kbW = size * 0.56 - let kbH = size * 0.30 - let kbX = (size - kbW) / 2 - let kbY = size * 0.38 - - let kbRect = NSRect(x: kbX, y: kbY, width: kbW, height: kbH) - let kb = NSBezierPath(roundedRect: kbRect, xRadius: size * 0.04, yRadius: size * 0.04) - NSColor.white.withAlphaComponent(0.75).setFill() - kb.fill() - NSColor.white.withAlphaComponent(0.50).setStroke() - kb.lineWidth = max(1, size * 0.005) - kb.stroke() - - // 4 个按键 - let keyInset = size * 0.03 - let keyGap = size * 0.02 - let innerW = kbW - keyInset * 2 - let keyW = (innerW - keyGap * 3) / 4 - let keyH = kbH - keyInset * 2 - for i in 0..<4 { - let x = kbX + keyInset + CGFloat(i) * (keyW + keyGap) - let y = kbY + keyInset - let keyRect = NSRect(x: x, y: y, width: keyW, height: keyH) - let key = NSBezierPath(roundedRect: keyRect, xRadius: size * 0.015, yRadius: size * 0.015) - - // 第一个键(麦克风)用强调色 - if i == 0 { - NSColor(calibratedRed: 0.30, green: 0.55, blue: 0.90, alpha: 0.85).setFill() - } else { - NSColor(calibratedWhite: 0.92, alpha: 0.90).setFill() - } - key.fill() - } - - // 麦克风键上画一个小圆点 - let dotSize = size * 0.03 - let firstKeyX = kbX + keyInset - let firstKeyY = kbY + keyInset - let dotRect = NSRect( - x: firstKeyX + (keyW - dotSize) / 2, - y: firstKeyY + (keyH - dotSize) / 2, - width: dotSize, - height: dotSize - ) - NSColor.white.withAlphaComponent(0.95).setFill() - NSBezierPath(ovalIn: dotRect).fill() - - image.unlockFocus() - return image -} - -func drawBlurBlob(color: NSColor, rect: NSRect, blur: CGFloat) { - let shadow = NSShadow() - shadow.shadowBlurRadius = blur - shadow.shadowColor = color - shadow.shadowOffset = .zero - shadow.set() - let blob = NSBezierPath(ovalIn: rect) - color.setFill() - blob.fill() -} - -func pngData(from image: NSImage) throws -> Data { - guard let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let png = bitmap.representation(using: .png, properties: [:]) else { - throw NSError(domain: "IconGeneration", code: 1) - } - return png -} diff --git a/releases/README.md b/releases/README.md deleted file mode 100644 index 8c72f9df..00000000 --- a/releases/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Releases - -## 这个目录是什么 - -发布说明与占位目录。 - -## 当前包含什么 - -- 当前仅保留 README,占位使用。 - -## 当前不包含什么 - -- `exe` -- `msi` -- `dmg` -- 打包目录 -- 任何安装包二进制 - -## 如何构建 - -当前无独立构建内容。 - -## GitHub Releases 对应发布物 - -真正的安装包与发布二进制应上传到 GitHub Releases,而不是保存在本目录中。 diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 1c35c840..00000000 --- a/scripts/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Scripts - -## 这个目录是什么 - -仓库级共享脚本目录。 - -## 当前包含什么 - -- 当前仅保留 README,占位使用。 - -## 当前不包含什么 - -- Windows 专属历史打包脚本 -- 安装包组装目录 -- 本地临时脚本 - -## 如何构建 - -当前无独立构建内容。 - -## GitHub Releases 对应发布物 - -当前没有直接对应发布物。这里未来只存放仓库级共享脚本源码。 diff --git a/platforms/macos/scripts/build.local.env b/scripts/build.local.env similarity index 100% rename from platforms/macos/scripts/build.local.env rename to scripts/build.local.env diff --git a/platforms/macos/scripts/build.local.env.example b/scripts/build.local.env.example similarity index 100% rename from platforms/macos/scripts/build.local.env.example rename to scripts/build.local.env.example diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 00000000..24249c76 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,234 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +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}" +BUILD_ARCHS="${BUILD_ARCHS:-arm64 x86_64}" +OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" +APP_BUNDLE="$OUTPUT_DIR/$APP_BUNDLE_NAME.app" +APP_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/$EXECUTABLE_NAME" +AGENT_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/ahakeyconfig-agent" +INFO_PLIST="$APP_BUNDLE/Contents/Info.plist" +ENTITLEMENTS="$APP_ROOT/.build/AhaKeyConfig.entitlements" +ICON_SOURCE="${ICON_SOURCE:-$APP_ROOT/ahakeyicon.png}" +INSTALL_TO_APPLICATIONS="${INSTALL_TO_APPLICATIONS:-0}" +INSTALL_DIR="${INSTALL_DIR:-/Applications}" +LAUNCH_AFTER_INSTALL="${LAUNCH_AFTER_INSTALL:-0}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +SIGNING_IDENTITY_HINT="${SIGNING_IDENTITY_HINT:-}" +REQUIRE_DEVELOPER_ID="${REQUIRE_DEVELOPER_ID:-0}" +DEST_APP="$INSTALL_DIR/$APP_BUNDLE_NAME.app" + +echo "📦 Building $APP_DISPLAY_NAME..." +cd "$APP_ROOT" + +export MACOSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" + +BUILD_OUTPUTS=() +AGENT_OUTPUTS=() +for ARCH in ${(z)BUILD_ARCHS}; do + echo "🔨 Building $ARCH for macOS $MACOS_DEPLOYMENT_TARGET..." + swift build -c release --arch "$ARCH" --product AhaKeyConfig + swift build -c release --arch "$ARCH" --product ahakeyconfig-agent + + ARCH_BUILD_OUTPUT=".build/$ARCH-apple-macosx/release/$EXECUTABLE_NAME" + ARCH_AGENT_OUTPUT=".build/$ARCH-apple-macosx/release/ahakeyconfig-agent" + if [[ ! -f "$ARCH_BUILD_OUTPUT" ]]; then + echo "Build output not found at $ARCH_BUILD_OUTPUT" + exit 1 + fi + if [[ ! -f "$ARCH_AGENT_OUTPUT" ]]; then + echo "Agent build output not found at $ARCH_AGENT_OUTPUT" + exit 1 + fi + BUILD_OUTPUTS+=("$ARCH_BUILD_OUTPUT") + AGENT_OUTPUTS+=("$ARCH_AGENT_OUTPUT") +done + +echo "🧱 Creating app bundle..." +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" +mkdir -p "$OUTPUT_DIR" + +ICONSET_DIR="$APP_ROOT/.build/AhaKeyConfig.iconset" +ICNS_PATH="$APP_ROOT/.build/AhaKeyConfig.icns" + +echo "🎨 Generating app icon..." +if [[ -f "$ICON_SOURCE" ]]; then + swift "$APP_ROOT/scripts/generate_icons.swift" "$ICONSET_DIR" "$ICON_SOURCE" +else + swift "$APP_ROOT/scripts/generate_icons.swift" "$ICONSET_DIR" +fi +iconutil -c icns "$ICONSET_DIR" -o "$ICNS_PATH" + +if [[ ${#BUILD_OUTPUTS[@]} -gt 1 ]]; then + lipo -create "${BUILD_OUTPUTS[@]}" -output "$APP_EXECUTABLE" + lipo -create "${AGENT_OUTPUTS[@]}" -output "$AGENT_EXECUTABLE" +else + cp "$BUILD_OUTPUTS[1]" "$APP_EXECUTABLE" + cp "$AGENT_OUTPUTS[1]" "$AGENT_EXECUTABLE" +fi +cp "$ICNS_PATH" "$APP_BUNDLE/Contents/Resources/AhaKeyConfig.icns" +if [[ -d "$APP_ROOT/Resources/Help" ]]; then + mkdir -p "$APP_BUNDLE/Contents/Resources/Help" + ditto "$APP_ROOT/Resources/Help" "$APP_BUNDLE/Contents/Resources/Help" +fi +if [[ -d "$APP_ROOT/Resources/DefaultOLED" ]]; then + mkdir -p "$APP_BUNDLE/Contents/Resources/DefaultOLED" + ditto "$APP_ROOT/Resources/DefaultOLED" "$APP_BUNDLE/Contents/Resources/DefaultOLED" +fi + +BUILD_NUMBER="$(git -C "$APP_ROOT/../.." rev-list --count HEAD 2>/dev/null || echo 1)" + +cat > "$INFO_PLIST" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${APP_DISPLAY_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${APP_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + AhaKeyConfig + CFBundleName + ${APP_DISPLAY_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + ${BUILD_NUMBER} + LSMinimumSystemVersion + ${MACOS_DEPLOYMENT_TARGET} + NSBluetoothAlwaysUsageDescription + AhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescription + AhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescription + AhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + +PLIST + +mkdir -p "$(dirname "$ENTITLEMENTS")" +cat > "$ENTITLEMENTS" <<'ENTITLEMENTS' + + + + + com.apple.security.device.bluetooth + + com.apple.security.device.audio-input + + + +ENTITLEMENTS + +find_developer_id() { + local identities + identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$SIGNING_IDENTITY_HINT" ]]; then + echo "$identities" | grep 'Developer ID Application' | grep "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + else + echo "$identities" | grep 'Developer ID Application' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + fi +} + +find_apple_development() { + local identities + identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$SIGNING_IDENTITY_HINT" ]]; then + echo "$identities" | grep 'Apple Development' | grep "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + else + echo "$identities" | grep 'Apple Development' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true + fi +} + +if [[ -z "$SIGNING_IDENTITY" ]]; then + SIGNING_IDENTITY="$(find_developer_id)" +fi + +if [[ -z "$SIGNING_IDENTITY" && "$REQUIRE_DEVELOPER_ID" != "1" ]]; then + SIGNING_IDENTITY="$(find_apple_development)" +fi + +if [[ "$REQUIRE_DEVELOPER_ID" == "1" && -z "$SIGNING_IDENTITY" ]]; then + echo "❌ No Developer ID Application identity found in keychain." + echo " Please install a Developer ID Application certificate first." + exit 1 +fi + +if [[ -n "${SIGNING_IDENTITY}" ]]; then + echo "🔏 Signing with: $SIGNING_IDENTITY" + SIGN_ARGS=(--force --sign "$SIGNING_IDENTITY") + APP_SIGN_ARGS=("${SIGN_ARGS[@]}") + + if [[ "$SIGNING_IDENTITY" == Developer\ ID\ Application:* ]]; then + SIGN_ARGS+=(--timestamp --options runtime) + APP_SIGN_ARGS=("${SIGN_ARGS[@]}") + else + APP_SIGN_ARGS+=(--entitlements "$ENTITLEMENTS") + fi + + xattr -cr "$APP_BUNDLE" 2>/dev/null || true + codesign "${SIGN_ARGS[@]}" "$AGENT_EXECUTABLE" + codesign "${APP_SIGN_ARGS[@]}" "$APP_BUNDLE" +else + if [[ "$REQUIRE_DEVELOPER_ID" == "1" ]]; then + echo "❌ RELEASE_DISTRIBUTION requires a valid Developer ID Application identity." + exit 1 + fi + echo "🧪 No signing identity found, using ad-hoc signature for local testing" + xattr -cr "$APP_BUNDLE" 2>/dev/null || true + codesign --force --sign - "$AGENT_EXECUTABLE" + codesign --force --sign - "$APP_BUNDLE" +fi + +echo "🔎 Verifying app signature..." +codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" + +if [[ "$INSTALL_TO_APPLICATIONS" == "1" ]]; then + echo "📥 Installing to $DEST_APP..." + + # 单实例:关闭已运行的实例 + if pgrep -f "$DEST_APP/Contents/MacOS/$EXECUTABLE_NAME" >/dev/null 2>&1; then + osascript -e "tell application id \"$APP_IDENTIFIER\" to quit" 2>/dev/null || true + for _ in {1..20}; do + pgrep -f "$DEST_APP/Contents/MacOS/$EXECUTABLE_NAME" >/dev/null 2>&1 || break + sleep 0.25 + done + pkill -9 -f "$DEST_APP/Contents/MacOS/$EXECUTABLE_NAME" 2>/dev/null || true + sleep 0.3 + fi + + rm -rf "$DEST_APP" + mkdir -p "$INSTALL_DIR" + ditto "$APP_BUNDLE" "$DEST_APP" + xattr -dr com.apple.quarantine "$DEST_APP" 2>/dev/null || true + + LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + if [[ -x "$LSREGISTER" ]]; then + "$LSREGISTER" -f "$DEST_APP" >/dev/null 2>&1 || true + fi + + if [[ "$LAUNCH_AFTER_INSTALL" == "1" ]]; then + open "$DEST_APP" + fi +fi + +echo "✅ Build complete: $APP_BUNDLE" diff --git a/scripts/generate_dmg_background.swift b/scripts/generate_dmg_background.swift new file mode 100644 index 00000000..fcf9cd4b --- /dev/null +++ b/scripts/generate_dmg_background.swift @@ -0,0 +1,100 @@ +import AppKit + +let arguments = CommandLine.arguments +guard arguments.count >= 2 else { + fputs("usage: swift generate_dmg_background.swift \n", stderr) + exit(1) +} + +let outputURL = URL(fileURLWithPath: arguments[1]) + +// Background size must match DMG window content area +// Window bounds in osascript: {100, 100, 960, 580} => content 860 x 480 +let width: CGFloat = 860 +let height: CGFloat = 480 + +let image = NSImage(size: NSSize(width: width, height: height)) +image.lockFocus() + +let bounds = NSRect(x: 0, y: 0, width: width, height: height) + +// ── Background: clean white-to-light-gray, like macOS system dialogs ───── +NSGradient(colors: [ + NSColor(calibratedRed: 0.980, green: 0.982, blue: 0.988, alpha: 1), + NSColor(calibratedRed: 0.940, green: 0.944, blue: 0.955, alpha: 1), +])!.draw(in: bounds, angle: 90) + +// ── Title ───────────────────────────────────────────────────────────────── +let centerStyle = NSMutableParagraphStyle() +centerStyle.alignment = .center + +let titleAttr: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 26, weight: .semibold), + .foregroundColor: NSColor(calibratedWhite: 0.13, alpha: 1), + .paragraphStyle: centerStyle, +] +"拖动 AhaKey Studio 到 Applications".draw( + in: NSRect(x: 60, y: height - 82, width: width - 120, height: 40), + withAttributes: titleAttr +) + +// ── Subtitle ────────────────────────────────────────────────────────────── +let subAttr: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 14, weight: .regular), + .foregroundColor: NSColor(calibratedWhite: 0.48, alpha: 1), + .paragraphStyle: centerStyle, +] +"安装完成后,就能像普通 Mac 应用一样打开使用".draw( + in: NSRect(x: 80, y: height - 114, width: width - 160, height: 22), + withAttributes: subAttr +) + +// ── Arrow: clean filled shape between the two icon positions ────────────── +// App icon centre ~x=170, Applications icon centre ~x=690 (at icon size 100) +// Arrow spans roughly x 260 → 590, y centred at 210 +let arrowColor = NSColor(calibratedRed: 0.20, green: 0.50, blue: 0.95, alpha: 1) +let arrowY: CGFloat = 210 +let arrowX0: CGFloat = 268 +let arrowX1: CGFloat = 588 +let arrowLW: CGFloat = 6 +let headLen: CGFloat = 22 +let headHalf: CGFloat = 14 + +// Shaft +let shaft = NSBezierPath() +shaft.move(to: NSPoint(x: arrowX0, y: arrowY)) +shaft.line(to: NSPoint(x: arrowX1 - headLen * 0.6, y: arrowY)) +shaft.lineWidth = arrowLW +shaft.lineCapStyle = .round +arrowColor.setStroke() +shaft.stroke() + +// Filled arrowhead +let head = NSBezierPath() +head.move(to: NSPoint(x: arrowX1, y: arrowY)) +head.line(to: NSPoint(x: arrowX1 - headLen, y: arrowY + headHalf)) +head.line(to: NSPoint(x: arrowX1 - headLen, y: arrowY - headHalf)) +head.close() +arrowColor.setFill() +head.fill() + +// Arrow caption +let captionAttr: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: arrowColor, + .paragraphStyle: centerStyle, +] +"拖过去安装".draw( + in: NSRect(x: arrowX0, y: arrowY - 36, width: arrowX1 - arrowX0, height: 20), + withAttributes: captionAttr +) + +image.unlockFocus() + +guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { + fputs("failed to render png background\n", stderr) + exit(1) +} +try png.write(to: outputURL) diff --git a/scripts/generate_icons.swift b/scripts/generate_icons.swift new file mode 100644 index 00000000..7a2542f5 --- /dev/null +++ b/scripts/generate_icons.swift @@ -0,0 +1,124 @@ +import AppKit +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count >= 2 else { + fputs("usage: swift generate_icons.swift [source-image]\n", stderr) + exit(1) +} + +let outputDirectory = URL(fileURLWithPath: arguments[1], isDirectory: true) +let sourceImageURL = arguments.count >= 3 ? URL(fileURLWithPath: arguments[2]) : nil +let fileManager = FileManager.default +try? fileManager.removeItem(at: outputDirectory) +try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + +let sizes: [(Int, String)] = [ + (16, "icon_16x16.png"), + (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), + (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), + (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), + (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), + (1024, "icon_512x512@2x.png"), +] + +let sourceImage = sourceImageURL.flatMap { NSImage(contentsOf: $0) } + +for (size, filename) in sizes { + let image = sourceImage.map { renderSourceIcon($0, size: CGFloat(size)) } ?? makeIcon(size: CGFloat(size)) + let destination = outputDirectory.appendingPathComponent(filename) + try pngData(from: image).write(to: destination) +} + +// MARK: - Icon rendering + +func renderSourceIcon(_ source: NSImage, size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + + source.draw( + in: NSRect(x: 0, y: 0, width: size, height: size), + from: NSRect(origin: .zero, size: source.size), + operation: .sourceOver, + fraction: 1.0 + ) + + image.unlockFocus() + return image +} + +func makeIcon(size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + + let s = size + + // ── Background: white ────────────────────────────────────────────────── + NSColor.white.setFill() + NSBezierPath.fill(NSRect(x: 0, y: 0, width: s, height: s)) + + // ── "A" letter: bold geometric dark charcoal ─────────────────────────── + // Use heaviest system font weight to match the bold geometric "A" in reference + let aColor = NSColor(calibratedRed: 0.09, green: 0.09, blue: 0.11, alpha: 1.0) + let aFont = NSFont.systemFont(ofSize: s * 0.86, weight: .black) + let aAttribs: [NSAttributedString.Key: Any] = [ + .font: aFont, + .foregroundColor: aColor, + ] + let aStr = NSAttributedString(string: "A", attributes: aAttribs) + let aSize = aStr.size() + // Center horizontally; nudge slightly below vertical center for optical balance + let aX = (s - aSize.width) / 2 + let aY = (s - aSize.height) / 2 - s * 0.02 + aStr.draw(at: NSPoint(x: aX, y: aY)) + + // ── Lightning bolt: electric blue diagonal slash ──────────────────────── + // Bolt runs from upper-right → lower-left, cutting through the "A". + // All coordinates in 0…1000 space; scaled to actual icon size via `pt()`. + let boltBlue = NSColor(calibratedRed: 0.08, green: 0.38, blue: 0.96, alpha: 1.0) + let sc = s / 1000.0 + func pt(_ x: CGFloat, _ y: CGFloat) -> NSPoint { NSPoint(x: x * sc, y: y * sc) } + + // Classic zigzag lightning bolt outline (8 vertices, clockwise): + // Upper section → step right (jog) → Lower section → tip → back up + // + // P1 ── P2 (top edge) + // | \ + // P8 P3 ── P4 (step: lower section is offset right ~90 units) + // | \ + // P7 ── P6 P5 (tip) + + let bolt = NSBezierPath() + bolt.move(to: pt(502, 880)) // P1 top-left + bolt.line(to: pt(662, 880)) // P2 top-right + bolt.line(to: pt(492, 474)) // P3 mid-right (upper section right edge at step) + bolt.line(to: pt(580, 474)) // P4 step-right (lower section right edge start) + bolt.line(to: pt(406, 78)) // P5 bottom tip right + bolt.line(to: pt(328, 78)) // P6 bottom tip left + bolt.line(to: pt(393, 454)) // P7 mid-left (lower section left edge at step) + bolt.line(to: pt(305, 454)) // P8 step-left (upper section left edge) + bolt.close() // back to P1 + + boltBlue.setFill() + bolt.fill() + + image.unlockFocus() + return image +} + +// MARK: - PNG helper + +func pngData(from image: NSImage) throws -> Data { + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) else { + throw NSError(domain: "IconGeneration", code: 1) + } + return png +} diff --git a/scripts/package_dmg.sh b/scripts/package_dmg.sh new file mode 100755 index 00000000..28fd86fd --- /dev/null +++ b/scripts/package_dmg.sh @@ -0,0 +1,198 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [[ -f "$SCRIPT_DIR/build.local.env" ]]; then + source "$SCRIPT_DIR/build.local.env" +fi + +APP_BUNDLE_NAME="${APP_BUNDLE_NAME:-AhaKey Studio}" +APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AhaKey Studio}" +OUTPUT_DIR="${OUTPUT_DIR:-$APP_ROOT/dist}" +DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-AhaKey Studio Installer}" +DMG_BASENAME="${DMG_BASENAME:-AhaKey-Studio-macOS-prod-$(date +%Y%m%d%H%M%S)}" +DMG_PATH="$OUTPUT_DIR/$DMG_BASENAME.dmg" +DMG_STAGING_DIR="$OUTPUT_DIR/.dmg-staging" +RW_DMG_PATH="$OUTPUT_DIR/$DMG_BASENAME-rw.dmg" +DMG_MOUNTPOINT="/Volumes/$DMG_VOLUME_NAME" +APP_BUNDLE_PATH="$OUTPUT_DIR/$APP_BUNDLE_NAME.app" +BACKGROUND_DIR="$DMG_STAGING_DIR/.background" +BACKGROUND_IMAGE="$BACKGROUND_DIR/InstallerBackground.png" +NOTARY_PROFILE="${NOTARY_PROFILE:-}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +SIGNING_IDENTITY_HINT="${SIGNING_IDENTITY_HINT:-}" +RELEASE_DISTRIBUTION="${RELEASE_DISTRIBUTION:-0}" + +if [[ -z "$SIGNING_IDENTITY" ]]; then + IDENTITIES="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$SIGNING_IDENTITY_HINT" ]]; then + SIGNING_IDENTITY="$(echo "$IDENTITIES" | grep 'Developer ID Application' | grep "$SIGNING_IDENTITY_HINT" | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true)" + else + SIGNING_IDENTITY="$(echo "$IDENTITIES" | grep 'Developer ID Application' | head -n 1 | sed -E 's/.*"(.+)"/\1/' || true)" + fi +fi + +echo "📦 Packaging $APP_DISPLAY_NAME DMG..." + +if [[ "$RELEASE_DISTRIBUTION" == "1" ]]; then + REQUIRE_DEVELOPER_ID=1 "$SCRIPT_DIR/build.sh" +else + "$SCRIPT_DIR/build.sh" +fi + +if [[ ! -d "$APP_BUNDLE_PATH" ]]; then + echo "App bundle not found at $APP_BUNDLE_PATH" + exit 1 +fi + +rm -rf "$DMG_STAGING_DIR" +mkdir -p "$DMG_STAGING_DIR" + +echo "🧱 Preparing DMG staging folder..." +ditto "$APP_BUNDLE_PATH" "$DMG_STAGING_DIR/$APP_BUNDLE_NAME.app" +ln -s /Applications "$DMG_STAGING_DIR/Applications" +mkdir -p "$BACKGROUND_DIR" +swift "$APP_ROOT/scripts/generate_dmg_background.swift" "$BACKGROUND_IMAGE" + +rm -f "$DMG_PATH" "$RW_DMG_PATH" +if [[ -d "$DMG_MOUNTPOINT" ]]; then + hdiutil detach "$DMG_MOUNTPOINT" -force >/dev/null 2>&1 || true +fi + +echo "💽 Creating writable HFS+ DMG..." +# macOS 13+ 上 APFS DMG 的 Finder 元数据持久化不可靠,强制 HFS+。 +# macOS 26 起 hdiutil create -srcfolder 无法处理含签名 .app 的目录(EPERM), +# 改为:先建空 DMG → 挂载 → ditto 拷入文件,再走 AppleScript 布局。 +APP_SIZE_MB=$(du -sm "$APP_BUNDLE_PATH" | awk '{print $1}') +DMG_SIZE_MB=$(( APP_SIZE_MB + 32 )) + +rm -f "$RW_DMG_PATH" +hdiutil create \ + -volname "$DMG_VOLUME_NAME" \ + -size "${DMG_SIZE_MB}m" \ + -ov \ + -fs HFS+ \ + "$RW_DMG_PATH" + +echo "🪟 Applying drag-to-install layout..." +# 关键:不要 -noautoopen,否则 Finder 不会把这个卷加进 visible volume list, +# 后面 AppleScript 用 `tell disk "..."` 直接失败 -1728 (object not found) +hdiutil attach "$RW_DMG_PATH" -mountpoint "$DMG_MOUNTPOINT" -readwrite -noverify + +# 把文件拷进挂载好的卷(避免 -srcfolder 的 EPERM 限制) +ditto "$APP_BUNDLE_PATH" "$DMG_MOUNTPOINT/$APP_BUNDLE_NAME.app" +ln -sf /Applications "$DMG_MOUNTPOINT/Applications" +mkdir -p "$DMG_MOUNTPOINT/.background" +cp "$BACKGROUND_IMAGE" "$DMG_MOUNTPOINT/.background/InstallerBackground.png" + +# 隐藏 .background,不让用户在 Finder 看到目录 +chflags hidden "$DMG_MOUNTPOINT/.background" 2>/dev/null || true + +# 给 Finder 一点时间把卷加进它的内部 list +sleep 3 + +set +e +osascript 2>&1 </dev/null || true + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$MOUNTED_AGENT" + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$MOUNTED_APP" + codesign --verify --deep --strict --verbose=2 "$MOUNTED_APP" +fi + +# 把元数据强制刷盘,再校验 .DS_Store 是否真写进去 +sync; sync; sync +sleep 3 +if [[ -f "$DMG_MOUNTPOINT/.DS_Store" ]]; then + echo "✅ .DS_Store written ($(stat -f%z "$DMG_MOUNTPOINT/.DS_Store") bytes) — installer layout will persist." +else + echo "⚠️ .DS_Store missing — Finder customization didn't persist. 检查 System Settings > Privacy > Automation 给 Terminal 授予 Finder 权限。" +fi + +hdiutil detach "$DMG_MOUNTPOINT" -force + +echo "🗜️ Converting DMG..." +hdiutil convert "$RW_DMG_PATH" -ov -format UDZO -o "$DMG_PATH" +rm -f "$RW_DMG_PATH" + +if [[ -n "$SIGNING_IDENTITY" ]]; then + echo "🔏 Signing DMG with: $SIGNING_IDENTITY" + codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH" +fi + +if [[ -n "$NOTARY_PROFILE" ]]; then + echo "🧾 Notarizing DMG with profile: $NOTARY_PROFILE" + xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait + xcrun stapler staple "$DMG_PATH" +elif [[ "$RELEASE_DISTRIBUTION" == "1" ]]; then + echo "❌ RELEASE_DISTRIBUTION=1 requires NOTARY_PROFILE." + echo " Create one with: xcrun notarytool store-credentials ..." + exit 1 +fi + +echo "🔎 Verifying DMG..." +hdiutil verify "$DMG_PATH" + +if [[ -n "$SIGNING_IDENTITY" ]]; then + echo "🔎 Assessing signed artifacts..." + spctl --assess --type open --context context:primary-signature -vv "$DMG_PATH" +fi + +echo "✅ DMG ready: $DMG_PATH" diff --git a/test-cursor-hook.sh b/test-cursor-hook.sh new file mode 100755 index 00000000..f9cbef42 --- /dev/null +++ b/test-cursor-hook.sh @@ -0,0 +1,19 @@ +#!/bin/zsh + +# 模拟 Cursor 实际调用 hook 的方式 +echo "模拟 Cursor 调用 beforeShellExecution hook:" + +# 模拟 Cursor 实际发送的 JSON 格式 +cat > cursor-request.json << 'EOF' +{ + "command": "python3 /Users/cjn/Desktop/keyboard1/ahakeyconfig/helloworld.py", + "cwd": "/Users/cjn/Desktop/keyboard1/ahakeyconfig", + "env": {} +} +EOF + +# 测试 hook 响应 +cat cursor-request.json | "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent" hook beforeShellExecution + +echo "\n测试完成" +rm cursor-request.json diff --git a/test-hook.sh b/test-hook.sh new file mode 100755 index 00000000..dbf494b9 --- /dev/null +++ b/test-hook.sh @@ -0,0 +1,11 @@ +#!/bin/zsh + +# 测试 hook 执行 +echo "测试 preToolUse hook:" +echo '{"tool_name":"Read","name":"read"}' | "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent" hook preToolUse + +echo "\n测试 beforeShellExecution hook:" +echo '{"command":"python3 /Users/cjn/Desktop/keyboard1/ahakeyconfig/helloworld.py"}' | "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent" hook beforeShellExecution + +echo "\n测试 beforeMCPExecution hook:" +echo '{"command":"test command"}' | "/Users/cjn/Desktop/keyboard1/ahakeyconfig/dist/AhaKey Studio.app/Contents/MacOS/ahakeyconfig-agent" hook beforeMCPExecution