diff --git a/.gitignore b/.gitignore index d6677d41..99046ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ logs/ **/config_client.json **/settings.json.bak.* **/hooks.json.bak.* +**/build.local.env +**/*.local.env # Local backups / temp *.bak @@ -72,3 +74,56 @@ logs/ # Local model / runtime payloads internal/ .gstack/ +/ahakeyconfig-win-java/.m2repo/ +/ahakeyconfig-win-java/target/ + +# Java / Maven build output +target/ +*.class + +# Swift build artifacts +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate + +# Packaged outputs +dist/ + +# Local env (signing creds etc.) — covered by **/*.local.env above + +# Server codebase (separate Gitee repo) +badao-resource-sharing-backstage/ + +# Firmware backup / artifacts +CH582m_vibe_coding_BLE_keyboard-master.bak-previous/ +*.hex + +# Local SSH keys / toolchain +.ssh/ +.toolchain/ + +# Server setup helpers +server-ssh-setup.md +server-ssh-setup.sh + +# Web client (separate project) +ahakey-web-main/ + +# Misc local assets +backspace.png +codex.gif +cursor.gif + +# Python / bridge runtime outputs +*.out +*.err + +# BLE_tcp_bridge_v3 — 防止打包时再生成 30+ 个按时间戳命名的 spec +BLE_tcp_bridge_v3/ahakey-bridge-2*.spec +BLE_tcp_bridge_v3/ahakey-bridge-onefile-2*.spec +BLE_tcp_bridge_v3/test-winrt.spec + +# Rust toolchain installer 不该入库 +rustup-init.exe + 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/BLE_tcp_bridge_linux/Makefile b/BLE_tcp_bridge_linux/Makefile new file mode 100644 index 00000000..ae764f26 --- /dev/null +++ b/BLE_tcp_bridge_linux/Makefile @@ -0,0 +1,31 @@ +PACKAGE_NAME = ahakey-ble-bridge +VERSION = 1.0.0 +ARCH = all +DEB_NAME = $(PACKAGE_NAME)_$(VERSION)_$(ARCH).deb + +.PHONY: all build clean install uninstall + +all: build + +build: + @echo "Building $(DEB_NAME)..." + @cp bridge_gui.py deb_package/opt/ahakey/ble-bridge/ + @chmod 644 deb_package/opt/ahakey/ble-bridge/bridge_gui.py + @chmod 755 deb_package/DEBIAN/postinst deb_package/DEBIAN/prerm + @chmod 755 deb_package/usr/bin/ahakey-ble-bridge + @dpkg-deb --build deb_package $(DEB_NAME) + @echo "Built: $(DEB_NAME)" + +clean: + @echo "Cleaning..." + @rm -f $(DEB_NAME) + @rm -f deb_package/opt/ahakey/ble-bridge/bridge_gui.py + +install: build + @echo "Installing..." + @sudo dpkg -i $(DEB_NAME) + @sudo apt-get install -f -y + +uninstall: + @echo "Uninstalling..." + @sudo dpkg -r $(PACKAGE_NAME) diff --git a/BLE_tcp_bridge_linux/README.md b/BLE_tcp_bridge_linux/README.md new file mode 100644 index 00000000..40fb423b --- /dev/null +++ b/BLE_tcp_bridge_linux/README.md @@ -0,0 +1,145 @@ +# BLE TCP Bridge for Linux (GUI 版) + +使用 BlueZ D-Bus API 连接 BLE 设备,提供 TCP 接口给上位机通信。 +对标 Windows 版 BLE_tcp_bridge 的功能和界面。 + +## 功能特性 + +- **GUI 界面**: PyQt5 桌面应用,类似 Win 版布局(适配 Ubuntu 20.04) +- **自动扫描**: 启动后自动扫描周围 BLE 设备 +- **自动连接**: 发现已保存的 AhaKey 设备后自动连接 +- **设备选择**: 下拉列表选择设备,无需手动输入 MAC +- **系统托盘**: 关闭窗口最小化到托盘,后台持续运行 +- **TCP 桥接**: 提供 127.0.0.1:9000 接口给 Java 应用连接 +- **DEB 包**: 支持打包成 deb 包,一键安装 + +## 构建 deb 包 + +```bash +# 进入目录 +cd BLE_tcp_bridge_linux + +# 构建 deb 包 +make + +# 安装 deb 包 +sudo dpkg -i ahakey-ble-bridge_1.0.0_all.deb + +# 修复依赖(如果需要) +sudo apt-get install -f +``` + +## 安装后使用 + +1. 在应用程序菜单中找到 **AhaKey BLE Bridge** +2. 点击图标启动程序 +3. 程序会自动扫描并连接 AhaKey 设备 + +## 卸载 + +```bash +sudo dpkg -r ahakey-ble-bridge +``` + +## 开发运行 + +```bash +# 正常启动 +python3 bridge_gui.py + +# 最小化启动 +python3 bridge_gui.py --minimized + +# 调试日志 +python3 bridge_gui.py --debug +``` + +## 界面说明 + +| 组件 | 说明 | +|------|------| +| 设备下拉框 | 显示扫描到的 BLE 设备 | +| 连接按钮 | 手动连接选中的设备 | +| 当前连接设备 | 显示已连接的 AhaKey 设备名 | +| TCP服务 | 显示本机IP、端口和客户端数 | +| 下次最小化启动 | 设置后下次启动自动最小化到托盘 | +| 退出程序 | 完全退出 | +| 日志区 | 显示蓝牙扫描、连接、TCP 通信日志 | + +## 首次使用 + +1. 确保蓝牙适配器已启用: + ```bash + sudo systemctl start bluetooth + ``` + +2. 启动程序后会自动扫描设备 + +3. 连接成功后启动 Java 应用: + ```bash + java -jar ahakey-studio.jar + ``` + +## TCP 协议 + +与 Windows 版 BLE_tcp_bridge 兼容,包格式: +``` +[Type:1字节][Length:2字节小端][Data:N字节] +``` + +### 客户端 → 服务器 + +| 类型 | 值 | 说明 | +|------|-----|------| +| WriteData | 0x01 | 写数据到 BLE | +| WriteCommand | 0x02 | 写命令到 BLE | +| QueryBleStatus | 0x03 | 查询 BLE 连接状态 | +| QueryDeviceInfo | 0x04 | 查询设备状态信息 | + +### 服务器 → 客户端 + +| 类型 | 值 | 说明 | +|------|-----|------| +| BleNotify | 0x81 | BLE 通知数据 | +| BleStatusResp | 0x82 | BLE 连接状态响应 | +| DeviceInfoResp | 0x83 | 设备状态信息响应 | + +## 配置文件 + +配置保存在 `~/.config/ahakey/ble_bridge.json`: +```json +{ + "ble_name": "AhaKey XXXX", + "ble_mac": "DC:04:5A:XX:XX:XX", + "server_port": 9000, + "start_minimized": false +} +``` + +## 文件结构 + +``` +BLE_tcp_bridge_linux/ +├── bridge_gui.py # GUI 主程序 +├── ble_bridge.py # 命令行版本 +├── requirements.txt # Python 依赖 +├── Makefile # 构建脚本 +├── README.md # 说明文档 +└── deb_package/ # deb 包结构 + ├── DEBIAN/ + │ ├── control # 包控制信息 + │ ├── postinst # 安装后脚本 + │ └── prerm # 卸载前脚本 + ├── usr/ + │ ├── bin/ + │ │ └── ahakey-ble-bridge # 启动脚本 + │ ├── share/ + │ │ ├── applications/ + │ │ │ └── ahakey-ble-bridge.desktop # 桌面入口 + │ │ └── pixmaps/ + │ │ └── ahakey-ble-bridge.svg # 图标 + └── opt/ + └── ahakey/ + └── ble-bridge/ + └── bridge_gui.py # 程序文件 +``` diff --git a/BLE_tcp_bridge_linux/ble_bridge.py b/BLE_tcp_bridge_linux/ble_bridge.py new file mode 100644 index 00000000..cd57b897 --- /dev/null +++ b/BLE_tcp_bridge_linux/ble_bridge.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +BLE TCP Bridge for Linux +使用 BlueZ D-Bus API 连接 BLE 设备,提供 TCP 接口给上位机 + +依赖: + pip install dbus-python pydbus + +运行: + python ble_bridge.py +""" + +import asyncio +import sys +import os +import struct +import logging +import signal +import argparse +from typing import Optional, List +from dataclasses import dataclass +from enum import IntEnum + +import dbus +import dbus.service +import dbus.mainloop.glib +from gi.repository import GLib + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class PacketType(IntEnum): + """TCP 通信协议包类型""" + WRITE_DATA = 0x01 + WRITE_COMMAND = 0x02 + QUERY_BLE_STATUS = 0x03 + QUERY_DEVICE_INFO = 0x04 + BLE_NOTIFY = 0x81 + BLE_STATUS_RESP = 0x82 + DEVICE_INFO_RESP = 0x83 + + +@dataclass +class BleStatusInfo: + connected: bool = False + device_name: str = "" + mac_address: str = "" + is_target_device: bool = False + + +@dataclass +class DeviceStatusInfo: + battery_level: int = 0 + signal_strength: int = 0 + firmware_main: int = 0 + firmware_sub: int = 0 + work_mode: int = 0 + light_mode: int = 0 + switch_state: int = 0 + reserve: int = 0 + + +def build_packet(pkt_type: PacketType, data: bytes = None) -> bytes: + """构建协议包: [Type:1][Length:2 LE][Data:N]""" + length = len(data) if data else 0 + header = bytes([pkt_type, length & 0xFF, (length >> 8) & 0xFF]) + return header + (data if data else b'') + + +def build_ble_status_packet(status: BleStatusInfo) -> bytes: + """构建BLE状态响应包""" + name_bytes = status.device_name.encode('utf-8') + mac_bytes = status.mac_address.encode('utf-8') + data = bytes([ + 1 if status.connected else 0, + len(name_bytes) + ]) + name_bytes + bytes([ + len(mac_bytes) + ]) + mac_bytes + bytes([ + 1 if status.is_target_device else 0 + ]) + return build_packet(PacketType.BLE_STATUS_RESP, data) + + +def build_device_info_packet(info: DeviceStatusInfo) -> bytes: + """构建设备状态信息响应包""" + data = bytes([ + info.battery_level, + info.signal_strength, + info.firmware_main, + info.firmware_sub, + info.work_mode, + info.light_mode, + info.switch_state, + info.reserve + ]) + return build_packet(PacketType.DEVICE_INFO_RESP, data) + + +def is_device_status_notification(data: bytes) -> bool: + """检查是否为设备状态信息包 [0xAA][0xBB][0x00]...[0xCC][0xDD]""" + if len(data) != 13: + return False + return data[0] == 0xAA and data[1] == 0xBB and data[2] == 0x00 \ + and data[11] == 0xCC and data[12] == 0xDD + + +def parse_device_status(data: bytes) -> DeviceStatusInfo: + """从通知数据中解析设备状态""" + info = DeviceStatusInfo() + if len(data) >= 12: + info.battery_level = data[3] + info.signal_strength = data[4] + info.firmware_main = data[5] + info.firmware_sub = data[6] + info.work_mode = data[7] + info.light_mode = data[8] + info.switch_state = data[9] + return info + + +class TcpClient: + """TCP 客户端连接""" + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, bridge: 'BleBridge'): + self.reader = reader + self.writer = writer + self.bridge = bridge + self.addr = writer.get_extra_info('peername') + logger.info(f"TCP client connected: {self.addr}") + + async def handle(self): + """处理客户端数据""" + try: + while True: + # 读取包头 (3 字节) + header = await self.reader.readexactly(3) + pkt_type = header[0] + length = header[1] | (header[2] << 8) + + # 读取数据包 + data = b'' + if length > 0: + data = await self.reader.readexactly(length) + + await self.bridge.handle_packet(self, pkt_type, data) + except asyncio.IncompleteReadError: + logger.info(f"TCP client disconnected: {self.addr}") + except Exception as e: + logger.error(f"TCP client error: {self.addr}, {e}") + finally: + self.bridge.remove_client(self) + + def send(self, data: bytes): + """发送数据到客户端""" + try: + self.writer.write(data) + # asyncio StreamWriter 没有 flush 参数 + except Exception as e: + logger.error(f"Send to {self.addr} failed: {e}") + + +class BleBridge: + """BLE 桥接器 - 连接 BlueZ 和 TCP""" + + # AhaKey 设备的 BLE 服务和特征 UUID + TARGET_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb" + WRITE_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb" + NOTIFY_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb" # 相同 UUID,0x7341/0x7343/0x7344 是设备的 handle + + # 设备的 MAC 地址 (需要替换为实际设备地址) + DEFAULT_DEVICE_MAC = "00:00:00:00:00:00" + + def __init__(self, device_mac: str = None, target_uuid: str = None): + self.device_mac = device_mac or os.environ.get("AHAKEY_BLE_MAC", self.DEFAULT_DEVICE_MAC) + self.target_uuid = target_uuid or os.environ.get("AHAKEY_BLE_UUID", self.TARGET_SERVICE_UUID) + self.bus: Optional[dbus.Bus] = None + self.device_path: Optional[str] = None + self.device_props: Optional[dbus.proxies.Interface] = None + self.device_iface: Optional[dbus.proxies.Interface] = None + self.notify_char_path: Optional[str] = None + self.write_char_path: Optional[str] = None + + self.connected = False + self.device_name = "Unknown" + self.clients: List[TcpClient] = [] + self.device_status = DeviceStatusInfo() + + # 设备状态查询命令 + self.DEVICE_STATUS_QUERY = bytes([0xAA, 0xBB, 0x00, 0xCC, 0xDD]) + + async def start(self): + """启动 BLE 桥接""" + logger.info(f"Starting BLE Bridge for device: {self.device_mac}") + await self.connect_bluetooth() + await self.start_tcp_server() + + async def connect_bluetooth(self): + """连接到 BLE 设备""" + logger.info("Connecting to Bluetooth...") + try: + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + self.bus = dbus.SystemBus() + + # 获取 BlueZ Adapter + adapter_path = await self.get_adapter_path() + logger.info(f"Using adapter: {adapter_path}") + + # 获取设备路径 + self.device_path = f"{adapter_path}/dev_{self.device_mac.replace(':', '_')}" + + # 检查设备是否已知 + try: + self.device_props = self.bus.get_object("org.bluez", self.device_path) + except dbus.exceptions.DBusException: + logger.warning(f"Device {self.device_mac} not found. Make sure it's paired and powered on.") + # 不阻塞,继续启动 TCP 服务器 + return + + # 获取设备接口 + self.device_iface = dbus.Interface( + self.bus.get_object("org.bluez", self.device_path), + "org.bluez.Device1" + ) + + # 监听属性变化 + self.bus.add_signal_receiver( + self.on_properties_changed, + signal_name="PropertiesChanged", + path=self.device_path, + interface_keyword="org.bluez.Device1" + ) + + # 连接设备 + logger.info(f"Connecting to device {self.device_mac}...") + self.device_iface.Connect(reply_handler=self._on_connect_reply, error_handler=self._on_connect_error) + + except Exception as e: + logger.error(f"BLE connection error: {e}") + import traceback + traceback.print_exc() + + def _on_connect_reply(self): + """连接成功回调""" + logger.info("BLE device connected") + self.connected = True + # 获取服务 + self._discover_services() + + def _on_connect_error(self, error: dbus.DBusException): + """连接失败回调""" + logger.error(f"BLE connect failed: {error}") + + def _discover_services(self): + """发现服务""" + try: + # 获取 GATT 服务 + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + for path, interfaces in objects.items(): + if "org.bluez.GattService1" in interfaces: + props = interfaces["org.bluez.GattService1"] + if props.get("UUID") == self.target_uuid: + logger.info(f"Found target service: {path}") + # 查找特征 + for char_path, char_interfaces in objects.items(): + if char_path.startswith(path): + if "org.bluez.GattCharacteristic1" in char_interfaces: + char_props = char_interfaces["org.bluez.GattCharacteristic1"] + char_uuid = char_props.get("UUID", "") + flags = char_props.get("Flags", []) + logger.info(f" Characteristic: {char_uuid}, flags: {flags}") + + # 根据 UUID 判断是读/写/通知特征 + if "write" in flags or "write-without-response" in flags: + self.write_char_path = char_path + logger.info(f" -> Write characteristic") + if "notify" in flags or "indicate" in flags: + self.notify_char_path = char_path + logger.info(f" -> Notify characteristic") + self._subscribe_notify(char_path) + + # 查询设备状态 + if self.write_char_path: + self._query_device_status() + + except Exception as e: + logger.error(f"Service discovery error: {e}") + + def _subscribe_notify(self, char_path: str): + """订阅通知""" + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", char_path), + "org.bluez.GattCharacteristic1" + ) + char_iface.StartNotify( + reply_handler=lambda: logger.info("Notify started"), + error_handler=lambda e: logger.error(f"Notify error: {e}") + ) + + # 监听 ValueChanged 信号 + self.bus.add_signal_receiver( + self.on_characteristic_changed, + signal_name="PropertiesChanged", + path=char_path, + interface_keyword="org.bluez.GattCharacteristic1" + ) + logger.info(f"Subscribed to notifications on {char_path}") + except Exception as e: + logger.error(f"Subscribe notify error: {e}") + + def on_characteristic_changed(self, interface: str, changed: dict, invalidated: list): + """特征值变化回调""" + if "Value" in changed: + data = bytes(changed["Value"]) + logger.debug(f"Notification: {data.hex()}") + + # 检查是否是设备状态 + if is_device_status_notification(data): + self.device_status = parse_device_status(data) + logger.info(f"Device status: battery={self.device_status.battery_level}, " + f"switch={self.device_status.switch_state}") + + # 转发给所有 TCP 客户端 + packet = build_packet(PacketType.BLE_NOTIFY, data) + for client in self.clients: + client.send(packet) + + def on_properties_changed(self, interface: str, changed: dict, invalidated: list): + """设备属性变化""" + if "Connected" in changed: + self.connected = changed["Connected"] + logger.info(f"Device connected: {self.connected}") + if "Name" in changed: + self.device_name = str(changed["Name"]) + logger.info(f"Device name: {self.device_name}") + + def _query_device_status(self): + """查询设备状态""" + if self.write_char_path: + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", self.write_char_path), + "org.bluez.GattCharacteristic1" + ) + char_iface.WriteValue(self.DEVICE_STATUS_QUERY, {}, + reply_handler=lambda: logger.debug("Device status query sent"), + error_handler=lambda e: logger.error(f"Write error: {e}") + ) + except Exception as e: + logger.error(f"Query device status error: {e}") + + async def get_adapter_path(self) -> str: + """获取 BlueZ 适配器路径""" + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + for path, interfaces in objects.items(): + if "org.bluez.Adapter1" in interfaces: + return path + raise RuntimeError("No Bluetooth adapter found") + + async def start_tcp_server(self, host: str = "127.0.0.1", port: int = 9000): + """启动 TCP 服务器""" + server = await asyncio.start_server(self.handle_tcp_client, host, port) + logger.info(f"TCP server listening on {host}:{port}") + + async with server: + await server.serve_forever() + + async def handle_tcp_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """处理 TCP 客户端连接""" + client = TcpClient(reader, writer, self) + self.clients.append(client) + await client.handle() + + async def handle_packet(self, client: TcpClient, pkt_type: int, data: bytes): + """处理来自 TCP 客户端的包""" + logger.debug(f"Received packet: type=0x{pkt_type:02x}, len={len(data)}") + + try: + pkt_type = PacketType(pkt_type) + except ValueError: + logger.warning(f"Unknown packet type: 0x{pkt_type:02x}") + return + + if pkt_type == PacketType.WRITE_COMMAND: + await self.write_command(data) + elif pkt_type == PacketType.WRITE_DATA: + await self.write_data(data) + elif pkt_type == PacketType.QUERY_BLE_STATUS: + self.send_ble_status(client) + elif pkt_type == PacketType.QUERY_DEVICE_INFO: + self.send_device_info(client) + + async def write_command(self, data: bytes): + """写命令到 BLE 设备""" + if not self.write_char_path: + logger.warn("Write char not available") + return + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", self.write_char_path), + "org.bluez.GattCharacteristic1" + ) + char_iface.WriteValue(list(data), {}, + reply_handler=lambda: logger.debug("Command sent"), + error_handler=lambda e: logger.error(f"Write error: {e}") + ) + except Exception as e: + logger.error(f"Write command error: {e}") + + async def write_data(self, data: bytes): + """写数据到 BLE 设备""" + await self.write_command(data) + + def send_ble_status(self, client: TcpClient): + """发送 BLE 状态到客户端""" + status = BleStatusInfo( + connected=self.connected, + device_name=self.device_name, + mac_address=self.device_mac, + is_target_device=self.connected + ) + packet = build_ble_status_packet(status) + client.send(packet) + + def send_device_info(self, client: TcpClient): + """发送设备信息到客户端""" + packet = build_device_info_packet(self.device_status) + client.send(packet) + + def remove_client(self, client: TcpClient): + """移除客户端""" + if client in self.clients: + self.clients.remove(client) + + def disconnect(self): + """断开 BLE 连接""" + if self.device_iface and self.connected: + try: + self.device_iface.Disconnect() + except Exception as e: + logger.error(f"Disconnect error: {e}") + + +async def list_devices(): + """列出所有可用的 BLE 设备""" + logger.info("Scanning for BLE devices...") + try: + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + + object_manager = dbus.Interface( + bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + found_devices = [] + for path, interfaces in objects.items(): + if "org.bluez.Device1" in interfaces: + props = interfaces["org.bluez.Device1"] + name = props.get("Name", "Unknown") + address = props.get("Address", "Unknown") + connected = props.get("Connected", False) + paired = props.get("Paired", False) + trusted = props.get("Trusted", False) + + if paired or trusted: + found_devices.append({ + "name": name, + "address": address, + "connected": connected, + "path": path + }) + logger.info(f" [{'+' if connected else ' '}] {address} - {name}") + + if not found_devices: + logger.info("No paired devices found. Pair with bluetoothctl first:") + logger.info(" sudo bluetoothctl") + logger.info(" [bluetooth]# scan on") + logger.info(" [bluetooth]# pair ") + logger.info(" [bluetooth]# trust ") + + except Exception as e: + logger.error(f"List devices error: {e}") + import traceback + traceback.print_exc() + + +async def main(): + parser = argparse.ArgumentParser(description="BLE TCP Bridge for Linux") + parser.add_argument("--mac", type=str, help="Device MAC address (e.g., AA:BB:CC:DD:EE:FF)") + parser.add_argument("--port", type=int, default=9000, help="TCP server port (default: 9000)") + parser.add_argument("--list", action="store_true", help="List paired BLE devices and exit") + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + args = parser.parse_args() + + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + + if args.list: + await list_devices() + return + + bridge = BleBridge(device_mac=args.mac) + + # 信号处理 + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, bridge.disconnect) + + await bridge.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/BLE_tcp_bridge_linux/bridge_gui.py b/BLE_tcp_bridge_linux/bridge_gui.py new file mode 100644 index 00000000..6ea3e27d --- /dev/null +++ b/BLE_tcp_bridge_linux/bridge_gui.py @@ -0,0 +1,1120 @@ +#!/usr/bin/env python3 +""" +BLE TCP Bridge for Linux - PyQt5 GUI 版 +功能对标 Windows 版 BLE_tcp_bridge +适配 Ubuntu 20.04 +""" + +import asyncio +import json +import logging +import os +import signal +import socket +import struct +import sys +import time +import traceback +from dataclasses import dataclass, asdict +from enum import IntEnum +from pathlib import Path +from typing import Optional, List, Dict + +import dbus +import dbus.service +import dbus.mainloop.glib +from PyQt5.QtCore import Qt, pyqtSignal, QObject, QThread, QSize +from PyQt5.QtGui import QFont, QIcon, QKeyEvent, QTextCursor, QColor +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QComboBox, QPushButton, QTextEdit, QCheckBox, + QSystemTrayIcon, QMenu, QMessageBox, QAction +) +from gi.repository import GLib + +# 配置日志 +LOG_FILE = Path("/tmp") / "ble_bridge.log" +LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE, encoding='utf-8'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +CONFIG_PATH = Path.home() / ".config" / "ahakey" / "ble_bridge.json" + + +class PacketType(IntEnum): + WRITE_DATA = 0x01 + WRITE_COMMAND = 0x02 + QUERY_BLE_STATUS = 0x03 + QUERY_DEVICE_INFO = 0x04 + BLE_NOTIFY = 0x81 + BLE_STATUS_RESP = 0x82 + DEVICE_INFO_RESP = 0x83 + + +@dataclass +class AppConfig: + ble_name: str = "" + ble_mac: str = "" + server_port: int = 9000 + start_minimized: bool = False + + @property + def has_saved_device(self) -> bool: + return bool(self.ble_name and self.ble_mac) + + def save(self): + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, 'w') as f: + json.dump(asdict(self), f, indent=2) + + @classmethod + def load(cls) -> 'AppConfig': + try: + if CONFIG_PATH.exists(): + with open(CONFIG_PATH) as f: + data = json.load(f) + return cls(**data) + except Exception as e: + logger.warning(f"加载配置失败: {e}") + return cls() + + +def build_packet(pkt_type: PacketType, data: bytes = None) -> bytes: + length = len(data) if data else 0 + header = bytes([pkt_type, length & 0xFF, (length >> 8) & 0xFF]) + return header + (data if data else b'') + + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: + return "127.0.0.1" + + +class TcpClient: + def __init__(self, reader, writer, bridge): + self.reader = reader + self.writer = writer + self.bridge = bridge + self.addr = writer.get_extra_info('peername') + self.last_activity = time.time() # 记录最后活动时间 + + async def handle(self): + try: + while True: + try: + # 添加读取超时(60秒) + header = await asyncio.wait_for( + self.reader.readexactly(3), + timeout=60.0 + ) + self.last_activity = time.time() # 更新活动时间 + pkt_type = header[0] + length = header[1] | (header[2] << 8) + data = b'' + if length > 0: + data = await asyncio.wait_for( + self.reader.readexactly(length), + timeout=30.0 # 数据读取超时30秒 + ) + await self.bridge.handle_packet(self, pkt_type, data) + except asyncio.TimeoutError: + logger.warning(f"TCP client {self.addr} read timeout (60s)") + self.bridge.log("orange", f"TCP 客户端 {self.addr} 读取超时,断开连接") + break + except asyncio.IncompleteReadError: + self.bridge.log("darkcyan", f"TCP 客户端已断开: {self.addr}") + except Exception as e: + logger.error(f"TCP client {self.addr} error: {e}") + finally: + self.bridge.remove_client(self) + + def send(self, data: bytes): + try: + self.writer.write(data) + self.last_activity = time.time() # 发送数据也更新活动时间 + except Exception as e: + logger.error(f"Send to {self.addr} failed: {e}") + + +class AsyncWorker(QThread): + log_signal = pyqtSignal(str, str) + device_found = pyqtSignal(str, str) + connected_signal = pyqtSignal(str) + disconnected_signal = pyqtSignal() + status_update = pyqtSignal(str) + client_count_changed = pyqtSignal(int) + + def __init__(self, config: AppConfig): + super().__init__() + self.config = config + self.bus: Optional[dbus.Bus] = None + self.device_path: Optional[str] = None + self.device_iface = None + self.write_char_path: Optional[str] = None + self.notify_char_path: Optional[str] = None + self.command_char_path: Optional[str] = None # 0x7343 - Write Without Response + self.data_char_path: Optional[str] = None # 0x7341 - Write + self.connected = False + self.device_name = "" + self.clients: List[TcpClient] = [] + self.running = False + self.auto_connecting = False + self.target_confirmed = False + self.devices_map: Dict[str, str] = {} + self.server = None + self.claude_state: Optional[bytes] = None # 保存 Claude 状态,用于重连后重发 + self.device_status: Dict[str, any] = {} # 保存设备状态信息 + self._signal_matches = [] # 追踪已注册的 D-Bus 信号接收器,防止累积 + self._reconnecting = False # 防止多个断开事件触发多次自动重连 + + self.TARGET_SERVICE = "0000ffe0-0000-1000-8000-00805f9b34fb" + self.DEVICE_STATUS_QUERY = bytes([0xAA, 0xBB, 0x00, 0xCC, 0xDD]) + + def log(self, color: str, message: str): + self.log_signal.emit(color, message) + logger.info(f"[{color}] {message}") + + def run(self): + # 必须在任何 dbus.SystemBus() 调用之前初始化 GLib mainloop + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.running = True + + # 启动 GLib mainloop 在单独线程中(用于 D-Bus 信号处理) + import threading + self.glib_thread = threading.Thread(target=self._run_glib_loop, daemon=True) + self.glib_thread.start() + + try: + self.loop.run_until_complete(self.main_loop()) + except Exception as e: + logger.error(f"Worker error: {e}") + finally: + self.running = False + self.loop.close() + + def _run_glib_loop(self): + """GLib mainloop 运行在单独线程,用于处理 D-Bus 信号""" + try: + self.glib_loop = GLib.MainLoop() + self.glib_loop.run() + except Exception as e: + logger.error(f"GLib loop error: {e}") + + async def main_loop(self): + try: + # 获取 D-Bus 总线连接 + self.bus = dbus.SystemBus() + except Exception as e: + self.log("red", f"D-Bus 连接失败: {e}") + return + + # 启动 TCP 服务器 + await self.start_tcp_server() + + # 自动开始扫描 + self.start_scan() + + # 保持运行 + while self.running: + await asyncio.sleep(0.1) + + def start_scan(self): + self.devices_map.clear() + self.log("blue", "自动扫描蓝牙设备中...") + + # 调用 StartDiscovery() 主动扫描 + try: + adapter_path = "/org/bluez/hci0" + adapter = dbus.Interface( + self.bus.get_object("org.bluez", adapter_path), + "org.bluez.Adapter1" + ) + adapter.StartDiscovery( + reply_handler=lambda: self.log("blue", "开始 BLE 扫描..."), + error_handler=lambda e: self.log("orange", f"扫描启动失败: {e}") + ) + except Exception as e: + self.log("orange", f"启动扫描异常: {e}") + + self.scan_devices() + + if self.config.has_saved_device: + self.log("blue", f"目标设备: {self.config.ble_name} [{self.config.ble_mac}]") + self.start_retry_timer() + + def stop_scan(self): + """停止BLE扫描,避免干扰连接""" + try: + adapter_path = "/org/bluez/hci0" + adapter = dbus.Interface( + self.bus.get_object("org.bluez", adapter_path), + "org.bluez.Adapter1" + ) + adapter.StopDiscovery( + reply_handler=lambda: self.log("blue", "BLE 扫描已停止"), + error_handler=lambda e: self.log("orange", f"停止扫描失败: {e}") + ) + except Exception as e: + logger.error(f"Stop scan error: {e}") + + def scan_devices(self): + try: + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + for path, interfaces in objects.items(): + if "org.bluez.Device1" in interfaces: + props = interfaces["org.bluez.Device1"] + name = props.get("Name", "") + address = props.get("Address", "") + + if not name: + continue + + self.devices_map[address] = name + self.device_found.emit(name, address) + + # 自动连接已保存的设备 + if (self.config.has_saved_device + and not self.auto_connecting + and not self.connected): + if (name == self.config.ble_name + and address.upper() == self.config.ble_mac.upper()): + self.auto_connecting = True + self.log("blue", "发现目标设备, 自动连接...") + self.connect_to_device(path, name, address) + + except Exception as e: + logger.error(f"Scan error: {e}") + + def _cleanup_signal_receivers(self): + """移除所有已注册的 D-Bus 信号接收器,防止重连后累积导致回调重复触发""" + for match in self._signal_matches: + try: + match.remove() + except Exception: + pass + self._signal_matches = [] + + def connect_to_device(self, path: str, name: str, address: str): + try: + self.device_path = path + self.device_name = name + + self.device_iface = dbus.Interface( + self.bus.get_object("org.bluez", path), + "org.bluez.Device1" + ) + + props_iface = dbus.Interface( + self.bus.get_object("org.bluez", path), + "org.freedesktop.DBus.Properties" + ) + + paired = bool(props_iface.Get("org.bluez.Device1", "Paired")) + if not paired: + self.log("orange", f"设备未配对,正在配对: {name}") + self.device_iface.Pair( + reply_handler=lambda: self._on_pair_reply(path, name, address), + error_handler=lambda e: self._on_pair_error(e) + ) + return + + self.log("blue", f"设备已配对,直接连接: {name}") + + # 清理旧接收器,防止多次重连后信号被触发多次 + self._cleanup_signal_receivers() + match = self.bus.add_signal_receiver( + self.on_properties_changed, + signal_name="PropertiesChanged", + dbus_interface="org.freedesktop.DBus.Properties", + path=path + ) + self._signal_matches.append(match) + + self.device_iface.Connect( + reply_handler=lambda: self._on_connect_reply(), + error_handler=lambda e: self._on_connect_error(e) + ) + except Exception as e: + self.log("red", f"连接失败: {e}") + self.auto_connecting = False + + def _on_pair_reply(self, path: str, name: str, address: str): + self.log("green", f"设备配对成功: {name}") + self.connect_to_device(path, name, address) + + def _on_pair_error(self, error: str): + self.log("red", f"设备配对失败: {error}") + self.auto_connecting = False + + def _on_connect_reply(self): + self.connected = True + self.log("blue", f"Connected: {self.device_name}") + self.connected_signal.emit(self.device_name) + self.stop_scan() # 连接成功后停止BLE扫描,避免干扰连接 + # 检查 GATT 服务是否已就绪 + try: + props = dbus.Interface( + self.bus.get_object("org.bluez", self.device_path), + "org.freedesktop.DBus.Properties" + ) + services_resolved = bool(props.Get("org.bluez.Device1", "ServicesResolved")) + if services_resolved: + self.discover_services() + else: + self.log("blue", "等待 GATT 服务发现...") + # 服务发现就绪后会在 on_properties_changed 中触发 + except Exception as e: + logger.error(f"Check ServicesResolved error: {e}") + self.discover_services() + + def _on_connect_error(self, error): + self.log("red", f"BLE 连接失败: {error}") + self.connected = False + self.auto_connecting = False + # 连接失败后(含 NoReply)等待30秒再重试,给设备足够时间完成内部重置 + if self.config.has_saved_device and not self._reconnecting: + self._reconnecting = True + self.log("blue", "30秒后重试连接...") + GLib.timeout_add(30000, self._auto_reconnect_once) + + def discover_services(self): + try: + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + services_found = [] + for path, interfaces in objects.items(): + if "org.bluez.GattService1" in interfaces: + props = interfaces["org.bluez.GattService1"] + uuid = props.get("UUID", "").lower() + services_found.append(f"{uuid}") + + self.log("blue", f"=== 已发现 {len(services_found)} 个 GATT 服务 ===") + for i, svc in enumerate(services_found): + is_target = " [目标]" if svc == self.TARGET_SERVICE.lower() else "" + self.log("blue", f" {i+1}. {svc}{is_target}") + + write_char_path = None + notify_char_path = None + command_char_path = None # 0x7343 - Write Without Response (命令特征) + data_char_path = None # 0x7341 - Write (数据特征) + + self.log("blue", "=== 扫描所有特征 ===") + for path, interfaces in objects.items(): + if "org.bluez.GattCharacteristic1" in interfaces: + char_props = interfaces["org.bluez.GattCharacteristic1"] + char_uuid = char_props.get("UUID", "").lower() + flags = char_props.get("Flags", []) + + has_write = "write" in flags or "write-without-response" in flags + has_notify = "notify" in flags or "indicate" in flags + has_read = "read" in flags + + if has_write and has_notify: + write_char_path = path + notify_char_path = path + self.log("green", f" -> 找到 Write+Notify 特征: {char_uuid}") + elif has_write and not has_notify: + write_char_path = path + self.log("green", f" -> 找到 Write 特征: {char_uuid}") + elif has_notify and not has_write: + notify_char_path = path + self.log("green", f" -> 找到 Notify 特征: {char_uuid}") + elif has_read: + data_char_path = path + self.log("blue", f" -> 找到 Read 特征: {char_uuid}") + + for path, interfaces in objects.items(): + if "org.bluez.GattCharacteristic1" in interfaces: + char_props = interfaces["org.bluez.GattCharacteristic1"] + char_uuid = char_props.get("UUID", "").lower() + flags = char_props.get("Flags", []) + + has_write = "write" in flags or "write-without-response" in flags + has_notify = "notify" in flags or "indicate" in flags + has_read = "read" in flags + + if char_uuid == "00007344-0000-1000-8000-00805f9b34fb": + notify_char_path = path + self.log("green", f" -> 找到 AhaKey Notify 特征 [0x7344]: {char_uuid}") + elif char_uuid == "00007343-0000-1000-8000-00805f9b34fb": + command_char_path = path + self.log("green", f" -> 找到 AhaKey Write 命令特征 [0x7343]: {char_uuid}") + elif char_uuid == "00007341-0000-1000-8000-00805f9b34fb": + data_char_path = path + self.log("green", f" -> 找到 AhaKey Write 数据特征 [0x7341]: {char_uuid}") + + # 如果没有找到专用的0x7343/0x7341,则使用通用Write特征 + if not command_char_path and write_char_path: + command_char_path = write_char_path + if not data_char_path and write_char_path: + data_char_path = write_char_path + + self.write_char_path = command_char_path # 默认使用命令特征 + self.notify_char_path = notify_char_path + self.command_char_path = command_char_path # 0x7343 - Write Without Response + self.data_char_path = data_char_path # 0x7341 - Write + + self.log("blue", f"=== 特征查找结果 ===") + self.log("blue", f" 命令特征路径 [0x7343]: {command_char_path}") + self.log("blue", f" 数据特征路径 [0x7341]: {data_char_path}") + self.log("blue", f" Notify特征路径: {notify_char_path}") + + if not command_char_path: + self.log("orange", "未找到命令特征 [0x7343],重试中...") + self._retry_discover_services(3) + return + + if not notify_char_path: + self.log("orange", "未找到 Notify 特征,重试中...") + self._retry_discover_services(3) + return + + self.log("blue", "发现目标服务") + + if self.notify_char_path: + self._subscribe_notify(self.notify_char_path) + + if self.write_char_path and self.notify_char_path: + self.target_confirmed = True + self.save_device_config() + # 状态查询会在 _subscribe_notify 的回调中发送 + + except Exception as e: + logger.error(f"Discover services error: {e}") + + def _on_connected_delayed(self): + """连接后的延迟操作 - 在 StartNotify 成功后被调用""" + # StartNotify 是异步的,回调触发时设备可能已断开,直接返回避免启动无效保活 + if not self.connected: + self.log("orange", "StartNotify 回调触发时已断开,忽略") + return + + # 延迟500ms发送状态查询,确保BlueZ完成所有GATT操作 + GLib.timeout_add(500, self._send_initial_status_query_once) + + # 启动保活定时器,每30秒发送一次心跳防止设备休眠 + self.start_keepalive_timer() + + # 重连后重发保存的 Claude 状态到 BLE 设备 + if self.claude_state: + self.log("blue", f"重发 Claude 状态到设备: {self.claude_state.hex()}") + claude_state = self.claude_state + GLib.timeout_add(600, lambda: ( + self.send_ble_command(claude_state, char_path=self.command_char_path, max_chunk=20), False + )[1]) + + def _send_initial_status_query_once(self): + """发送初始状态查询(GLib 单次回调,返回 False 不重复)""" + self.log("blue", "发送初始状态查询...") + self.send_device_status_query() + return False # GLib: 只执行一次 + + def _retry_discover_services(self, retries_left: int): + """重试发现服务,每次间隔1秒""" + if retries_left <= 0: + self.log("orange", "重试次数用尽,请手动重新连接设备") + self.disconnect_device() + return + + def retry(): + if self.connected and not self.target_confirmed: + self.log("blue", f"重试发现服务 (还剩 {retries_left - 1} 次)...") + self.discover_services() + return False # GLib: 只执行一次 + + GLib.timeout_add(1000, retry) + + def _subscribe_notify(self, char_path: str): + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", char_path), + "org.bluez.GattCharacteristic1" + ) + # 在 StartNotify 成功后再发送状态查询 + def on_notify_started(): + self.log("green", "通知已启用") + self._on_connected_delayed() + + char_iface.StartNotify( + reply_handler=on_notify_started, + error_handler=lambda e: self.log("red", f"通知订阅失败: {e}") + ) + # 注册特征值变化监听 + match = self.bus.add_signal_receiver( + self.on_characteristic_changed, + signal_name="PropertiesChanged", + dbus_interface="org.freedesktop.DBus.Properties", + path=char_path + ) + self._signal_matches.append(match) + self.log("blue", f"已订阅特征通知: {char_path}") + except Exception as e: + logger.error(f"Subscribe error: {e}") + + def on_properties_changed(self, interface: str, changed: dict, invalidated: list): + if "Connected" in changed: + connected = bool(changed["Connected"]) + if not connected and self.connected: + self.connected = False + self.target_confirmed = False + self.write_char_path = None + self.notify_char_path = None + self.command_char_path = None + self.data_char_path = None + self.stop_keepalive_timer() + self.log("red", f"Disconnected: {self.device_name}") + self.disconnected_signal.emit() + # _reconnecting 标志确保只触发一次自动重连,即使信号接收器仍有残余 + if self.config.has_saved_device and not self._reconnecting: + self._reconnecting = True + self.log("blue", "2秒后自动重连...") + GLib.timeout_add(2000, self._auto_reconnect_once) + if "ServicesResolved" in changed: + services_resolved = bool(changed["ServicesResolved"]) + if services_resolved and not self.target_confirmed: + self.log("blue", "GATT 服务已发现") + self.discover_services() + + def _auto_reconnect_once(self): + """自动重连(GLib 单次回调)""" + self._reconnecting = False + if not self.connected and self.running and self.config.has_saved_device: + self.log("blue", f"自动重连: {self.config.ble_name} [{self.config.ble_mac}]") + self.auto_connecting = False # 重置,允许重新触发 + self.scan_devices() + return False # GLib: 只执行一次 + + def on_characteristic_changed(self, interface: str, changed: dict, invalidated: list): + if "Value" in changed: + data = bytes(changed["Value"]) + # 更新最后收到数据的时间(用于超时检测) + self.last_ble_data_time = time.time() + self.log("blue", f"收到 BLE 数据: 长度={len(data)}, 原始数据={data.hex()}") + + # 检查是否是设备状态(格式: [0xAA][0xBB][0x00][8字节设备信息][0xCC][0xDD] = 13字节) + if len(data) == 13 and data[0] == 0xAA and data[1] == 0xBB and data[2] == 0x00 and data[11] == 0xCC and data[12] == 0xDD: + battery = data[3] + signal = data[4] + firmware = f"{data[5]}.{data[6]}" + # 保存设备状态,对标 Windows 版 Protocol.cs 格式 + self.device_status = { + "battery": battery, + "signal": signal, + "firmware": firmware, + "work_mode": data[7] if len(data) > 7 else 0, + "light_mode": data[8] if len(data) > 8 else 0, + "switch_state": data[9] if len(data) > 9 else 0 + } + self.status_update.emit(f"电量={battery}%") + self.log("green", f"设备状态: 电量={battery}% 信号={signal} 固件={firmware}") + # 设备状态包不转发给 TCP 客户端 + return + + # 检查是否是 Claude 状态(0xAA BB 90 ...) + if len(data) >= 3 and data[0] == 0xAA and data[1] == 0xBB and data[2] == 0x90: + self.claude_state = data # 保存 Claude 状态 + self.log("green", f"Claude 状态已保存: {data.hex()}") + + # 转发给 TCP 客户端(除了设备状态之外的所有数据) + packet = build_packet(PacketType.BLE_NOTIFY, data) + for client in self.clients: + client.send(packet) + + def start_keepalive_timer(self): + """启动保活定时器,每30秒发送心跳防止设备休眠(使用 GLib,线程安全)""" + self.stop_keepalive_timer() + self._keepalive_source_id = GLib.timeout_add(10000, self._keepalive_ping) + self.last_ble_data_time = time.time() # 初始化,避免首次检查误判 + self.log("blue", "保活定时器已启动 (10s间隔)") + + def stop_keepalive_timer(self): + """停止保活定时器""" + if hasattr(self, '_keepalive_source_id') and self._keepalive_source_id: + GLib.source_remove(self._keepalive_source_id) + self._keepalive_source_id = None + + def _keepalive_ping(self): + """保活心跳:发送状态查询(GLib 回调,返回 True 继续重复)""" + if not self.connected or not self.write_char_path: + return True # 继续等待,BlueZ 的 Connected 属性变化负责检测真实断开 + + # 检查上次收到数据的时间,记录警告但不主动断开(由 BlueZ 负责断连事件) + elapsed = time.time() - self.last_ble_data_time + if elapsed > 60: # 60秒无数据,记录警告 + self.log("orange", f"BLE 60秒无数据 ({elapsed:.0f}s),设备可能无响应") + + self.log("blue", "保活心跳...") + self.send_device_status_query() + return True # GLib: 继续重复 + + def send_device_status_query(self): + """发送状态查询,返回是否成功""" + if not self.write_char_path: + return False + + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", self.write_char_path), + "org.bluez.GattCharacteristic1" + ) + self.log("blue", f"发送状态查询到: {self.write_char_path}") + self.log("blue", f"查询命令: {self.DEVICE_STATUS_QUERY.hex()}") + + success = [False] # 使用列表来在回调中修改值 + + def on_success(): + success[0] = True + self.log("green", "状态查询已发送,等待设备回复") + + def on_error(e): + success[0] = False + self.log("red", f"状态查询发送失败: {e}") + + char_iface.WriteValue(list(self.DEVICE_STATUS_QUERY), {}, + reply_handler=on_success, + error_handler=on_error) + + return success[0] + + except Exception as e: + logger.error(f"Query error: {e}") + self.log("red", f"状态查询异常: {e}") + return False + + def send_ble_command(self, data: bytes, char_path: str = None, max_chunk: int = 20): + """发送 BLE 命令,支持分片 + + Args: + data: 要发送的数据 + char_path: 特征路径,默认使用 write_char_path + max_chunk: 每包最大字节数,默认20(BLE MTU) + """ + target_path = char_path or self.write_char_path + if not target_path: + self.log("orange", "无可用的写入特征路径") + return + + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", target_path), + "org.bluez.GattCharacteristic1" + ) + + # 如果数据超过 max_chunk,进行分片 + if len(data) <= max_chunk: + char_iface.WriteValue(list(data), {}, + reply_handler=lambda: None, + error_handler=lambda e: logger.error(f"Write error: {e}")) + self.log("blue", f"BLE发送: {data.hex()}") + else: + # 分片发送 + chunks = [data[i:i + max_chunk] for i in range(0, len(data), max_chunk)] + total = len(chunks) + for i, chunk in enumerate(chunks, 1): + char_iface.WriteValue(list(chunk), {}, + reply_handler=lambda: None, + error_handler=lambda e: logger.error(f"Write error: {e}")) + self.log("blue", f"BLE发送: {chunk.hex()} ({i}/{total})") + # BlueZ 有自己的 GATT 写入队列,无需额外延迟 + except Exception as e: + logger.error(f"Send command error: {e}") + + def save_device_config(self): + if self.device_name and self.device_path: + try: + props = dbus.Interface( + self.bus.get_object("org.bluez", self.device_path), + "org.freedesktop.DBus.Properties" + ) + address = props.Get("org.bluez.Device1", "Address") + self.config.ble_name = self.device_name + self.config.ble_mac = str(address) + self.config.save() + self.log("blue", f"已保存设备: {self.device_name} [{address}]") + except Exception as e: + logger.error(f"Save config error: {e}") + + def start_retry_timer(self): + # 8秒后重试 + asyncio.run_coroutine_threadsafe(self._retry_after_delay(), self.loop) + + async def _retry_after_delay(self): + await asyncio.sleep(8) + if not self.connected and self.running: + self.log("gray", "重新扫描蓝牙设备...") + self.scan_devices() + + def software_disconnect(self): + """软件层面断开:清理内部状态,停止保活,但保持 BLE 物理连接""" + # 清理信号接收器,停止接收 BLE 通知和属性变化事件 + self._cleanup_signal_receivers() + + self.connected = False + self.target_confirmed = False + self.write_char_path = None + self.notify_char_path = None + self.command_char_path = None + self.data_char_path = None + self.stop_keepalive_timer() + + # 注意:不调用 StopNotify!部分设备(如 AhaKey)在 CCCD 清零后会主动断开 BLE 连接 + + self.log("blue", "软件已断开连接(BLE 物理连接保持)") + self.disconnected_signal.emit() + + def disconnect_device(self): + """真实断开:同时断开 BlueZ 层面的 BLE 连接""" + self.software_disconnect() + + if self.device_iface: + try: + self.log("blue", f"正在断开 BLE 连接: {self.device_path}") + self.device_iface.Disconnect() + self.log("blue", "BLE 设备连接已断开") + except Exception as e: + self.log("red", f"断开失败: {e}") + + def manual_connect(self, name: str, address: str): + if self.connected: + return + self.auto_connecting = True + for path, interfaces in self._get_all_objects().items(): + if "org.bluez.Device1" in interfaces: + props = interfaces["org.bluez.Device1"] + if props.get("Address", "") == address: + self.connect_to_device(path, name, address) + return + self.log("red", f"未找到设备: {name}") + + def _get_all_objects(self): + try: + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + return object_manager.GetManagedObjects() + except: + return {} + + async def start_tcp_server(self): + try: + self.server = await asyncio.start_server( + self.handle_tcp_client, + "0.0.0.0", + self.config.server_port + ) + self.log("darkcyan", f"TCP服务器已启动, 监听端口: {self.config.server_port}") + except Exception as e: + self.log("red", f"TCP服务器启动失败: {e}") + + async def handle_tcp_client(self, reader, writer): + addr = writer.get_extra_info('peername') + self.log("darkcyan", f"TCP 客户端已连接: {addr}") + client = TcpClient(reader, writer, self) + self.clients.append(client) + self.client_count_changed.emit(len(self.clients)) + await client.handle() + + async def handle_packet(self, client: TcpClient, pkt_type: int, data: bytes): + if pkt_type == PacketType.WRITE_COMMAND: + # 捕获 Claude 状态(0xAA BB 90 ...) + if len(data) >= 3 and data[0] == 0xAA and data[1] == 0xBB and data[2] == 0x90: + self.claude_state = data + self.log("blue", f"捕获 Claude 状态: {data.hex()}") + # 命令写入 0x7343,每包最大 20 字节(BLE MTU) + self.send_ble_command(data, char_path=self.command_char_path, max_chunk=20) + elif pkt_type == PacketType.WRITE_DATA: + # 数据写入 0x7341,每包最大 200 字节 + self.send_ble_command(data, char_path=self.data_char_path, max_chunk=200) + elif pkt_type == PacketType.QUERY_BLE_STATUS: + self.send_ble_status(client) + elif pkt_type == PacketType.QUERY_DEVICE_INFO: + self.send_device_info(client) + + def send_ble_status(self, client: TcpClient): + status_data = bytes([ + 1 if self.connected else 0, + len(self.device_name.encode()) + ]) + self.device_name.encode() + bytes([ + len(self.config.ble_mac.encode()) + ]) + self.config.ble_mac.encode() + bytes([ + 1 if self.target_confirmed else 0 + ]) + packet = build_packet(PacketType.BLE_STATUS_RESP, status_data) + client.send(packet) + + def send_device_info(self, client: TcpClient): + # 返回真实设备状态信息,对标 Windows 版 Protocol.cs 格式 + battery = self.device_status.get("battery", 0) + signal = self.device_status.get("signal", 0) + firmware = self.device_status.get("firmware", "0.0") + work_mode = self.device_status.get("work_mode", 0) + light_mode = self.device_status.get("light_mode", 0) + switch_state = self.device_status.get("switch_state", 0) + + # 解析固件版本 + fw_parts = firmware.split(".") + fw_major = int(fw_parts[0]) if fw_parts else 0 + fw_minor = int(fw_parts[1]) if len(fw_parts) > 1 else 0 + + info = bytes([ + battery, # data[0] 电量 + signal, # data[1] 信号 + fw_major, # data[2] 固件主版本 + fw_minor, # data[3] 固件次版本 + work_mode, # data[4] 工作模式 + light_mode, # data[5] 灯光模式 + switch_state, # data[6] 开关状态 + 0 # data[7] Reserve + ]) + packet = build_packet(PacketType.DEVICE_INFO_RESP, info) + client.send(packet) + self.log("blue", f"发送设备信息: 电量={battery}% 信号={signal} 固件={firmware}") + + def remove_client(self, client: TcpClient): + if client in self.clients: + self.clients.remove(client) + self.client_count_changed.emit(len(self.clients)) + + def stop(self): + self.running = False + # 退出时保持BLE连接不断开,让设备继续连接到电脑蓝牙 + self.log("blue", "程序正在退出,保持BLE设备连接...") + if self.server: + self.server.close() + # 优雅退出 GLib mainloop + if hasattr(self, 'glib_loop') and self.glib_loop.is_running(): + self.glib_loop.quit() + self.log("blue", "BLE Bridge 程序已停止(设备保持连接)") + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.config = AppConfig.load() + self.setWindowTitle("VibeCoding Keyboard BLE Driver") + self.setMinimumSize(800, 500) + + # 创建系统托盘 + self.tray_icon = QSystemTrayIcon(self) + self.tray_icon.setToolTip("BLE TCP Bridge") + # 使用默认图标 + self.tray_icon.setIcon(self.style().standardIcon( + self.style().SP_ComputerIcon)) + + tray_menu = QMenu(self) + show_action = QAction("显示窗口", self) + show_action.triggered.connect(self.show_window) + tray_menu.addAction(show_action) + tray_menu.addSeparator() + exit_action = QAction("退出程序", self) + exit_action.triggered.connect(self.force_exit) + tray_menu.addAction(exit_action) + self.tray_icon.setContextMenu(tray_menu) + self.tray_icon.activated.connect(self.on_tray_activated) + self.tray_icon.show() + + self._setup_ui() + self._start_worker() + + if self.config.start_minimized or "--minimized" in sys.argv: + self.hide() + else: + self.show() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(10) + + # 顶部控制区 + top = QHBoxLayout() + + # 设备选择 + self.device_combo = QComboBox() + self.device_combo.setMinimumWidth(180) + self.device_combo.addItem("扫描中...") + top.addWidget(QLabel("设备:")) + top.addWidget(self.device_combo) + + # 连接按钮 + self.btn_connect = QPushButton("连接") + self.btn_connect.setFixedWidth(80) + self.btn_connect.clicked.connect(self.on_connect_clicked) + top.addWidget(self.btn_connect) + + top.addStretch() + + # 状态信息 + info_layout = QVBoxLayout() + self.label_device = QLabel("当前连接设备: 无") + self.label_tcp = QLabel(f"TCP服务: {get_local_ip()}:{self.config.server_port} (客户端:0)") + info_layout.addWidget(self.label_device) + info_layout.addWidget(self.label_tcp) + top.addLayout(info_layout) + + top.addStretch() + + # 退出按钮 + self.btn_exit = QPushButton("退出程序") + self.btn_exit.setFixedWidth(90) + self.btn_exit.clicked.connect(self.force_exit) + top.addWidget(self.btn_exit) + + layout.addLayout(top) + + # 选项区 + options = QHBoxLayout() + self.chk_minimize = QCheckBox("下次最小化启动") + self.chk_minimize.setChecked(self.config.start_minimized) + self.chk_minimize.stateChanged.connect(self.on_minimize_changed) + options.addWidget(self.chk_minimize) + layout.addLayout(options) + + # 日志区 + self.log_edit = QTextEdit() + self.log_edit.setReadOnly(True) + self.log_edit.setFont(QFont("Microsoft YaHei", 10)) + self.log_edit.setStyleSheet("background-color: #f5f5f5;") + layout.addWidget(self.log_edit) + + def _start_worker(self): + self.worker = AsyncWorker(self.config) + self.worker.log_signal.connect(self.on_log) + self.worker.device_found.connect(self.on_device_found) + self.worker.connected_signal.connect(self.on_connected) + self.worker.disconnected_signal.connect(self.on_disconnected) + self.worker.status_update.connect(self.on_status_update) + self.worker.client_count_changed.connect(self.on_client_count_changed) + self.worker.start() + + def on_log(self, color: str, message: str): + colors = { + "red": "#CC0000", "blue": "#0000CC", "green": "#006600", + "darkcyan": "#008B8B", "orange": "#FF8C00", "gray": "#808080" + } + hex_color = colors.get(color, "#000000") + + self.log_edit.append( + f'{message}' + ) + cursor = self.log_edit.textCursor() + cursor.movePosition(QTextCursor.End) + self.log_edit.setTextCursor(cursor) + + def on_device_found(self, name: str, address: str): + # 检查是否已存在 + for i in range(self.device_combo.count()): + if self.device_combo.itemData(i) == address: + return + + if self.device_combo.count() == 1 and self.device_combo.itemText(0) == "扫描中...": + self.device_combo.clear() + + if name and name.startswith("AhaKey"): + self.device_combo.addItem(f"{name} [{address}]", address) + + def on_connect_clicked(self): + if self.btn_connect.text() == "断开": + # 只断开软件连接,保持电脑与设备的 BLE 物理连接 + self.worker.software_disconnect() + return + + idx = self.device_combo.currentIndex() + if idx < 0: + return + address = self.device_combo.itemData(idx) + name = self.device_combo.currentText().split("[")[0].strip() + if address: + self.worker.manual_connect(name, address) + + def on_connected(self, name: str): + self._connected_device_name = name # 记录原始设备名 + self.label_device.setText(f"当前连接设备: {name}") + self.btn_connect.setText("断开") + self.btn_connect.setEnabled(True) + + def on_disconnected(self): + self.label_device.setText("当前连接设备: 无") + self.btn_connect.setText("连接") + self.btn_connect.setEnabled(True) + + def on_status_update(self, status: str): + name = getattr(self, '_connected_device_name', '') + if name: + self.label_device.setText(f"当前连接设备: {name} ({status})") + + def on_client_count_changed(self, count: int): + self.label_tcp.setText( + f"TCP服务: {get_local_ip()}:{self.config.server_port} (客户端:{count})" + ) + + def on_minimize_changed(self, state): + self.config.start_minimized = bool(state) + self.config.save() + + def on_tray_activated(self, reason): + if reason == QSystemTrayIcon.Trigger: + self.show_window() + + def show_window(self): + self.showNormal() + self.activateWindow() + self.raise_() + + def closeEvent(self, event): + # 关闭按钮最小化到托盘 + event.ignore() + self.hide() + self.tray_icon.showMessage( + "BLE Bridge", + "程序已最小化到系统托盘", + QSystemTrayIcon.Information, + 2000 + ) + + def force_exit(self): + self.worker.stop() + self.worker.wait(3000) + self.tray_icon.hide() + QApplication.instance().quit() + + +def main(): + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + window = MainWindow() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/BLE_tcp_bridge_linux/deb_package/DEBIAN/control b/BLE_tcp_bridge_linux/deb_package/DEBIAN/control new file mode 100644 index 00000000..3385b75c --- /dev/null +++ b/BLE_tcp_bridge_linux/deb_package/DEBIAN/control @@ -0,0 +1,11 @@ +Package: ahakey-ble-bridge +Version: 1.0.0 +Architecture: all +Maintainer: AhaKey +Description: BLE TCP Bridge for AhaKey Keyboard on Linux + A Bluetooth Low Energy bridge application that connects AhaKey devices + to the ahakeyconfig-ubuntu-java application via TCP. +Depends: python3, python3-dbus, python3-gi, python3-pyqt5, bluez +Homepage: https://ahakey.com +Section: misc +Priority: optional diff --git a/BLE_tcp_bridge_linux/deb_package/DEBIAN/postinst b/BLE_tcp_bridge_linux/deb_package/DEBIAN/postinst new file mode 100644 index 00000000..bcc7f756 --- /dev/null +++ b/BLE_tcp_bridge_linux/deb_package/DEBIAN/postinst @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +# 创建配置目录 +mkdir -p /etc/ahakey/ble-bridge + +# 设置启动脚本权限 +chmod +x /usr/bin/ahakey-ble-bridge + +echo "AhaKey BLE Bridge installed successfully" diff --git a/BLE_tcp_bridge_linux/deb_package/DEBIAN/prerm b/BLE_tcp_bridge_linux/deb_package/DEBIAN/prerm new file mode 100644 index 00000000..6f7e1f10 --- /dev/null +++ b/BLE_tcp_bridge_linux/deb_package/DEBIAN/prerm @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +# 清理配置文件(保留用户数据) +# rm -rf /etc/ahakey/ble-bridge + +echo "AhaKey BLE Bridge removed" diff --git a/BLE_tcp_bridge_linux/deb_package/opt/ahakey/ble-bridge/bridge_gui.py b/BLE_tcp_bridge_linux/deb_package/opt/ahakey/ble-bridge/bridge_gui.py new file mode 100644 index 00000000..24bb296f --- /dev/null +++ b/BLE_tcp_bridge_linux/deb_package/opt/ahakey/ble-bridge/bridge_gui.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 +""" +BLE TCP Bridge for Linux - PyQt5 GUI 版 +功能对标 Windows 版 BLE_tcp_bridge +适配 Ubuntu 20.04 +""" + +import asyncio +import json +import logging +import os +import signal +import socket +import struct +import sys +import traceback +from dataclasses import dataclass, asdict +from enum import IntEnum +from pathlib import Path +from typing import Optional, List, Dict + +import dbus +import dbus.service +import dbus.mainloop.glib +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QThread, QSize +from PyQt5.QtGui import QFont, QIcon, QKeyEvent, QTextCursor, QColor +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QComboBox, QPushButton, QTextEdit, QCheckBox, + QSystemTrayIcon, QMenu, QMessageBox, QAction +) +from gi.repository import GLib + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +CONFIG_PATH = Path.home() / ".config" / "ahakey" / "ble_bridge.json" + + +class PacketType(IntEnum): + WRITE_DATA = 0x01 + WRITE_COMMAND = 0x02 + QUERY_BLE_STATUS = 0x03 + QUERY_DEVICE_INFO = 0x04 + BLE_NOTIFY = 0x81 + BLE_STATUS_RESP = 0x82 + DEVICE_INFO_RESP = 0x83 + + +@dataclass +class AppConfig: + ble_name: str = "" + ble_mac: str = "" + server_port: int = 9000 + start_minimized: bool = False + + @property + def has_saved_device(self) -> bool: + return bool(self.ble_name and self.ble_mac) + + def save(self): + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, 'w') as f: + json.dump(asdict(self), f, indent=2) + + @classmethod + def load(cls) -> 'AppConfig': + try: + if CONFIG_PATH.exists(): + with open(CONFIG_PATH) as f: + data = json.load(f) + return cls(**data) + except Exception as e: + logger.warning(f"加载配置失败: {e}") + return cls() + + +def build_packet(pkt_type: PacketType, data: bytes = None) -> bytes: + length = len(data) if data else 0 + header = bytes([pkt_type, length & 0xFF, (length >> 8) & 0xFF]) + return header + (data if data else b'') + + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: + return "127.0.0.1" + + +class TcpClient: + def __init__(self, reader, writer, bridge): + self.reader = reader + self.writer = writer + self.bridge = bridge + self.addr = writer.get_extra_info('peername') + + async def handle(self): + try: + while True: + header = await self.reader.readexactly(3) + pkt_type = header[0] + length = header[1] | (header[2] << 8) + data = b'' + if length > 0: + data = await self.reader.readexactly(length) + await self.bridge.handle_packet(self, pkt_type, data) + except asyncio.IncompleteReadError: + pass + except Exception as e: + logger.error(f"TCP client error: {e}") + finally: + self.bridge.remove_client(self) + + def send(self, data: bytes): + try: + self.writer.write(data) + except Exception as e: + logger.error(f"Send to {self.addr} failed: {e}") + + +class AsyncWorker(QThread): + log_signal = pyqtSignal(str, str) + device_found = pyqtSignal(str, str) + connected_signal = pyqtSignal(str) + disconnected_signal = pyqtSignal() + status_update = pyqtSignal(str) + client_count_changed = pyqtSignal(int) + + def __init__(self, config: AppConfig): + super().__init__() + self.config = config + self.bus: Optional[dbus.Bus] = None + self.device_path: Optional[str] = None + self.device_iface = None + self.write_char_path: Optional[str] = None + self.notify_char_path: Optional[str] = None + self.data_char_path: Optional[str] = None + self.connected = False + self.device_name = "" + self.clients: List[TcpClient] = [] + self.running = False + self.auto_connecting = False + self.target_confirmed = False + self.devices_map: Dict[str, str] = {} + self.server = None + + self.TARGET_SERVICE = "0000ffe0-0000-1000-8000-00805f9b34fb" + self.DEVICE_STATUS_QUERY = bytes([0xAA, 0xBB, 0x00, 0xCC, 0xDD]) + + def log(self, color: str, message: str): + self.log_signal.emit(color, message) + + def run(self): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.running = True + try: + self.loop.run_until_complete(self.main_loop()) + except Exception as e: + logger.error(f"Worker error: {e}") + finally: + self.loop.close() + + async def main_loop(self): + try: + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + self.bus = dbus.SystemBus() + except Exception as e: + self.log("red", f"D-Bus 连接失败: {e}") + return + + # 启动 TCP 服务器 + await self.start_tcp_server() + + # 自动开始扫描 + self.start_scan() + + # 保持运行 + while self.running: + await asyncio.sleep(0.1) + + def start_scan(self): + self.devices_map.clear() + self.log("blue", "自动扫描蓝牙设备中...") + self.scan_devices() + + if self.config.has_saved_device: + self.log("blue", f"目标设备: {self.config.ble_name} [{self.config.ble_mac}]") + self.start_retry_timer() + + def scan_devices(self): + try: + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + for path, interfaces in objects.items(): + if "org.bluez.Device1" in interfaces: + props = interfaces["org.bluez.Device1"] + name = props.get("Name", "") + address = props.get("Address", "") + + if not name: + continue + + self.devices_map[address] = name + self.device_found.emit(name, address) + + # 自动连接已保存的设备 + if (self.config.has_saved_device + and not self.auto_connecting + and not self.connected): + if (name == self.config.ble_name + and address.upper() == self.config.ble_mac.upper()): + self.auto_connecting = True + self.log("blue", "发现目标设备, 自动连接...") + self.connect_to_device(path, name, address) + + except Exception as e: + logger.error(f"Scan error: {e}") + + def connect_to_device(self, path: str, name: str, address: str): + try: + self.device_path = path + self.device_name = name + + self.device_iface = dbus.Interface( + self.bus.get_object("org.bluez", path), + "org.bluez.Device1" + ) + + # 监听设备属性变化(Connected, ServicesResolved 等) + self.bus.add_signal_receiver( + self.on_properties_changed, + signal_name="PropertiesChanged", + dbus_interface="org.freedesktop.DBus.Properties", + path=path + ) + # 注意:特征值变化监听在 _subscribe_notify 中注册,因为特征路径在发现服务后才确定 + + self.device_iface.Connect( + reply_handler=lambda: self._on_connect_reply(), + error_handler=lambda e: self._on_connect_error(e) + ) + except Exception as e: + self.log("red", f"连接失败: {e}") + self.auto_connecting = False + + def _on_connect_reply(self): + self.connected = True + self.log("blue", f"Connected: {self.device_name}") + self.connected_signal.emit(self.device_name) + # 检查 GATT 服务是否已就绪 + try: + props = dbus.Interface( + self.bus.get_object("org.bluez", self.device_path), + "org.freedesktop.DBus.Properties" + ) + services_resolved = bool(props.Get("org.bluez.Device1", "ServicesResolved")) + if services_resolved: + self.discover_services() + else: + self.log("blue", "等待 GATT 服务发现...") + # 服务发现就绪后会在 on_properties_changed 中触发 + except Exception as e: + logger.error(f"Check ServicesResolved error: {e}") + self.discover_services() + + def _on_connect_error(self, error): + self.log("red", f"BLE 连接失败: {error}") + self.connected = False + self.auto_connecting = False + self.start_retry_timer() + + def discover_services(self): + try: + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + objects = object_manager.GetManagedObjects() + + service_path = None + for path, interfaces in objects.items(): + if "org.bluez.GattService1" in interfaces: + props = interfaces["org.bluez.GattService1"] + uuid = props.get("UUID", "").lower() + if uuid == self.TARGET_SERVICE.lower(): + service_path = path + break + + if not service_path: + self.log("orange", "未找到目标服务,重试中...") + # 如果服务已就绪但找不到,可能是设备未配对,尝试重试 + self._retry_discover_services(3) + return + + self.log("blue", f"发现目标服务") + + # 查找特征 + for path, interfaces in objects.items(): + if path.startswith(service_path) and "org.bluez.GattCharacteristic1" in interfaces: + char_props = interfaces["org.bluez.GattCharacteristic1"] + char_uuid = char_props.get("UUID", "").lower() + flags = char_props.get("Flags", []) + + # AhaKey 使用同一个 UUID 的不同 handle,这里简化处理 + if "write" in flags or "write-without-response" in flags: + self.write_char_path = path + self.log("green", " -> 命令特征已就绪") + if "notify" in flags or "indicate" in flags: + self.notify_char_path = path + self.log("green", " -> 通知特征已就绪") + + # 启用通知 + if self.notify_char_path: + self._subscribe_notify(self.notify_char_path) + + # 确认目标设备 + if self.write_char_path and self.notify_char_path: + self.target_confirmed = True + self.save_device_config() + self.send_device_status_query() + + except Exception as e: + logger.error(f"Discover services error: {e}") + + def _retry_discover_services(self, retries_left: int): + """重试发现服务,每次间隔1秒""" + if retries_left <= 0: + self.log("orange", "重试次数用尽,断开连接。请确保设备已配对:") + self.log("orange", " sudo bluetoothctl") + self.log("orange", " [bluetooth]# pair <设备MAC>") + self.log("orange", " [bluetooth]# trust <设备MAC>") + self.disconnect_device() + self.start_retry_timer() + return + + def retry(): + if self.connected and not self.target_confirmed: + self.log("blue", f"重试发现服务 (还剩 {retries_left - 1} 次)...") + self.discover_services() + + QTimer.singleShot(1000, retry) + + def _subscribe_notify(self, char_path: str): + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", char_path), + "org.bluez.GattCharacteristic1" + ) + char_iface.StartNotify( + reply_handler=lambda: self.log("green", "通知已启用"), + error_handler=lambda e: self.log("red", f"通知订阅失败: {e}") + ) + # 注册特征值变化监听(监听该特征路径的 PropertiesChanged 信号) + self.bus.add_signal_receiver( + self.on_characteristic_changed, + signal_name="PropertiesChanged", + dbus_interface="org.freedesktop.DBus.Properties", + path=char_path + ) + except Exception as e: + logger.error(f"Subscribe error: {e}") + + def on_properties_changed(self, interface: str, changed: dict, invalidated: list): + if "Connected" in changed: + connected = bool(changed["Connected"]) + if not connected and self.connected: + self.connected = False + self.log("red", f"Disconnected: {self.device_name}") + self.disconnected_signal.emit() + self.start_retry_timer() + if "ServicesResolved" in changed: + services_resolved = bool(changed["ServicesResolved"]) + if services_resolved and not self.target_confirmed: + self.log("blue", "GATT 服务已发现") + self.discover_services() + + def on_characteristic_changed(self, interface: str, changed: dict, invalidated: list): + if "Value" in changed: + data = bytes(changed["Value"]) + + # 检查是否是设备状态 + if len(data) == 13 and data[0] == 0xAA and data[1] == 0xBB and data[2] == 0x00: + battery = data[3] + switch = data[9] + self.status_update.emit(f"电量={battery} 开关={switch}") + self.log("green", f"设备状态: 电量={battery} 开关={switch}") + else: + self.log("blue", f"Notify: {data.hex()}") + + # 转发给 TCP 客户端 + packet = build_packet(PacketType.BLE_NOTIFY, data) + for client in self.clients: + client.send(packet) + + def send_device_status_query(self): + if self.write_char_path: + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", self.write_char_path), + "org.bluez.GattCharacteristic1" + ) + char_iface.WriteValue(list(self.DEVICE_STATUS_QUERY), {}, + reply_handler=lambda: None, + error_handler=lambda e: None) + self.log("blue", "已发送设备状态查询指令") + except Exception as e: + logger.error(f"Query error: {e}") + + def send_ble_command(self, data: bytes): + if self.write_char_path: + try: + char_iface = dbus.Interface( + self.bus.get_object("org.bluez", self.write_char_path), + "org.bluez.GattCharacteristic1" + ) + char_iface.WriteValue(list(data), {}, + reply_handler=lambda: None, + error_handler=lambda e: logger.error(f"Write error: {e}")) + except Exception as e: + logger.error(f"Send command error: {e}") + + def save_device_config(self): + if self.device_name and self.device_path: + try: + props = dbus.Interface( + self.bus.get_object("org.bluez", self.device_path), + "org.freedesktop.DBus.Properties" + ) + address = props.Get("org.bluez.Device1", "Address") + self.config.ble_name = self.device_name + self.config.ble_mac = str(address) + self.config.save() + self.log("blue", f"已保存设备: {self.device_name} [{address}]") + except Exception as e: + logger.error(f"Save config error: {e}") + + def start_retry_timer(self): + # 8秒后重试 + asyncio.run_coroutine_threadsafe(self._retry_after_delay(), self.loop) + + async def _retry_after_delay(self): + await asyncio.sleep(8) + if not self.connected and self.running: + self.log("gray", "重新扫描蓝牙设备...") + self.scan_devices() + + def disconnect_device(self): + self.connected = False + self.target_confirmed = False + if self.device_iface: + try: + self.device_iface.Disconnect() + except Exception: + pass + + def manual_connect(self, name: str, address: str): + if self.connected: + return + self.auto_connecting = True + for path, interfaces in self._get_all_objects().items(): + if "org.bluez.Device1" in interfaces: + props = interfaces["org.bluez.Device1"] + if props.get("Address", "") == address: + self.connect_to_device(path, name, address) + return + self.log("red", f"未找到设备: {name}") + + def _get_all_objects(self): + try: + object_manager = dbus.Interface( + self.bus.get_object("org.bluez", "/"), + "org.freedesktop.DBus.ObjectManager" + ) + return object_manager.GetManagedObjects() + except: + return {} + + async def start_tcp_server(self): + try: + self.server = await asyncio.start_server( + self.handle_tcp_client, + "0.0.0.0", + self.config.server_port + ) + self.log("darkcyan", f"TCP服务器已启动, 监听端口: {self.config.server_port}") + except Exception as e: + self.log("red", f"TCP服务器启动失败: {e}") + + async def handle_tcp_client(self, reader, writer): + client = TcpClient(reader, writer, self) + self.clients.append(client) + self.client_count_changed.emit(len(self.clients)) + await client.handle() + + async def handle_packet(self, client: TcpClient, pkt_type: int, data: bytes): + if pkt_type == PacketType.WRITE_COMMAND: + self.send_ble_command(data) + elif pkt_type == PacketType.WRITE_DATA: + self.send_ble_command(data) + elif pkt_type == PacketType.QUERY_BLE_STATUS: + self.send_ble_status(client) + elif pkt_type == PacketType.QUERY_DEVICE_INFO: + self.send_device_info(client) + + def send_ble_status(self, client: TcpClient): + status_data = bytes([ + 1 if self.connected else 0, + len(self.device_name.encode()) + ]) + self.device_name.encode() + bytes([ + len(self.config.ble_mac.encode()) + ]) + self.config.ble_mac.encode() + bytes([ + 1 if self.target_confirmed else 0 + ]) + packet = build_packet(PacketType.BLE_STATUS_RESP, status_data) + client.send(packet) + + def send_device_info(self, client: TcpClient): + info = bytes([50, 0, 1, 0, 0, 0, 1, 0]) + packet = build_packet(PacketType.DEVICE_INFO_RESP, info) + client.send(packet) + + def remove_client(self, client: TcpClient): + if client in self.clients: + self.clients.remove(client) + self.client_count_changed.emit(len(self.clients)) + + def stop(self): + self.running = False + self.disconnect_device() + if self.server: + self.server.close() + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.config = AppConfig.load() + self.setWindowTitle("VibeCoding Keyboard BLE Driver") + self.setMinimumSize(800, 500) + + # 创建系统托盘 + self.tray_icon = QSystemTrayIcon(self) + self.tray_icon.setToolTip("BLE TCP Bridge") + # 使用默认图标 + self.tray_icon.setIcon(self.style().standardIcon( + self.style().SP_ComputerIcon)) + + tray_menu = QMenu(self) + show_action = QAction("显示窗口", self) + show_action.triggered.connect(self.show_window) + tray_menu.addAction(show_action) + tray_menu.addSeparator() + exit_action = QAction("退出程序", self) + exit_action.triggered.connect(self.force_exit) + tray_menu.addAction(exit_action) + self.tray_icon.setContextMenu(tray_menu) + self.tray_icon.activated.connect(self.on_tray_activated) + self.tray_icon.show() + + self._setup_ui() + self._start_worker() + + if self.config.start_minimized or "--minimized" in sys.argv: + self.hide() + else: + self.show() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(10) + + # 顶部控制区 + top = QHBoxLayout() + + # 设备选择 + self.device_combo = QComboBox() + self.device_combo.setMinimumWidth(180) + self.device_combo.addItem("扫描中...") + top.addWidget(QLabel("设备:")) + top.addWidget(self.device_combo) + + # 连接按钮 + self.btn_connect = QPushButton("连接") + self.btn_connect.setFixedWidth(80) + self.btn_connect.clicked.connect(self.on_connect_clicked) + top.addWidget(self.btn_connect) + + top.addStretch() + + # 状态信息 + info_layout = QVBoxLayout() + self.label_device = QLabel("当前连接设备: 无") + self.label_tcp = QLabel(f"TCP服务: {get_local_ip()}:{self.config.server_port} (客户端:0)") + info_layout.addWidget(self.label_device) + info_layout.addWidget(self.label_tcp) + top.addLayout(info_layout) + + top.addStretch() + + # 退出按钮 + self.btn_exit = QPushButton("退出程序") + self.btn_exit.setFixedWidth(90) + self.btn_exit.clicked.connect(self.force_exit) + top.addWidget(self.btn_exit) + + layout.addLayout(top) + + # 选项区 + options = QHBoxLayout() + self.chk_minimize = QCheckBox("下次最小化启动") + self.chk_minimize.setChecked(self.config.start_minimized) + self.chk_minimize.stateChanged.connect(self.on_minimize_changed) + options.addWidget(self.chk_minimize) + layout.addLayout(options) + + # 日志区 + self.log_edit = QTextEdit() + self.log_edit.setReadOnly(True) + self.log_edit.setFont(QFont("Microsoft YaHei", 10)) + self.log_edit.setStyleSheet("background-color: #f5f5f5;") + layout.addWidget(self.log_edit) + + def _start_worker(self): + self.worker = AsyncWorker(self.config) + self.worker.log_signal.connect(self.on_log) + self.worker.device_found.connect(self.on_device_found) + self.worker.connected_signal.connect(self.on_connected) + self.worker.disconnected_signal.connect(self.on_disconnected) + self.worker.status_update.connect(self.on_status_update) + self.worker.client_count_changed.connect(self.on_client_count_changed) + self.worker.start() + + def on_log(self, color: str, message: str): + colors = { + "red": "#CC0000", "blue": "#0000CC", "green": "#006600", + "darkcyan": "#008B8B", "orange": "#FF8C00", "gray": "#808080" + } + hex_color = colors.get(color, "#000000") + + self.log_edit.append( + f'{message}' + ) + cursor = self.log_edit.textCursor() + cursor.movePosition(QTextCursor.End) + self.log_edit.setTextCursor(cursor) + + def on_device_found(self, name: str, address: str): + # 检查是否已存在 + for i in range(self.device_combo.count()): + if self.device_combo.itemData(i) == address: + return + + if self.device_combo.count() == 1 and self.device_combo.itemText(0) == "扫描中...": + self.device_combo.clear() + + self.device_combo.addItem(f"{name} [{address}]", address) + + def on_connect_clicked(self): + idx = self.device_combo.currentIndex() + if idx < 0: + return + address = self.device_combo.itemData(idx) + name = self.device_combo.currentText().split("[")[0].strip() + if address: + self.worker.manual_connect(name, address) + + def on_connected(self, name: str): + self.label_device.setText(f"当前连接设备: {name}") + self.btn_connect.setEnabled(False) + + def on_disconnected(self): + self.label_device.setText("当前连接设备: 无") + self.btn_connect.setEnabled(True) + + def on_status_update(self, status: str): + current = self.label_device.text() + if "当前连接设备:" in current and current != "当前连接设备: 无": + self.label_device.setText(f"{current} ({status})") + + def on_client_count_changed(self, count: int): + self.label_tcp.setText( + f"TCP服务: {get_local_ip()}:{self.config.server_port} (客户端:{count})" + ) + + def on_minimize_changed(self, state): + self.config.start_minimized = bool(state) + self.config.save() + + def on_tray_activated(self, reason): + if reason == QSystemTrayIcon.Trigger: + self.show_window() + + def show_window(self): + self.showNormal() + self.activateWindow() + self.raise_() + + def closeEvent(self, event): + # 关闭按钮最小化到托盘 + event.ignore() + self.hide() + self.tray_icon.showMessage( + "BLE Bridge", + "程序已最小化到系统托盘", + QSystemTrayIcon.Information, + 2000 + ) + + def force_exit(self): + self.worker.stop() + self.worker.wait(3000) + self.tray_icon.hide() + QApplication.instance().quit() + + +def main(): + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + window = MainWindow() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/BLE_tcp_bridge_linux/deb_package/usr/share/applications/ahakey-ble-bridge.desktop b/BLE_tcp_bridge_linux/deb_package/usr/share/applications/ahakey-ble-bridge.desktop new file mode 100644 index 00000000..4a7f69a3 --- /dev/null +++ b/BLE_tcp_bridge_linux/deb_package/usr/share/applications/ahakey-ble-bridge.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=AhaKey BLE Bridge +Comment=Bluetooth BLE bridge for AhaKey keyboard +Exec=ahakey-ble-bridge +Icon=ahakey-ble-bridge +Terminal=false +StartupWMClass=AhaKeyBLEBridge +Categories=Utility;System; +Keywords=bluetooth;ble;keyboard; diff --git a/BLE_tcp_bridge_linux/deb_package/usr/share/pixmaps/ahakey-ble-bridge.svg b/BLE_tcp_bridge_linux/deb_package/usr/share/pixmaps/ahakey-ble-bridge.svg new file mode 100644 index 00000000..b9d81726 --- /dev/null +++ b/BLE_tcp_bridge_linux/deb_package/usr/share/pixmaps/ahakey-ble-bridge.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/BLE_tcp_bridge_linux/requirements.txt b/BLE_tcp_bridge_linux/requirements.txt new file mode 100644 index 00000000..1f582b62 --- /dev/null +++ b/BLE_tcp_bridge_linux/requirements.txt @@ -0,0 +1,3 @@ +dbus-python>=1.3.2 +PyGObject>=3.44.0 +PyQt5>=5.15.0 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/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/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..21a70ae2 --- /dev/null +++ b/ahakeyconfig-mac/Package.swift @@ -0,0 +1,63 @@ +// 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"]), + .executable(name: "PluginShowcase", targets: ["PluginShowcase"]), + .library(name: "AhaKeyPluginKit", targets: ["AhaKeyPluginKit"]), + ], + + targets: [ + .target( + name: "AhaKeyPluginKit", + path: "Sources/AhaKeyPluginKit" + ), + .executableTarget( + name: "Plugin", + dependencies: ["AhaKeyPluginKit"], + path: "Sources/AhaKeyPlugin" + ), + .executableTarget( + name: "PluginShowcase", + dependencies: ["AhaKeyPluginKit"], + path: "Sources/AhaKeyPluginShowcase" + ), + .executableTarget( + name: "AhaKeyConfig", + dependencies: ["AhaKeyPluginKit"], + path: "Sources", + exclude: ["Agent", "AhaKeyPlugin", "AhaKeyPluginKit", "AhaKeyPluginShowcase"], + // 与 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" + ), + .testTarget( + name: "AhaKeyConfigTests", + dependencies: ["AhaKeyConfig"], + path: "Tests/AhaKeyConfigTests" + ), + ] +) diff --git a/platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist similarity index 100% rename from platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist rename to ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist diff --git a/platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist similarity index 100% rename from platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo.plist rename to ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist 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/platforms/macos/Resources/DefaultOLED/claude_0.gif b/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif similarity index 100% rename from platforms/macos/Resources/DefaultOLED/claude_0.gif rename to ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif 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 60% rename from platforms/macos/Sources/Agent/AhaKeyAgent.swift rename to ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift index a42d6d55..39a8d198 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,66 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat private(set) var cachedSwitchState: UInt8? /// 最新 lightMode private(set) var cachedLightMode: UInt8? + /// 用户在画布点击虚拟拨杆设置的覆盖值;非 nil 时优先于 cachedSwitchState 用于 hook auto-approve 判断。 + /// 持久化到 UserDefaults 以便 agent 重启后保留。物理拨杆损坏的用户靠这个让 hook 自动批准生效。 + 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? + + // MARK: 看门狗(Claude Code 手动停止任务时 Stop hook 不触发,超时后自动归位) + /// 最近一次 hook 发来状态命令的时间(nil = 尚未收到) + private var lastHookStateAt: Date? + /// 最近一次我们主动发给键盘的 LED 状态 + private var lastSentState: UInt8 = 0 + private var watchdogTimer: DispatchSourceTimer? + + private let hookHeartbeatInterval: Double = 30 + private let hookStaleTimeout: Double = 90 var onLog: ((String) -> Void)? init(socketPath: String = "/tmp/ahakey.sock") { self.socketPath = socketPath + if let raw = UserDefaults.standard.object(forKey: Self.switchOverrideDefaultsKey) as? Int { + userSwitchOverride = UInt8(clamping: raw) + } super.init() central = CBCentralManager(delegate: self, queue: nil) + // 启动时如果有持久化的 override,立刻把它落进共享文件,让主 App UI 一上来就能看到 + Self.writeLiveState(switchState: userSwitchOverride) + } + + /// 实际给 hook 用的拨杆值:用户覆盖优先,没有就回落到 BLE 缓存 + var effectiveSwitchState: UInt8? { + userSwitchOverride ?? cachedSwitchState + } + + func setSwitchOverride(_ value: UInt8?) { + userSwitchOverride = value + if let v = value { + UserDefaults.standard.set(Int(v), forKey: Self.switchOverrideDefaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: Self.switchOverrideDefaultsKey) + } + // 最新固件中 0x91 已用于灯效预览;拨杆只保留 hook 软件覆盖,不再向键盘发送旧 0x91。 + if let v = value { + emit("拨杆 \(v) 仅记录为软件覆盖;不发送旧 0x91。") + } + // 把覆盖值写进共享文件,主 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 +107,46 @@ 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) + } } /// 主动查询一次设备状态,等待下一个 notify 回包 (timeout 秒内)。 @@ -99,12 +183,19 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat ) } - func startSocketListener() { - // 清理旧 socket + @discardableResult + func startSocketListener() -> Bool { + if Self.hasLiveSocket(at: socketPath) { + emit("已有 Agent 在监听 Unix socket: \(socketPath)") + return false + } + + startWatchdog() + // 清理没有监听进程的残留 socket unlink(socketPath) let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { emit("socket() 失败"); return } + guard fd >= 0 else { emit("socket() 失败"); return false } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -120,7 +211,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat bind(fd, sockPtr, socklen_t(MemoryLayout.size)) } } - guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return } + guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return false } listen(fd, 5) emit("监听 Unix socket: \(socketPath)") @@ -132,10 +223,61 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat self?.handleClient(clientFd) } } + return true + } + + // MARK: - 看门狗 + + private func startWatchdog() { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + hookHeartbeatInterval, repeating: hookHeartbeatInterval) + timer.setEventHandler { [weak self] in + self?.checkWatchdog() + } + timer.resume() + watchdogTimer = timer + } + + private func checkWatchdog() { + guard let lastAt = lastHookStateAt else { return } + // Notification / permission / running states need a heartbeat. Stop and + // TaskCompleted deliberately do not: firmware returns DONE to IDLE after 60s. + let activeStates: [UInt8] = [0, 1, 2, 3, 4, 7] + guard activeStates.contains(lastSentState) else { return } + let elapsed = Date().timeIntervalSince(lastAt) + if elapsed >= hookStaleTimeout { + emit("看门狗:\(Int(elapsed))s 无 Hook 活动,回到待机") + sendState(8) + lastHookStateAt = nil + } else { + emit("状态心跳:续期 \(lastSentState)") + sendState(lastSentState) + } } // MARK: - Socket handling + private static func hasLiveSocket(at path: String) -> Bool { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + let buf = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + strcpy(buf, ptr) + } + } + + return withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) == 0 + } + } + } + /// 单个客户端的处理:读一包请求,按 JSON 或旧版纯数字分发。 /// /// 协议: @@ -171,16 +313,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,17 +347,40 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } case "status": + // 判断 BLE 是否真实连上键盘:只有当 cachedSwitchState 不为 nil 时(键盘通过 notify 上报过)才算连上。 + // effectiveSwitchState 在用户设置了 userSwitchOverride 时即使未连上 BLE 也有值,不能作为连上键盘的依据。 if cachedSwitchState != nil { Self.replyAndClose(clientFd, [ - "switchState": cachedSwitchState.map { Int($0) } ?? NSNull(), + "switchState": effectiveSwitchState.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)) + 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)"]) } @@ -219,6 +398,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 +440,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 +470,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 @@ -347,6 +537,13 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat cachedSwitchState = UInt8(clamping: status.switchState) cachedLightMode = UInt8(clamping: status.lightMode) emit("← status battery=\(status.battery) light=\(status.lightMode) switch=\(status.switchState)") + // 写共享文件时优先使用用户覆盖值,否则用键盘真实上报。这样主 App 画布上的拨杆位置始终与 + // hook 实际使用的批准逻辑一致(避免画布显示一档、hook 按另一档运行的割裂)。 + Self.writeLiveState( + lightMode: UInt8(clamping: status.lightMode), + switchState: effectiveSwitchState, + 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/platforms/macos/Sources/Agent/CursorCliLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift similarity index 100% rename from platforms/macos/Sources/Agent/CursorCliLeverSync.swift rename to ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift 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/platforms/macos/Sources/Agent/CursorPermissionsJsonLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift similarity index 100% rename from platforms/macos/Sources/Agent/CursorPermissionsJsonLeverSync.swift rename to ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift 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/platforms/macos/Sources/Agent/main.swift b/ahakeyconfig-mac/Sources/Agent/main.swift similarity index 83% rename from platforms/macos/Sources/Agent/main.swift rename to ahakeyconfig-mac/Sources/Agent/main.swift index 9bb17dc3..c7621513 100644 --- a/platforms/macos/Sources/Agent/main.swift +++ b/ahakeyconfig-mac/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。 @@ -29,7 +29,9 @@ agent.onLog = { msg in let ts = ISO8601DateFormatter().string(from: Date()) print("[\(ts)] \(msg)") } -agent.startSocketListener() +guard agent.startSocketListener() else { + exit(EXIT_FAILURE) +} // 保持运行 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/ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift b/ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift new file mode 100644 index 00000000..4095f2fa --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift @@ -0,0 +1,47 @@ +import AhaKeyPluginKit +import Foundation + +// AhaKey Plugin demo executable —— 把 `AhaKeyPluginKit` 当 SDK 走一遍 manager 流程: +// 1. 扫描 `~/Library/Application Support/AhaKeyConfig/plugins/` +// (或环境变量 `AHAKEY_PLUGINS_DIR` 指定的目录) +// 2. 按 manifest 拉起每个插件 + 握手 +// 3. 列出加载结果 +// 4. 停一会儿(让插件有机会反向调用 host) +// 5. 全部 shutdown +// +// 真实主 app 直接 `import AhaKeyPluginKit` 使用 `PluginManager`,不依赖这个 executable。 +// 这里只是给 `swift run Plugin` 一个能跑的入口,便于本地验证骨架。 + +@main +struct PluginDemoMain { + static func main() async { + let manager = PluginManager() + let count = await manager.loadAll() + + let plugins = await manager.allLoaded() + if plugins.isEmpty { + FileHandle.standardError.write(Data( + """ + [plugin-demo] no plugins loaded. \ + drop a plugin.json into \(PluginManager.defaultPluginsRoot.path) \ + or set AHAKEY_PLUGINS_DIR= and rerun. + + """.utf8 + )) + } else { + FileHandle.standardError.write(Data( + "[plugin-demo] loaded \(count) plugin(s):\n".utf8 + )) + for p in plugins { + let reported = p.initialize?.name ?? "" + FileHandle.standardError.write(Data( + " - \(p.manifest.id) v\(p.manifest.version) [plugin says: \(reported)]\n".utf8 + )) + } + } + + // 给插件 1 秒做反向 RPC 之类的事,再 shutdown。 + try? await Task.sleep(nanoseconds: 1_000_000_000) + await manager.unloadAll() + } +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift new file mode 100644 index 00000000..ca0f1e39 --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift @@ -0,0 +1,173 @@ +import Foundation + +// JSON-RPC 2.0 协议类型(https://www.jsonrpc.org/specification)。 +// +// 设计取舍: +// - params / result 是任意 JSON,用本文件里的 `JSONValue` 表达,避免引入第三方 AnyCodable。 +// - id 允许 int / string / null(按规范);本客户端只主动使用 int,但收到 string/null 也能解。 +// - 不区分 batch 调用(暂不需要)。要支持时再加 `[JSONRPCResponse]` 解码分支。 + +public enum JSONRPC { + public static let version = "2.0" +} + +// MARK: - JSONValue + +/// 任意 JSON 值。 +public enum JSONValue: Equatable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let b = try? c.decode(Bool.self) { self = .bool(b); return } + // Int 先于 Double:纯整数解出来是 .int。 + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let d = try? c.decode(Double.self) { self = .double(d); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + if let a = try? c.decode([JSONValue].self) { self = .array(a); return } + if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return } + throw DecodingError.dataCorruptedError( + in: c, debugDescription: "Unsupported JSON value" + ) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let b): try c.encode(b) + case .int(let i): try c.encode(i) + case .double(let d): try c.encode(d) + case .string(let s): try c.encode(s) + case .array(let a): try c.encode(a) + case .object(let o): try c.encode(o) + } + } +} + +public extension JSONValue { + /// 把任意 Encodable 转成 JSONValue(走一遍 JSONEncoder/Decoder)。 + static func encode(_ value: T) throws -> JSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(JSONValue.self, from: data) + } + + /// 把 JSONValue 解成具体类型。 + func decode(_ type: T.Type) throws -> T { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(type, from: data) + } +} + +// MARK: - ID + +/// JSON-RPC id:规范允许 Number / String / Null。 +public enum JSONRPCID: Hashable, Sendable { + case int(Int) + case string(String) + case null +} + +extension JSONRPCID: Codable { + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + throw DecodingError.dataCorruptedError( + in: c, debugDescription: "JSON-RPC id must be number, string, or null" + ) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .int(let i): try c.encode(i) + case .string(let s): try c.encode(s) + case .null: try c.encodeNil() + } + } +} + +// MARK: - Request / Notification + +/// 出站请求;`id == nil` 代表 Notification(按规范连 `id` 字段都不编码)。 +public struct JSONRPCRequest: Encodable, Sendable { + public let jsonrpc: String + public let method: String + public let params: JSONValue? + public let id: JSONRPCID? + + public init(method: String, params: JSONValue?, id: JSONRPCID?) { + self.jsonrpc = JSONRPC.version + self.method = method + self.params = params + self.id = id + } + + private enum CodingKeys: String, CodingKey { + case jsonrpc, method, params, id + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(jsonrpc, forKey: .jsonrpc) + try c.encode(method, forKey: .method) + if let params { try c.encode(params, forKey: .params) } + if let id { try c.encode(id, forKey: .id) } + } +} + +// MARK: - Response + +public struct JSONRPCResponse: Decodable, Sendable { + public let jsonrpc: String + public let id: JSONRPCID? + public let result: JSONValue? + public let error: JSONRPCError? +} + +public struct JSONRPCError: Codable, Sendable, Error { + public let code: Int + public let message: String + public let data: JSONValue? + + public init(code: Int, message: String, data: JSONValue? = nil) { + self.code = code + self.message = message + self.data = data + } +} + +public extension JSONRPCError { + // 规范保留错误码。-32000..-32099 留给实现自定义。 + static let parseError = -32700 + static let invalidRequest = -32600 + static let methodNotFound = -32601 + static let invalidParams = -32602 + static let internalError = -32603 +} + +// MARK: - 客户端侧错误 + +public enum PluginClientError: Error, Sendable { + /// 子进程未启动 / 已退出。 + case notRunning + /// 子进程意外退出,附终止码。 + case processTerminated(Int32) + /// 响应 id 与所有 pending 都对不上。 + case unknownResponseID(JSONRPCID?) + /// 单次 call 等待超时。 + case timeout + /// 解码失败。 + case decodingFailed(String) +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift new file mode 100644 index 00000000..ff039e5d --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift @@ -0,0 +1,355 @@ +import Foundation + +/// 一个 JSON-RPC 2.0 over stdio 客户端。 +/// +/// - 启动子进程(插件 host 端 = 我们;子进程 = 插件本体)。 +/// - 帧格式:**newline-delimited JSON**(每条消息一行 UTF-8 + `\n`)。 +/// 这是 stdio 通讯里最常见的选择;如果未来要换成 LSP 的 `Content-Length` 头, +/// 只需替换 `sendFramed(_:)` 与 reader 里的拆帧逻辑。 +/// - 子进程的 `stderr` 透传给上层(默认打到本进程 stderr,可换 `onStderr`)。 +/// +/// 用法: +/// ```swift +/// let client = PluginClient( +/// executable: URL(fileURLWithPath: "/usr/bin/env"), +/// arguments: ["node", "my-plugin.js"] +/// ) +/// try client.start() +/// let result = try await client.call("add", params: .array([.int(1), .int(2)])) +/// // result == .int(3) +/// client.stop() +/// ``` +public actor PluginClient { + // MARK: - 配置 + + private let executable: URL + private let arguments: [String] + private let environment: [String: String]? + private let workingDirectory: URL? + + /// 单次 `call` 等待响应的默认超时。`notify` 不受此影响。 + public var defaultCallTimeout: TimeInterval = 30 + + /// 子进程 stderr 回调,nil 表示透传到本进程 stderr。 + public var onStderr: (@Sendable (String) -> Void)? + + // MARK: - 进程 / 管道 + + private let process = Process() + private let stdinPipe = Pipe() + private let stdoutPipe = Pipe() + private let stderrPipe = Pipe() + private var started = false + + // MARK: - 协议状态 + + private var nextID = 1 + private var pending: [Int: CheckedContinuation] = [:] + + /// 服务端 → 客户端的 notification 处理器(method → 处理闭包)。 + public typealias NotificationHandler = @Sendable (JSONValue?) -> Void + private var notificationHandlers: [String: NotificationHandler] = [:] + + /// 服务端 → 客户端的 request 处理器(method → 返回 result 或抛 JSONRPCError)。 + /// JSON-RPC 是对等协议,子进程也可以反向调用我们。 + public typealias RequestHandler = @Sendable (JSONValue?) async throws -> JSONValue + private var requestHandlers: [String: RequestHandler] = [:] + + // MARK: - 初始化 + + public init( + executable: URL, + arguments: [String] = [], + environment: [String: String]? = nil, + workingDirectory: URL? = nil + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + } + + // MARK: - 启动 / 停止 + + public func start() throws { + guard !started else { return } + process.executableURL = executable + process.arguments = arguments + if let environment { process.environment = environment } + if let workingDirectory { process.currentDirectoryURL = workingDirectory } + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // 子进程退出时,唤醒所有 pending。 + process.terminationHandler = { [weak self] proc in + Task { await self?.handleProcessTermination(status: proc.terminationStatus) } + } + + try process.run() + started = true + startReader() + startStderrReader() + } + + /// 优雅停止:关闭 stdin,等子进程自然退出;若想强杀用 `terminate()`。 + public func stop() { + guard started else { return } + try? stdinPipe.fileHandleForWriting.close() + } + + public func terminate() { + guard started else { return } + process.terminate() + } + + // MARK: - 注册回调 + + public func setNotificationHandler(_ method: String, _ handler: @escaping NotificationHandler) { + notificationHandlers[method] = handler + } + + public func setRequestHandler(_ method: String, _ handler: @escaping RequestHandler) { + requestHandlers[method] = handler + } + + // MARK: - 发送:call / notify + + /// 发起 JSON-RPC 调用,等待响应。 + @discardableResult + public func call( + _ method: String, + params: JSONValue? = nil, + timeout: TimeInterval? = nil + ) async throws -> JSONValue { + guard started, process.isRunning else { throw PluginClientError.notRunning } + + let id = nextID + nextID += 1 + let request = JSONRPCRequest(method: method, params: params, id: .int(id)) + + // 先注册 continuation,再发数据,避免响应早于注册。 + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + pending[id] = cont + do { + try sendFramed(request) + } catch { + pending.removeValue(forKey: id) + cont.resume(throwing: error) + return + } + armTimeout(id: id, after: timeout ?? defaultCallTimeout) + } + } onCancel: { + Task { await self.failPending(id: id, with: CancellationError()) } + } + } + + /// 发送 Notification(无 id,不等响应)。 + public func notify(_ method: String, params: JSONValue? = nil) throws { + guard started, process.isRunning else { throw PluginClientError.notRunning } + let request = JSONRPCRequest(method: method, params: params, id: nil) + try sendFramed(request) + } + + // MARK: - 帧编码 + + private func sendFramed(_ request: JSONRPCRequest) throws { + var data = try JSONEncoder().encode(request) + data.append(0x0A) // '\n' + if ProcessInfo.processInfo.environment["AHAKEY_PLUGIN_DEBUG"] != nil, + let s = String(data: data, encoding: .utf8) { + FileHandle.standardError.write(Data("[client→plugin] \(s)".utf8)) + } + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } + + // MARK: - Reader(stdout) + + // 之前用 `handle.bytes.lines` 在 daemon-thread 写 stdout 的场景下会延迟数秒才触发, + // 改用 `readabilityHandler` 走 dispatch IO,事件驱动、即时。 + private var stdoutBuffer = Data() + + private func startReader() { + let handle = stdoutPipe.fileHandleForReading + handle.readabilityHandler = { [weak self] h in + let data = h.availableData + guard let self else { return } + if data.isEmpty { + // EOF —— 子进程关了 stdout。 + h.readabilityHandler = nil + Task { await self.handleProcessTermination(status: nil) } + return + } + Task { await self.appendStdout(data) } + } + } + + private func appendStdout(_ data: Data) async { + stdoutBuffer.append(data) + while let nl = stdoutBuffer.firstIndex(of: 0x0A) { + let lineData = stdoutBuffer.subdata(in: stdoutBuffer.startIndex ..< nl) + stdoutBuffer.removeSubrange(stdoutBuffer.startIndex ... nl) + if let line = String(data: lineData, encoding: .utf8) { + await handleIncoming(line: line) + } + } + } + + private func startStderrReader() { + let handle = stderrPipe.fileHandleForReading + let onStderr = self.onStderr + handle.readabilityHandler = { h in + let data = h.availableData + if data.isEmpty { + h.readabilityHandler = nil + return + } + if let onStderr, let s = String(data: data, encoding: .utf8) { + // 按行回调;保留尾部不完整行(罕见)。 + for line in s.split(separator: "\n", omittingEmptySubsequences: false) { + if !line.isEmpty { onStderr(String(line)) } + } + } else { + FileHandle.standardError.write(data) + } + } + } + + // MARK: - 入站派发 + + private func handleIncoming(line: String) async { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return } + if ProcessInfo.processInfo.environment["AHAKEY_PLUGIN_DEBUG"] != nil { + FileHandle.standardError.write(Data("[client←plugin] \(trimmed)\n".utf8)) + } + + // 一条消息可能是:Response(含 id + result/error)、Request(含 id + method)、 + // 或 Notification(含 method 但无 id)。先看有没有 `method` 字段。 + if let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + raw["method"] is String { + await dispatchInbound(data: data, raw: raw) + return + } + + // 当作 Response 解。 + do { + let resp = try JSONDecoder().decode(JSONRPCResponse.self, from: data) + dispatch(response: resp) + } catch { + // 实在解不出来:丢到 stderr 便于排查。 + FileHandle.standardError.write( + Data("[PluginClient] unrecognized frame: \(trimmed)\n".utf8) + ) + } + } + + private func dispatch(response: JSONRPCResponse) { + guard case .int(let i)? = response.id else { + // 我们发出去的 id 全是 int;其他 id 形式直接丢弃。 + return + } + guard let cont = pending.removeValue(forKey: i) else { return } + if let err = response.error { + cont.resume(throwing: err) + } else { + cont.resume(returning: response.result ?? .null) + } + } + + private func dispatchInbound(data: Data, raw: [String: Any]) async { + guard let method = raw["method"] as? String else { return } + let params: JSONValue? = { + guard raw["params"] != nil else { return nil } + // 用 JSONValue 重新解一遍 params 字段。 + struct Wrapper: Decodable { let params: JSONValue? } + return (try? JSONDecoder().decode(Wrapper.self, from: data))?.params + }() + + if raw["id"] == nil { + // Notification + notificationHandlers[method]?(params) + return + } + + // 入站 Request:要回 Response。 + let id: JSONRPCID + do { + struct Wrapper: Decodable { let id: JSONRPCID } + id = try JSONDecoder().decode(Wrapper.self, from: data).id + } catch { + return + } + + if let handler = requestHandlers[method] { + do { + let result = try await handler(params) + try sendResult(id: id, result: result) + } catch let err as JSONRPCError { + try? sendError(id: id, error: err) + } catch { + try? sendError(id: id, error: JSONRPCError( + code: JSONRPCError.internalError, + message: "\(error)", + data: nil + )) + } + } else { + try? sendError(id: id, error: JSONRPCError( + code: JSONRPCError.methodNotFound, + message: "Method not found: \(method)", + data: nil + )) + } + } + + private struct OutboundResponse: Encodable { + let jsonrpc: String = JSONRPC.version + let id: JSONRPCID + let result: JSONValue? + let error: JSONRPCError? + } + + private func sendResult(id: JSONRPCID, result: JSONValue) throws { + let resp = OutboundResponse(id: id, result: result, error: nil) + var data = try JSONEncoder().encode(resp) + data.append(0x0A) + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } + + private func sendError(id: JSONRPCID, error: JSONRPCError) throws { + let resp = OutboundResponse(id: id, result: nil, error: error) + var data = try JSONEncoder().encode(resp) + data.append(0x0A) + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } + + // MARK: - 超时 / 取消 / 进程终止 + + private func armTimeout(id: Int, after seconds: TimeInterval) { + guard seconds > 0 else { return } + Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + failPending(id: id, with: PluginClientError.timeout) + } + } + + private func failPending(id: Int, with error: Error) { + guard let cont = pending.removeValue(forKey: id) else { return } + cont.resume(throwing: error) + } + + private func handleProcessTermination(status: Int32?) { + guard started else { return } + started = false + let err: Error = status.map { PluginClientError.processTerminated($0) } + ?? PluginClientError.notRunning + let pendingSnapshot = pending + pending.removeAll() + for (_, cont) in pendingSnapshot { + cont.resume(throwing: err) + } + } +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift new file mode 100644 index 00000000..e1669abf --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift @@ -0,0 +1,182 @@ +import Foundation + +// 在 PluginClient 之上包一层「宿主能力」:注册一组 `host/*` JSON-RPC method, +// 让插件能调用宿主提供的服务。 +// +// 当前最小三件套: +// - host/getInfo → 返回宿主 app 元信息(bundleID / version / build / platform) +// - host/log → 插件把日志打到宿主 stderr +// - host/getSwitchState → 通过 /tmp/ahakey.sock 问 daemon 拨杆状态(agent 没跑则返回 null) +// +// 后续要加的(如 host/showNotification、host/openURL、host/storage/*)按相同方式接到 +// `registerDefaultHandlers` 即可。增加新方法时记得在 manifest 的权限白名单里同步声明。 + +public final class PluginHost: @unchecked Sendable { + public let client: PluginClient + public let appInfo: HostAppInfo + + /// 该插件被允许调用的 `host/*` 方法集合;`nil` 表示不限制(仅用于 demo / 第一方)。 + public let permissions: Set? + + public init( + client: PluginClient, + appInfo: HostAppInfo = .current(), + permissions: Set? = nil + ) { + self.client = client + self.appInfo = appInfo + self.permissions = permissions + } + + /// 宿主当前 expose 的全部 `host/*` 方法名 —— 用于 `plugin/initialize` 时告诉插件。 + public static let availableHostMethods: [String] = [ + "host/getInfo", + "host/log", + "host/getSwitchState", + ] + + /// 注册默认 `host/*` 方法集。请在 `client.start()` 之前调用。 + public func registerDefaultHandlers() async { + let appInfo = self.appInfo + + await register("host/getInfo") { _ in + try JSONValue.encode(appInfo) + } + + await register("host/log") { params in + HostLog.write(params: params) + return .null + } + + await register("host/getSwitchState") { _ in + let state = HostAgentBridge.readSwitchState() + return .object([ + "switchState": state.map { JSONValue.int($0) } ?? .null, + "agentReachable": .bool(state != nil), + ]) + } + } + + /// 包一层权限检查后注册。不在 `permissions` 里的 method 会被 -32601 直接拒掉。 + private func register( + _ method: String, + _ handler: @escaping PluginClient.RequestHandler + ) async { + let permissions = self.permissions + await client.setRequestHandler(method) { params in + if let permissions, !permissions.contains(method) { + throw JSONRPCError( + code: JSONRPCError.methodNotFound, + message: "Method \(method) not in plugin permissions", + data: nil + ) + } + return try await handler(params) + } + } +} + +// MARK: - host/getInfo + +public struct HostAppInfo: Codable, Sendable { + public let bundleID: String + public let version: String + public let build: String + public let platform: String + + public init(bundleID: String, version: String, build: String, platform: String = "macos") { + self.bundleID = bundleID + self.version = version + self.build = build + self.platform = platform + } + + /// 从 `Bundle.main` 读;当宿主不是 app bundle(例如本 Plugin demo executable)时给保底值。 + public static func current() -> HostAppInfo { + let info = Bundle.main.infoDictionary ?? [:] + return .init( + bundleID: Bundle.main.bundleIdentifier ?? "dev.ahakey.unknown", + version: info["CFBundleShortVersionString"] as? String ?? "0.0.0", + build: info["CFBundleVersion"] as? String ?? "0" + ) + } +} + +// MARK: - host/log + +enum HostLog { + /// 兼容两种 params 形态: + /// - `{ "level": "info", "message": "..." }` + /// - `"plain string message"` + static func write(params: JSONValue?) { + var level = "info" + var message = "" + if case .object(let o)? = params { + if case .string(let s)? = o["level"] { level = s } + if case .string(let s)? = o["message"] { message = s } + } else if case .string(let s)? = params { + message = s + } + FileHandle.standardError.write( + Data("[plugin:\(level)] \(message)\n".utf8) + ) + } +} + +// MARK: - host/getSwitchState + +/// 与 `Agent/HookClient.swift` 走同一套 `/tmp/ahakey.sock` 协议 +/// (`{"cmd":"permission","value":1}` → `{"switchState": Int, ...}`)。 +/// agent 没跑或 BLE 没连上时返回 nil。 +/// +/// 没把 socket 协议抽成共用 util,是因为 Agent target 与 AhaKeyPluginKit 暂不互相依赖; +/// 后续若多处都要用,再抽 `AhaKeyAgentBridge` library。 +enum HostAgentBridge { + static let socketPath = "/tmp/ahakey.sock" + static let timeout: Double = 2.0 + + static func readSwitchState() -> Int? { + let req: [String: Any] = ["cmd": "permission", "value": 1] + guard let reply = sendJson(req) else { return nil } + if let i = reply["switchState"] as? Int { return i } + if let n = reply["switchState"] as? NSNumber { return n.intValue } + return nil + } + + private static func sendJson(_ dict: [String: Any]) -> [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: 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) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + _ = strcpy(dst, src) + } + } + let len = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, len) } + } + guard connected == 0 else { return nil } + + guard var payload = try? JSONSerialization.data(withJSONObject: dict) else { return nil } + payload.append(0x0A) + let wrote = payload.withUnsafeBytes { p -> Int in + guard let base = p.baseAddress else { return -1 } + return write(fd, base, p.count) + } + guard wrote >= 0 else { return nil } + + var buf = [UInt8](repeating: 0, count: 4096) + let n = read(fd, &buf, buf.count) + guard n > 0 else { return nil } + return (try? JSONSerialization.jsonObject(with: Data(buf[0 ..< Int(n)]))) as? [String: Any] + } +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift new file mode 100644 index 00000000..2125ac88 --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift @@ -0,0 +1,66 @@ +import Foundation + +// 插件生命周期握手 —— 借鉴 LSP 的 initialize/initialized/shutdown 流程。 +// +// 时序: +// 1. 宿主 spawn 子进程 → `client.start()` +// 2. 宿主 → 插件: call `plugin/initialize` (params: { host, hostMethods }) +// 3. 插件 → 宿主: result `{ name, version, methods }`(声明自己能处理哪些 method) +// 4. 宿主 → 插件: notify `plugin/initialized`(可选;告诉插件可以开始干活) +// 5. ... 正常 RPC ... +// 6. 宿主 → 插件: call `plugin/shutdown`(等插件清理) +// 7. 宿主 → 插件: notify `plugin/exit` +// 8. 宿主关闭 stdin → 子进程退出 + +public struct PluginInitializeParams: Codable, Sendable { + public let host: HostAppInfo + public let hostMethods: [String] + + public init(host: HostAppInfo, hostMethods: [String]) { + self.host = host + self.hostMethods = hostMethods + } +} + +public struct PluginInitializeResult: Codable, Sendable { + /// 插件自称的 id / name / version;只用来记日志、设置面板展示。 + /// 宿主信任的是 manifest 里的那一份。 + public let name: String? + public let version: String? + + /// 插件声明它能处理的 method 列表。宿主可以据此提前拒绝乱发 method。 + public let methods: [String]? + + public init(name: String? = nil, version: String? = nil, methods: [String]? = nil) { + self.name = name + self.version = version + self.methods = methods + } +} + +public extension PluginClient { + /// 完成 `plugin/initialize` 握手。失败时把错误透出(超时 → `PluginClientError.timeout`)。 + @discardableResult + func initialize( + host: HostAppInfo, + hostMethods: [String], + timeout: TimeInterval = 5 + ) async throws -> PluginInitializeResult { + let params = try JSONValue.encode( + PluginInitializeParams(host: host, hostMethods: hostMethods) + ) + let result = try await call("plugin/initialize", params: params, timeout: timeout) + return try result.decode(PluginInitializeResult.self) + } + + /// 通知插件「初始化完成,可以开干」。失败被吞掉(notification 没有响应)。 + func sendInitialized() throws { + try notify("plugin/initialized") + } + + /// 请求插件清理并准备退出。失败/超时不抛,调用方接着 `stop()` 即可。 + func shutdown(timeout: TimeInterval = 3) async { + _ = try? await call("plugin/shutdown", timeout: timeout) + try? notify("plugin/exit") + } +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift new file mode 100644 index 00000000..3068a630 --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift @@ -0,0 +1,145 @@ +import Foundation + +// 扫描插件目录、加载 manifest、拉起所有插件,并管它们的生命周期。 +// +// 默认插件目录: +// ~/Library/Application Support/AhaKeyConfig/plugins//plugin.json +// +// 可通过环境变量 `AHAKEY_PLUGINS_DIR` 临时覆写(调试用)。 +// +// 单个插件加载失败不会影响其他插件 —— 错误写到 stderr,把这个 id 标记为 failed。 + +public actor PluginManager { + public struct LoadedPlugin: Sendable { + public let manifest: PluginManifest + public let host: PluginHost + public let initialize: PluginInitializeResult? + } + + public struct LoadFailure: Sendable { + public let manifestDirectory: URL + public let error: String + } + + private let pluginsRoot: URL + private let appInfo: HostAppInfo + private var loaded: [String: LoadedPlugin] = [:] + private(set) public var failures: [LoadFailure] = [] + + public init( + pluginsRoot: URL = PluginManager.defaultPluginsRoot, + appInfo: HostAppInfo = .current() + ) { + self.pluginsRoot = pluginsRoot + self.appInfo = appInfo + } + + public static var defaultPluginsRoot: URL { + if let override = ProcessInfo.processInfo.environment["AHAKEY_PLUGINS_DIR"], + !override.isEmpty { + return URL(fileURLWithPath: override) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/AhaKeyConfig/plugins", + isDirectory: true + ) + } + + // MARK: - Discover + + /// 扫描 `pluginsRoot` 下所有一级子目录,挑出有 `plugin.json` 的。 + /// 不抛错(根目录不存在 → 空数组)。 + public func discover() -> [PluginManifest] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: pluginsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + var out: [PluginManifest] = [] + for dir in entries { + let isDir = (try? dir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard isDir else { continue } + do { + let manifest = try PluginManifest.load(from: dir) + out.append(manifest) + } catch { + failures.append(.init(manifestDirectory: dir, error: "\(error)")) + FileHandle.standardError.write( + Data("[PluginManager] skip \(dir.lastPathComponent): \(error)\n".utf8) + ) + } + } + return out + } + + // MARK: - Load / Unload + + /// 把发现到的插件全部加载。返回成功数;失败的写到 `failures` 与 stderr。 + @discardableResult + public func loadAll() async -> Int { + let manifests = discover() + var ok = 0 + for m in manifests { + do { + try await load(manifest: m) + ok += 1 + } catch { + failures.append(.init(manifestDirectory: m.directory, error: "\(error)")) + FileHandle.standardError.write( + Data("[PluginManager] load \(m.id) failed: \(error)\n".utf8) + ) + } + } + return ok + } + + public func load(manifest: PluginManifest) async throws { + if loaded[manifest.id] != nil { return } // 幂等 + + let ep = manifest.resolvedEntrypoint + let client = PluginClient( + executable: ep.executable, + arguments: ep.arguments, + environment: ep.environment, + workingDirectory: ep.workingDirectory + ) + let host = PluginHost( + client: client, + appInfo: appInfo, + permissions: Set(manifest.permissions) + ) + await host.registerDefaultHandlers() + try await client.start() + + // 握手 + let info = try await client.initialize( + host: appInfo, + hostMethods: PluginHost.availableHostMethods + ) + try await client.sendInitialized() + + loaded[manifest.id] = LoadedPlugin(manifest: manifest, host: host, initialize: info) + } + + public func unloadAll() async { + for (_, p) in loaded { + await p.host.client.shutdown() + await p.host.client.stop() + } + loaded.removeAll() + } + + // MARK: - 查询 + + public func allLoaded() -> [LoadedPlugin] { + Array(loaded.values) + } + + public func plugin(id: String) -> LoadedPlugin? { + loaded[id] + } +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift new file mode 100644 index 00000000..8f68391a --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift @@ -0,0 +1,132 @@ +import Foundation + +// 一个插件目录的清单文件(`plugin.json`),决定宿主怎么发现、启动、信任这个插件。 +// +// 目录约定: +// ~/Library/Application Support/AhaKeyConfig/plugins//plugin.json +// +// 最小示例: +// ```json +// { +// "id": "com.example.hello", +// "name": "Hello Plugin", +// "version": "0.1.0", +// "entrypoint": { +// "command": "python3", +// "args": ["${pluginDir}/main.py"] +// }, +// "permissions": ["host/log", "host/getInfo"] +// } +// ``` +// +// 设计取舍: +// - `entrypoint.command` 不要求绝对路径;宿主统一用 `/usr/bin/env ` 拉起, +// 走系统 PATH。要写死可执行用绝对路径,env 也会原样转发。 +// - `${pluginDir}` 是 args / env 里的字符串占位符,解析时替换成 manifest 所在目录的绝对路径。 +// - `permissions` 是白名单:只有在这个列表里的 `host/*` method 能被该插件调用, +// 未声明的会直接回 method-not-found(-32601)。 + +public struct PluginManifest: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let version: String + public let entrypoint: Entrypoint + public let permissions: [String] + + /// manifest 文件所在目录(解析时由 loader 注入;不会被序列化到 JSON)。 + public var directory: URL = URL(fileURLWithPath: "/") + + public struct Entrypoint: Codable, Sendable, Equatable { + public let command: String + public let args: [String] + public let env: [String: String]? + + public init(command: String, args: [String] = [], env: [String: String]? = nil) { + self.command = command + self.args = args + self.env = env + } + } + + public init( + id: String, + name: String, + version: String, + entrypoint: Entrypoint, + permissions: [String] = [], + directory: URL = URL(fileURLWithPath: "/") + ) { + self.id = id + self.name = name + self.version = version + self.entrypoint = entrypoint + self.permissions = permissions + self.directory = directory + } + + private enum CodingKeys: String, CodingKey { + case id, name, version, entrypoint, permissions + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + version = try c.decode(String.self, forKey: .version) + entrypoint = try c.decode(Entrypoint.self, forKey: .entrypoint) + permissions = try c.decodeIfPresent([String].self, forKey: .permissions) ?? [] + directory = URL(fileURLWithPath: "/") + } +} + +// MARK: - 加载 + +public enum PluginManifestError: Error, Sendable { + case fileNotFound(URL) + case decode(URL, String) +} + +public extension PluginManifest { + /// 从目录加载 `plugin.json`;解析失败抛 `PluginManifestError`。 + static func load(from directory: URL) throws -> PluginManifest { + let url = directory.appendingPathComponent("plugin.json") + guard FileManager.default.fileExists(atPath: url.path) else { + throw PluginManifestError.fileNotFound(url) + } + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw PluginManifestError.decode(url, "\(error)") + } + do { + var manifest = try JSONDecoder().decode(PluginManifest.self, from: data) + manifest.directory = directory + return manifest + } catch { + throw PluginManifestError.decode(url, "\(error)") + } + } + + /// 把字符串里所有 `${pluginDir}` 替换成 manifest 所在目录的绝对路径。 + func substitute(_ s: String) -> String { + s.replacingOccurrences(of: "${pluginDir}", with: directory.path) + } + + /// 解析好的、可直接交给 `Process` 的子进程描述。 + var resolvedEntrypoint: ResolvedEntrypoint { + ResolvedEntrypoint( + executable: URL(fileURLWithPath: "/usr/bin/env"), + arguments: [entrypoint.command] + entrypoint.args.map(substitute), + environment: entrypoint.env?.mapValues(substitute), + workingDirectory: directory + ) + } +} + +public struct ResolvedEntrypoint: Sendable { + public let executable: URL + public let arguments: [String] + public let environment: [String: String]? + public let workingDirectory: URL +} diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift new file mode 100644 index 00000000..245e2ae1 --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift @@ -0,0 +1,257 @@ +import AhaKeyPluginKit +import SwiftUI + +@main +struct PluginShowcaseApp: App { + @StateObject private var model = PluginShowcaseModel() + + var body: some Scene { + WindowGroup("AhaKey Plugin Showcase") { + PluginShowcaseView() + .environmentObject(model) + } + } +} +@MainActor +final class PluginShowcaseModel: ObservableObject { + struct PluginSummary: Identifiable { + let id: String + let manifestName: String + let version: String + let reportedName: String + let methods: [String] + } + + @Published var plugins: [PluginSummary] = [] + @Published var statusJSON = "Waiting for plugin status..." + @Published var activity: [String] = [] + @Published var isBusy = false + + private let manager = PluginManager() + private var loadedPlugins: [PluginManager.LoadedPlugin] = [] + private var pollingTask: Task? + private var started = false + + func start() { + guard !started else { return } + started = true + + Task { await reload() } + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + await refreshStatus(recordActivity: false) + } + } + } + + func stop() { + pollingTask?.cancel() + pollingTask = nil + started = false + Task { await manager.unloadAll() } + } + + func reload() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + await manager.unloadAll() + let count = await manager.loadAll() + loadedPlugins = await manager.allLoaded() + plugins = loadedPlugins.map { plugin in + PluginSummary( + id: plugin.manifest.id, + manifestName: plugin.manifest.name, + version: plugin.manifest.version, + reportedName: plugin.initialize?.name ?? "", + methods: plugin.initialize?.methods ?? [] + ) + } + appendActivity("Loaded \(count) plugin(s)") + + let failures = await manager.failures + for failure in failures { + appendActivity("Failed \(failure.manifestDirectory.lastPathComponent): \(failure.error)") + } + + await refreshStatus() + } + + func refreshStatus(recordActivity: Bool = true) async { + guard let plugin = pluginSupporting("demo/getStatus") else { + statusJSON = "No loaded plugin exposes demo/getStatus." + return + } + + do { + let result = try await plugin.host.client.call("demo/getStatus", timeout: 4) + statusJSON = prettyJSON(result) + if recordActivity { + appendActivity("Refreshed status through \(plugin.manifest.id)") + } + } catch { + statusJSON = "Status request failed: \(error)" + if recordActivity { + appendActivity("Status request failed: \(error)") + } + } + } + + func greet() async { + guard let plugin = pluginSupporting("demo/greet") else { + appendActivity("No loaded plugin exposes demo/greet") + return + } + + do { + let result = try await plugin.host.client.call( + "demo/greet", + params: .object(["name": .string("AhaKey Studio")]), + timeout: 4 + ) + appendActivity("demo/greet -> \(compactJSON(result))") + } catch { + appendActivity("demo/greet failed: \(error)") + } + } + + private func pluginSupporting(_ method: String) -> PluginManager.LoadedPlugin? { + loadedPlugins.first { $0.initialize?.methods?.contains(method) == true } + } + + private func appendActivity(_ message: String) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + activity.insert("[\(formatter.string(from: Date()))] \(message)", at: 0) + activity = Array(activity.prefix(20)) + } + + private func prettyJSON(_ value: JSONValue) -> String { + encodeJSON(value, prettyPrinted: true) + } + + private func compactJSON(_ value: JSONValue) -> String { + encodeJSON(value, prettyPrinted: false) + } + + private func encodeJSON(_ value: JSONValue, prettyPrinted: Bool) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = prettyPrinted ? [.prettyPrinted, .sortedKeys] : [.sortedKeys] + guard + let data = try? encoder.encode(value), + let text = String(data: data, encoding: .utf8) + else { + return "" + } + return text + } +} + +struct PluginShowcaseView: View { + @EnvironmentObject private var model: PluginShowcaseModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + pluginList + statusPanel + activityPanel + } + .padding(20) + } + .frame(minWidth: 720, minHeight: 620) + .task { model.start() } + .onDisappear { model.stop() } + } + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("AhaKey Plugin Showcase") + .font(.title.bold()) + Text("Swift host + TypeScript plugin + AhaKey agent bridge") + .foregroundColor(.secondary) + } + Spacer() + Button("Reload Plugins") { + Task { await model.reload() } + } + .disabled(model.isBusy) + } + } + + private var pluginList: some View { + GroupBox("Loaded Plugins") { + VStack(alignment: .leading, spacing: 10) { + if model.plugins.isEmpty { + Text("No plugins loaded. Build the TypeScript example and set AHAKEY_PLUGINS_DIR.") + .foregroundColor(.secondary) + } else { + ForEach(model.plugins) { plugin in + VStack(alignment: .leading, spacing: 4) { + Text(plugin.manifestName) + .font(.headline) + Text("\(plugin.id) v\(plugin.version)") + .font(.system(.body, design: .monospaced)) + Text("Plugin reports: \(plugin.reportedName)") + .foregroundColor(.secondary) + Text("Methods: \(plugin.methods.joined(separator: ", "))") + .font(.system(.caption, design: .monospaced)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var statusPanel: some View { + GroupBox("App-linked Demo") { + VStack(alignment: .leading, spacing: 10) { + Text("The TypeScript plugin asks the Swift host for app metadata and the physical lever state. The panel refreshes every 2 seconds.") + .foregroundColor(.secondary) + HStack { + Button("Refresh Status") { + Task { await model.refreshStatus() } + } + Button("Call demo/greet") { + Task { await model.greet() } + } + } + Text(model.statusJSON) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(8) + } + .padding(8) + } + } + + private var activityPanel: some View { + GroupBox("Activity") { + VStack(alignment: .leading, spacing: 4) { + if model.activity.isEmpty { + Text("No activity yet.") + .foregroundColor(.secondary) + } else { + ForEach(Array(model.activity.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift new file mode 100644 index 00000000..cdbec5d4 --- /dev/null +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -0,0 +1,1844 @@ +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 +} + +enum AhaKeyProtocolMode: Equatable { + case negotiating + case legacy + case current + case restrictedUnknown +} + +/// 通信日志条目 +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 isWiredConnected = 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 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] = [:] + @Published private(set) var keyboardTaskPictureStates: [KeyboardTaskPictureSlot: AhaKeyTaskPictureState] = [:] + @Published private(set) var activeTaskPictureSets: [Int: Int] = [:] + @Published private(set) var firmwareCapabilities: AhaKeyFirmwareCapabilities? + @Published private(set) var protocolMode: AhaKeyProtocolMode = .negotiating + + /// 通信日志(最近 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 = "AhaKey" + + // MARK: - Private + + private var central: CBCentralManager? + private let usbTransport = AhaKeyUSBHIDTransport() + private var isBLEConnected = false + 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 struct ProtocolResponseWaiter { + let id: UUID + let continuation: CheckedContinuation + let timeout: DispatchWorkItem + } + private struct DataWriteWaiter { + let id: UUID + let continuation: CheckedContinuation + let timeout: DispatchWorkItem + } + private var protocolResponseWaiters: [UInt8: ProtocolResponseWaiter] = [:] + private var dataWriteWaiter: DataWriteWaiter? + private var dataPacketWriteTask: Task? + private var forceBLEPictureTransfer = false + private var writeWithoutResponseReadyContinuation: CheckedContinuation? + private var currentUploadSessionID: UInt16? + + // MARK: - Init + + override init() { + super.init() + usbTransport.onConnected = { [weak self] in + Task { @MainActor in self?.usbDidConnect() } + } + usbTransport.onDisconnected = { [weak self] in + Task { @MainActor in self?.usbDidDisconnect() } + } + usbTransport.onFrame = { [weak self] data in + Task { @MainActor in self?.handleUSBFrame(data) } + } + usbTransport.onError = { [weak self] error in + Task { @MainActor in self?.appendLog("USB HID: \(error.localizedDescription)", isError: true) } + } + usbTransport.start() + let storedOwner = UserDefaults.standard.string(forKey: "lab.jawa.ahakeyconfig.bluetoothConnectionOwner") + if storedOwner == nil || storedOwner == BluetoothConnectionOwner.agentDaemon.rawValue { + suppressAutomaticConnection = true + } + // 只有蓝牙权限已授予时才创建 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) + } + + private var hasConfigurationTransport: Bool { + isWiredConnected || (peripheral != nil && commandChar != nil && dataChar != nil) + } + + private func usbDidConnect() { + guard !isWiredConnected else { return } + isWiredConnected = true + isConnected = true + deviceName = "AhaKey USB" + bleDeviceUUID = "USB 413C:2107" + bleConnectionStatus = "有线已连接" + didQueryAfterConnect = false + protocolMode = .negotiating + firmwareCapabilities = nil + appendLog("USB HID 有线配置通道已连接") + startStatusPolling() + onAllCharacteristicsReady() + } + + private func usbDidDisconnect() { + guard isWiredConnected else { return } + isWiredConnected = false + cancelPendingTransferWaiters(reason: .connectionLost) + isConnected = isBLEConnected + if isBLEConnected { + deviceName = peripheral?.name + bleDeviceUUID = peripheral?.identifier.uuidString ?? "—" + bleConnectionStatus = "已连接" + } else { + deviceName = nil + bleDeviceUUID = "—" + bleConnectionStatus = "已断开" + stopStatusPolling() + startAutoReconnectPolling() + } + appendLog("USB HID 有线配置通道已断开") + } + + var supportedTaskDisplayStates: [AhaKeyTaskDisplayState] { + firmwareCapabilities?.supportedTaskDisplayStates + ?? AhaKeyTaskDisplayState.allCases.filter { $0 != .idle } + } + + var allowsTaskPictureConfiguration: Bool { + protocolMode == .legacy || protocolMode == .current + } + + private func handleUSBFrame(_ data: Data) { + appendLog("← USB: \(data.hexString)") + parseProtocolResponse(data) + } + + // 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() + } + + /// 与 `AgentManager` 的蓝牙占用方一致:交给 Agent 时为 true,交回本 App 时为 false。 + func setSuppressedForAgentOwningKeyboard(_ suppress: Bool) { + suppressAutomaticConnection = suppress + } + + func connectAutomatically() { + if usbTransport.isConnected { + usbDidConnect() + return + } + 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.lowercased()) }) { + 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() { + if isWiredConnected { + appendLog("USB 有线连接由拔线断开") + return + } + guard let peripheral else { return } + central?.cancelPeripheralConnection(peripheral) + appendLog("用户主动断开") + } + + /// 发送原始命令到 0x7343(带队列,防止连发过载) + func writeCommand(_ data: Data, forceBLE: Bool = false) { + if isWiredConnected && !forceBLE { + do { + try usbTransport.sendCommand(data) + appendLog("→ USB CMD \(data.count)B: \(data.hexString)") + return + } catch { + appendLog( + "USB 命令发送失败,尝试回退 BLE: \(error.localizedDescription)", + isError: true + ) + } + } + 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, + preserveDefaultPictureBinding: Bool = false, + resetTransportPreference: Bool = true + ) async throws { + if resetTransportPreference { + forceBLEPictureTransfer = false + } + guard isWiredConnected || (peripheral != nil && dataChar != nil && commandChar != nil) else { + throw OLEDUploadError.channelNotReady + } + guard !frames.isEmpty else { + throw OLEDUploadError.noFrames + } + guard frames.count <= AhaKeyCommand.oledMaxFrames else { + throw OLEDUploadError.tooManyFrames(max: AhaKeyCommand.oledMaxFrames) + } + guard frames.allSatisfy({ $0.count == AhaKeyCommand.oledEncodedFrameBytes }) else { + throw OLEDUploadError.invalidEncodedFrameSize + } + + 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)") + + let usesSessionUpload = firmwareCapabilities?.supportsSessionUpload == true + var uploadFinished = false + defer { + if !uploadFinished, let sessionID = currentUploadSessionID { + writeCommand( + AhaKeyCommand.abortPictureWrite(sessionID: sessionID), + forceBLE: forceBLEPictureTransfer + ) + } + currentUploadSessionID = nil + isUploadingOLED = false + oledUploadProgress = nil + } + + 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) + var chunkAttempt = 0 + while true { + chunkAttempt += 1 + let prepare: Data + let expectedCommand: UInt8 + if usesSessionUpload { + let sessionID = UInt16.random(in: 1 ... UInt16.max) + currentUploadSessionID = sessionID + prepare = AhaKeyCommand.prepareSessionWrite( + sessionID: sessionID, + chunkLength: chunk.data.count, + address: address + ) + expectedCommand = AhaKeyCommand.cmdPrepareSessionWrite + } else { + prepare = AhaKeyCommand.prepareWrite( + chunkLength: chunk.data.count, + address: address + ) + expectedCommand = AhaKeyCommand.cmdPrepareWrite + } + let prepareResponse = try await sendCommandAwaitingResponse( + prepare, + expectedCommand: expectedCommand, + forceBLE: forceBLEPictureTransfer + ) + guard prepareResponse.status == 0 else { + throw OLEDUploadError.deviceRejected( + command: expectedCommand, + status: prepareResponse.status + ) + } + + do { + try await writeDataChunk(chunk.data) + currentUploadSessionID = nil + break + } catch OLEDUploadError.timeout(let command) + where command == AhaKeyCommand.cmdWriteResult && + chunkAttempt == 1 && + isWiredConnected && + peripheral != nil && + dataChar != nil { + forceBLEPictureTransfer = true + if let sessionID = currentUploadSessionID { + let abortResponse = try await sendCommandAwaitingResponse( + AhaKeyCommand.abortPictureWrite(sessionID: sessionID), + expectedCommand: AhaKeyCommand.cmdAbortPictureWrite, + timeoutSeconds: 2.0, + forceBLE: true + ) + guard abortResponse.status == 0 else { + throw OLEDUploadError.deviceRejected( + command: AhaKeyCommand.cmdAbortPictureWrite, + status: abortResponse.status + ) + } + } + currentUploadSessionID = nil + appendLog("当前数据块 USB 确认丢失,立即改用 BLE 重写", isError: true) + } + } + 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 + ) + } + + if preserveDefaultPictureBinding { + let finishCommand = AhaKeyCommand.finishTaskPictureWrite() + appendLog("→ finishTaskPictureWrite mode=\(mode) startIndex=\(startIndex) hex=\(finishCommand.hexString)") + let finishResponse = try await sendCommandAwaitingResponse( + finishCommand, + expectedCommand: AhaKeyCommand.cmdFinishTaskPicWrite + ) + guard finishResponse.status == 0 else { + throw OLEDUploadError.deviceRejected( + command: AhaKeyCommand.cmdFinishTaskPicWrite, + status: finishResponse.status + ) + } + } else { + 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)") + let updateResponse = try await sendCommandAwaitingResponse( + updateCommand, + expectedCommand: AhaKeyCommand.cmdUpdatePic + ) + guard updateResponse.status == 0 else { + throw OLEDUploadError.deviceRejected( + command: AhaKeyCommand.cmdUpdatePic, + status: updateResponse.status + ) + } + } + uploadFinished = true + appendLog("LCD 上传完成: \(frames.count) 帧, start=\(startIndex)") + } + + /// 批量写入命令(每条间隔 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 + } + + /// Reuse the proven 0x80/data frame writer, then bind the uploaded range to a task GIF slot. + /// Task resources must not use 0x82: that command replaces the normal per-mode animation binding. + func uploadTaskOLEDFrames(_ frames: [Data], fps: Int, mode: UInt8, set: UInt8, state: UInt8, startIndex: UInt16) async throws { + forceBLEPictureTransfer = false + let delay = UInt16(max(1, 1000 / max(1, fps))) + // One image's transient BLE hiccup (timeout / superseded / a stale + // metadata read) should not fail the whole multi-image sync. Retry the + // write+confirm for this single slot a few times before giving up, so + // the caller only sees a hard failure after the device really refuses. + let maxAttempts = 3 + var lastError: Error? + for attempt in 1 ... maxAttempts { + do { + try await uploadOLEDFrames( + frames, + fps: fps, + mode: mode, + startIndex: startIndex, + preserveDefaultPictureBinding: true, + resetTransportPreference: false + ) + _ = try await sendCommandAwaitingResponse( + AhaKeyCommand.updateTaskPictureSet(mode: mode, set: set, state: state, startIndex: startIndex, frameCount: UInt16(frames.count), timeDelayMs: delay), + expectedCommand: AhaKeyCommand.cmdUpdateTaskPicSet + ) + let confirmed = try await readTaskPictureState(mode: mode, set: set, state: state) + guard confirmed.startIndex == Int(startIndex), confirmed.picLength == frames.count, + confirmed.frameInterval == Int(delay) else { + throw OLEDUploadError.taskPictureMetadataMismatch + } + 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 + ) + return + } catch let error as OLEDUploadError { + // Cancellation and permanent failures must surface immediately; + // only transient transport-level errors are worth retrying. + switch error { + case .cancelled, .connectionLost, .noFrames, .tooManyFrames, .channelNotReady, .noAvailablePictureSlot: + throw error + default: + lastError = error + } + } catch is CancellationError { + throw OLEDUploadError.cancelled + } + if attempt < maxAttempts { + appendCommLogLine("任务图写入第 \(attempt) 次失败,重试中…(mode\(mode) 套图\(set) 状态\(state))", isError: true) + try? await Task.sleep(nanoseconds: 200 * 1_000_000) + if Task.isCancelled { throw OLEDUploadError.cancelled } + } + } + throw lastError ?? OLEDUploadError.taskPictureMetadataMismatch + } + + func clearTaskPicture(mode: UInt8, set: UInt8, state: UInt8) async throws { + _ = try await sendCommandAwaitingResponse( + AhaKeyCommand.updateTaskPictureSet(mode: mode, set: set, state: state, startIndex: 0, frameCount: 0, timeDelayMs: 0), + expectedCommand: AhaKeyCommand.cmdUpdateTaskPicSet + ) + let confirmed = try await readTaskPictureState(mode: mode, set: set, state: state) + guard confirmed.startIndex == 0, confirmed.picLength == 0, confirmed.frameInterval == 0 else { + throw OLEDUploadError.taskPictureMetadataMismatch + } + 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: 0, picLength: 0, frameInterval: 0, + 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.readTaskPictureSet(mode: mode, set: set, state: state), expectedCommand: AhaKeyCommand.cmdReadTaskPicSet) + guard let picture = AhaKeyResponseParser.parseTaskPictureSetResponse(response.payload) else { throw OLEDUploadError.invalidTaskPictureStatePayload } + keyboardTaskPictureStates[KeyboardTaskPictureSlot(mode: picture.mode, set: picture.set, state: picture.state)] = picture + activeTaskPictureSets[picture.mode] = picture.activeSet + return picture + } + + func readAllTaskPictureStates() async throws -> [AhaKeyTaskPictureState] { + var result: [AhaKeyTaskPictureState] = [] + var failedReads = 0 + for mode in 0 ..< AhaKeyCommand.oledModeCount { + for set in 0 ..< 2 { + for state in supportedTaskDisplayStates { + do { + result.append(try await readTaskPictureState(mode: UInt8(mode), set: UInt8(set), state: UInt8(state.rawValue))) + } catch OLEDUploadError.cancelled { + throw OLEDUploadError.cancelled + } catch OLEDUploadError.connectionLost { + throw OLEDUploadError.connectionLost + } catch { + // Per-slot tolerance: a single read hiccup must not blank + // out the whole device-state snapshot. A missing slot only + // means *that* slot is treated as empty (and may re-upload); + // the slots that did read keep their lengths so unchanged + // images are not needlessly re-sent. + failedReads += 1 + appendCommLogLine("槽位状态读取失败(mode\(mode) 套图\(set) 状态\(state)),按空槽处理:\(error.localizedDescription)", isError: true) + } + } + } + } + // Only treat the whole snapshot as unavailable when nothing came back at + // all (e.g. firmware without the task-picture command). A partial read is + // still useful for the incremental diff. + if failedReads > 0 { + throw OLEDUploadError.invalidTaskPictureStatePayload + } + return result + } + + func setActiveTaskPictureSet(mode: UInt8, set: UInt8) async throws { + let response = try await sendCommandAwaitingResponse( + AhaKeyCommand.setActiveTaskPictureSet(mode: mode, set: set), + expectedCommand: AhaKeyCommand.cmdSetActiveTaskPicSet + ) + guard response.payload.count >= 2 else { throw OLEDUploadError.invalidTaskPictureStatePayload } + activeTaskPictureSets[Int(response.payload[0])] = Int(response.payload[1]) + } + + func saveConfigAwaitingResponse() async throws { + _ = try await sendCommandAwaitingResponse(AhaKeyCommand.saveConfig(), expectedCommand: AhaKeyCommand.cmdSaveConfig) + } + + /// 同步 IDE 状态到键盘 LED + func updateIDEState(_ state: IDEState) { + guard hasConfigurationTransport else { return } + let cmd = AhaKeyCommand.updateState(state) + 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 hasConfigurationTransport else { return } + writeCommand(AhaKeyCommand.setLightMapping(mode: mode, stateEffects: stateEffects)) + appendLog("→ 灯效映射 mode=\(mode) effects=\(stateEffects)") + } + + func setBrightness(_ value: UInt8) { + guard hasConfigurationTransport else { return } + writeCommand(AhaKeyCommand.setBrightness(value)) + appendLog("→ 亮度 \(value)") + } + + func previewLightEffect(_ effect: UInt8) { + guard hasConfigurationTransport else { return } + writeCommand(AhaKeyCommand.previewLightEffect(effect)) + appendLog("→ 预览灯效 \(effect)") + } + + func setWorkMode(_ mode: UInt8) { + guard hasConfigurationTransport else { return } + writeCommand(AhaKeyCommand.setWorkMode(mode)) + appendLog("→ 工作模式 \(mode)") + } + + /// 修改设备蓝牙名称 + 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 hasConfigurationTransport else { return } + guard !didQueryAfterConnect else { return } + didQueryAfterConnect = true + appendLog("所有特征就绪,开始顺序握手") + Task { [weak self] in + guard let self else { return } + self.queryDeviceStatus() + try? await Task.sleep(nanoseconds: UInt64(200) * 1_000_000) + await self.queryFirmwareCapabilities() + await self.queryAllPictureStates() + } + } + + private func queryFirmwareCapabilities() async { + var lastFailure = "设备未返回能力帧" + for attempt in 1 ... 3 { + do { + let response = try await sendCommandAwaitingResponse( + AhaKeyCommand.queryCapabilities(), + expectedCommand: AhaKeyCommand.cmdCapabilities, + timeoutSeconds: 2.0 + ) + guard response.status == 0 else { + lastFailure = String( + format: "能力查询被设备拒绝,状态码 0x%02X", + response.status + ) + continue + } + guard let capabilities = AhaKeyResponseParser.parseCapabilities(response.payload) else { + lastFailure = "能力帧长度或字段无效" + continue + } + firmwareCapabilities = capabilities + protocolMode = capabilities.protocolVersion == 3 ? .current : .restrictedUnknown + appendLog( + "协议 v\(capabilities.protocolVersion),\(capabilities.modeCount) modes," + + "\(capabilities.stateCount) states,BLE packet \(capabilities.maxPacketSize)B" + ) + return + } catch { + lastFailure = error.localizedDescription + } + if attempt < 3 { + appendLog("能力查询第 \(attempt) 次失败,正在重试:\(lastFailure)", isError: true) + try? await Task.sleep(nanoseconds: 250 * 1_000_000) + } + } + + if firmwareMainVersion == 1 { + protocolMode = .legacy + appendLog("连续三次能力查询失败,按已知旧版固件兼容:\(lastFailure)", isError: true) + } else { + protocolMode = .restrictedUnknown + appendLog("连续三次无法识别固件协议,进入受限兼容模式:\(lastFailure)", isError: true) + } + } + + /// 顺序查询每个 mode 的 0x83 图片元信息,结果累积到 keyboardPictureStates + private func queryAllPictureStates() async { + for slot in 0..<4 { + do { + let state = try await readPictureState(mode: UInt8(slot)) + keyboardPictureStates[slot] = KeyboardPictureState( + frameCount: state.picLength, + frameIntervalMs: state.frameInterval + ) + appendLog(" mode\(slot) flash: 帧数=\(state.picLength) 间隔=\(state.frameInterval)ms") + } catch { + appendLog(" mode\(slot) 图片状态查询失败: \(error)", isError: true) + } + } + } + + private func sendCommandAwaitingResponse( + _ data: Data, + expectedCommand: UInt8, + timeoutSeconds: Double = 5.0, + forceBLE: Bool = false + ) async throws -> CommandResponse { + do { + return try await sendCommandAwaitingResponseOnce( + data, + expectedCommand: expectedCommand, + timeoutSeconds: timeoutSeconds, + forceBLE: forceBLE + ) + } catch OLEDUploadError.timeout + where !forceBLE && isWiredConnected && peripheral != nil && commandChar != nil { + if expectedCommand == AhaKeyCommand.cmdPrepareWrite || + expectedCommand == AhaKeyCommand.cmdPrepareSessionWrite { + forceBLEPictureTransfer = true + } + appendLog( + "USB 命令 0x\(String(format: "%02X", expectedCommand)) 响应超时,回退 BLE 重试", + isError: true + ) + return try await sendCommandAwaitingResponseOnce( + data, + expectedCommand: expectedCommand, + timeoutSeconds: timeoutSeconds, + forceBLE: true + ) + } + } + + private func sendCommandAwaitingResponseOnce( + _ data: Data, + expectedCommand: UInt8, + timeoutSeconds: Double, + forceBLE: Bool + ) async throws -> CommandResponse { + let id = UUID() + return try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: OLEDUploadError.cancelled) + return + } + let timeout = DispatchWorkItem { [weak self] in + self?.finishProtocolWaiter(expectedCommand, id: id, result: .failure(OLEDUploadError.timeout(command: expectedCommand))) + } + if protocolResponseWaiters[expectedCommand] != nil { + finishProtocolWaiter(expectedCommand, result: .failure(OLEDUploadError.requestSuperseded)) + } + protocolResponseWaiters[expectedCommand] = ProtocolResponseWaiter(id: id, continuation: continuation, timeout: timeout) + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeout) + writeCommand(data, forceBLE: forceBLE) + } + }, onCancel: { [weak self] in + Task { @MainActor in + self?.finishProtocolWaiter(expectedCommand, id: id, result: .failure(OLEDUploadError.cancelled)) + } + }) + } + + private func finishProtocolWaiter(_ command: UInt8, id: UUID? = nil, result: Result) { + guard let waiter = protocolResponseWaiters[command], id == nil || waiter.id == id else { return } + protocolResponseWaiters.removeValue(forKey: command) + waiter.timeout.cancel() + waiter.continuation.resume(with: result) + } + + private func writeDataChunk(_ data: Data, timeoutSeconds: Double = 5.0) async throws { + let id = UUID() + try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: OLEDUploadError.cancelled) + return + } + let timeout = DispatchWorkItem { [weak self] in + if let self, self.isWiredConnected, + self.peripheral != nil, self.dataChar != nil { + self.forceBLEPictureTransfer = true + self.appendLog("USB 图片数据确认超时,后续重试改走 BLE", isError: true) + } + self?.finishDataWrite(id: id, result: .failure(OLEDUploadError.timeout(command: AhaKeyCommand.cmdWriteResult))) + } + if dataWriteWaiter != nil { + finishDataWrite(result: .failure(OLEDUploadError.requestSuperseded)) + } + dataWriteWaiter = DataWriteWaiter(id: id, continuation: continuation, timeout: timeout) + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeout) + if self.isWiredConnected && !self.forceBLEPictureTransfer { + Task { @MainActor in + do { + try await self.usbTransport.sendData(data) + self.appendLog("→ USB DATA \(data.count)B") + } catch { + guard let peripheral = self.peripheral, + let characteristic = self.dataChar else { + self.finishDataWrite(id: id, result: .failure(error)) + return + } + self.appendLog("USB DATA 失败,自动回退 BLE:\(error.localizedDescription)", isError: true) + self.startBLEDataWrite( + data, + peripheral: peripheral, + characteristic: characteristic + ) + } + } + return + } + guard let peripheral = self.peripheral, let characteristic = self.dataChar else { + self.finishDataWrite(id: id, result: .failure(OLEDUploadError.channelNotReady)) + return + } + self.startBLEDataWrite(data, peripheral: peripheral, characteristic: characteristic) + } + }, onCancel: { [weak self] in + Task { @MainActor in + self?.finishDataWrite(id: id, result: .failure(OLEDUploadError.cancelled)) + } + }) + } + + private func startBLEDataWrite( + _ data: Data, + peripheral: CBPeripheral, + characteristic: CBCharacteristic + ) { + let type: CBCharacteristicWriteType = + characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + let negotiatedLength = max(1, peripheral.maximumWriteValueLength(for: type)) + let firmwareLimit = firmwareCapabilities?.maxPacketSize ?? AhaKeyCommand.oledPacketSize + let maxPacketLength = min(negotiatedLength, firmwareLimit) + let sessionID = currentUploadSessionID + let tagLength = sessionID == nil ? 0 : 2 + let payloadLength = max(1, maxPacketLength - tagLength) + appendLog("→ DATA \(data.count)B, 分片 \(payloadLength)B (协商上限 \(negotiatedLength)B)") + dataPacketWriteTask?.cancel() + dataPacketWriteTask = Task { @MainActor in + let startedAt = Date() + var bytesInFlightWindow = 0 + for offset in stride(from: 0, to: data.count, by: payloadLength) { + guard !Task.isCancelled else { return } + let end = min(offset + payloadLength, data.count) + if type == .withoutResponse { + await self.waitUntilPeripheralCanSendWriteWithoutResponse(peripheral) + guard !Task.isCancelled else { return } + } + var packet = Data() + if let sessionID { + packet.append(UInt8(sessionID & 0xFF)) + packet.append(UInt8((sessionID >> 8) & 0xFF)) + } + packet.append(data[offset ..< end]) + peripheral.writeValue(packet, for: characteristic, type: type) + bytesInFlightWindow += end - offset + if type == .withResponse { + do { + try await Task.sleep(nanoseconds: UInt64(12) * 1_000_000) + } catch { + return + } + } else if bytesInFlightWindow >= 976, end < data.count { + // CoreBluetooth backpressure covers the host queue, while + // this short window also gives the CH582 task time to drain + // its 2 KB application FIFO into W25QXX flash. + bytesInFlightWindow = 0 + do { + try await Task.sleep(nanoseconds: UInt64(6) * 1_000_000) + } catch { + return + } + } + } + let elapsed = max(0.001, Date().timeIntervalSince(startedAt)) + self.appendLog( + String(format: "DATA 已发送 %.0f KB/s", Double(data.count) / elapsed / 1024.0) + ) + } + } + + private func waitUntilPeripheralCanSendWriteWithoutResponse(_ peripheral: CBPeripheral) async { + if peripheral.canSendWriteWithoutResponse { return } + await withCheckedContinuation { continuation in + if peripheral.canSendWriteWithoutResponse { + continuation.resume() + } else { + writeWithoutResponseReadyContinuation = continuation + } + } + } + + private func finishDataWrite(id: UUID? = nil, result: Result) { + guard let waiter = dataWriteWaiter, id == nil || waiter.id == id else { return } + dataWriteWaiter = nil + waiter.timeout.cancel() + writeWithoutResponseReadyContinuation?.resume() + writeWithoutResponseReadyContinuation = nil + dataPacketWriteTask?.cancel() + dataPacketWriteTask = nil + waiter.continuation.resume(with: result) + } + + func cancelOLEDUpload() { + if let sessionID = currentUploadSessionID { + writeCommand(AhaKeyCommand.abortPictureWrite(sessionID: sessionID)) + currentUploadSessionID = nil + } + writeWithoutResponseReadyContinuation?.resume() + writeWithoutResponseReadyContinuation = nil + finishDataWrite(result: .failure(OLEDUploadError.cancelled)) + for command in Array(protocolResponseWaiters.keys) { + finishProtocolWaiter(command, result: .failure(OLEDUploadError.cancelled)) + } + } +} + +// MARK: - 拨杆档位切换 → 系统通知(与 `switchState` 同源,放在本文件避免独立 .swift 未被索引器收录) + +/// 监听 `AhaKeyBLEManager.switchState` 的稳定变化,在拨杆切换档位时弹一条 macOS 通知。 +@MainActor +final class SwitchStateNotifier: ObservableObject { + static let shared = SwitchStateNotifier() + + private weak var bleManager: AhaKeyBLEManager? + private var switchStateCancellable: AnyCancellable? + private var agentSwitchStateCancellable: 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, switchStateCancellable != nil, agentSwitchStateCancellable != nil { return } + + bleManager = manager + lastObservedState = nil + hasInitialState = false + 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 + 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 { + 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 { + 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 + case taskPictureMetadataMismatch + case cancelled + case connectionLost + case requestSuperseded + case unsupportedFirmwareProtocol + case invalidEncodedFrameSize + + 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 固件。" + case .taskPictureMetadataMismatch: + return "设备没有保存对应套图/状态的动画槽位信息;请更新到双套图固件后重试。" + case .cancelled: + return "图片写入已取消。已完成的图片会保留,未完成的图片可稍后继续写入。" + case .connectionLost: + return "键盘连接已中断,当前图片写入已停止。请重连后继续。" + case .requestSuperseded: + return "设备命令被新的请求替换,请重新尝试。" + case .unsupportedFirmwareProtocol: + return "无法识别当前固件协议,图片配置已进入受限兼容模式。" + case .invalidEncodedFrameSize: + return "图片尚未转换为 160×80 LCD 帧,已停止发送原图数据。" + } + } +} + +// 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.lowercased()) 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.isBLEConnected = true + self.isConnected = true + if !self.isWiredConnected { + self.deviceName = peripheral.name + self.bleDeviceUUID = peripheral.identifier.uuidString + self.bleConnectionStatus = "已连接" + } + self.lastPeripheralUUID = peripheral.identifier + 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 + self.cancelPendingTransferWaiters(reason: .connectionLost) + 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.isBLEConnected = false + self.isConnected = self.isWiredConnected + if !self.isWiredConnected { + 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() + if !self.isWiredConnected { + 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.isBLEConnected && !self.isWiredConnected { + 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) 完成") + } + } + } + + nonisolated func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + Task { @MainActor in + self.writeWithoutResponseReadyContinuation?.resume() + self.writeWithoutResponseReadyContinuation = nil + } + } + + 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 + brightness = status.brightness + activeTaskPictureSets[status.workMode] = status.activePictureSet + 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) { + finishProtocolWaiter(response.cmd, result: .success((response.status, response.payload))) + + if response.cmd == AhaKeyCommand.cmdWriteResult { + if let expectedSession = currentUploadSessionID { + guard response.payload.count >= 2 else { + appendLog("忽略缺少 session 的图片写入确认", isError: true) + return + } + let responseSession = + UInt16(response.payload[0]) | + (UInt16(response.payload[1]) << 8) + guard responseSession == expectedSession else { + appendLog( + "忽略过期图片确认 session=\(responseSession),当前=\(expectedSession)", + isError: true + ) + return + } + } + if response.status == 0 { + finishDataWrite(result: .success(())) + } else { + finishDataWrite(result: .failure(OLEDUploadError.deviceRejected(command: response.cmd, status: response.status))) + } + } + + 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)") + } + } + + private func cancelPendingTransferWaiters(reason: OLEDUploadError) { + writeWithoutResponseReadyContinuation?.resume() + writeWithoutResponseReadyContinuation = nil + finishDataWrite(result: .failure(reason)) + for command in Array(protocolResponseWaiters.keys) { + finishProtocolWaiter(command, result: .failure(reason)) + } + } + + /// 发送探测命令 + func sendProbeCommands() { + guard hasConfigurationTransport 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/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift new file mode 100644 index 00000000..696eb9e3 --- /dev/null +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift @@ -0,0 +1,742 @@ +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 oledEncodedFrameBytes = oledWidth * oledHeight * 2 + static let oledFrameSlotSize = 28_672 + static let oledFactoryReservedSlots = 10 + static let oledModeCount = 4 + static let oledMaxFramesPerMode = 70 + static let oledMaxFrames = oledMaxFramesPerMode + /// Task-state assets are uploaded and committed independently. + static let taskOLEDMaxFrames = 30 + static let animatedOLEDMaxSourceFileBytes = 50 * 1024 * 1024 + static let staticOLEDMaxSourceFileBytes = 50 * 1024 * 1024 + /// 固件端要求每个 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 + // AI OLED state resources: Working=1, Waiting=2, Done=3. + static let cmdUpdateTaskPic: UInt8 = 0x93 + static let cmdReadTaskPicState: UInt8 = 0x94 + static let cmdUpdateTaskPicSet: UInt8 = 0x95 + static let cmdReadTaskPicSet: UInt8 = 0x96 + static let cmdSetActiveTaskPicSet: UInt8 = 0x97 + /// Finish a task-GIF data transfer without changing the normal OLED animation binding. + static let cmdFinishTaskPicWrite: UInt8 = 0x98 + static let cmdCapabilities: UInt8 = 0x99 + static let cmdAbortPictureWrite: UInt8 = 0x9A + static let cmdPrepareSessionWrite: UInt8 = 0x9B + + 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) + } + + static func queryCapabilities() -> Data { + Data(header + [cmdCapabilities] + trailer) + } + + static func prepareSessionWrite(sessionID: UInt16, chunkLength: Int, address: UInt32) -> Data { + let payload: [UInt8] = [ + UInt8(sessionID & 0xFF), UInt8((sessionID >> 8) & 0xFF), + 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 + [cmdPrepareSessionWrite] + payload + trailer) + } + + static func abortPictureWrite(sessionID: UInt16? = nil) -> Data { + guard let sessionID else { + return Data(header + [cmdAbortPictureWrite] + trailer) + } + return Data(header + [ + cmdAbortPictureWrite, + UInt8(sessionID & 0xFF), UInt8((sessionID >> 8) & 0xFF), + ] + 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) + } + + /// AI OLED metadata: mode, state, flash frame range and frame delay. + static func updateTaskPicture(mode: UInt8, state: UInt8, startIndex: UInt16, frameCount: UInt16, timeDelayMs: UInt16) -> Data { + let payload: [UInt8] = [ + mode, 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) + } + + static func readTaskPictureState(mode: UInt8, state: UInt8) -> Data { + Data(header + [cmdReadTaskPicState, mode, state] + trailer) + } + + static func updateTaskPictureSet(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 + [cmdUpdateTaskPicSet] + payload + trailer) + } + + static func readTaskPictureSet(mode: UInt8, set: UInt8, state: UInt8) -> Data { + Data(header + [cmdReadTaskPicSet, mode, set, state] + trailer) + } + + static func setActiveTaskPictureSet(mode: UInt8, set: UInt8) -> Data { + Data(header + [cmdSetActiveTaskPicSet, mode, set] + trailer) + } + + static func finishTaskPictureWrite() -> Data { + Data(header + [cmdFinishTaskPicWrite] + 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) + } +} + +struct AhaKeyFirmwareCapabilities: Equatable { + static let idleTaskPictureFlag: UInt16 = 1 << 0 + static let sessionUploadFlag: UInt16 = 1 << 3 + + let protocolVersion: Int + let modeCount: Int + let setCount: Int + let stateCount: Int + let flags: UInt16 + let maxPacketSize: Int + let userSlotLimit: Int + let factorySlotBase: Int + let factoryBundleVersion: UInt32 + let factoryManifestCRC: UInt32 + let factoryStatus: Int + let factoryError: Int + let reclaimSlotBase: Int + let reclaimSlotLimit: Int + + var supportsIdleTaskPicture: Bool { + stateCount >= 4 && flags & Self.idleTaskPictureFlag != 0 + } + + var supportsSessionUpload: Bool { + flags & Self.sessionUploadFlag != 0 + } + + var supportedTaskDisplayStates: [AhaKeyTaskDisplayState] { + supportsIdleTaskPicture + ? AhaKeyTaskDisplayState.allCases + : AhaKeyTaskDisplayState.allCases.filter { $0 != .idle } + } +} + +/// 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 + let activePictureSet: Int +} + +struct AhaKeyPictureState { + let mode: Int + let startIndex: Int + let picLength: Int + let frameInterval: Int + 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)? { + 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 + let activePictureSet = payload.count >= 10 ? Int(payload[base + 8]) : 0 + 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, + activePictureSet: activePictureSet + ) + } + + static func parsePictureStateResponse(_ payload: Data) -> AhaKeyPictureState? { + // 0x83 payload is mode + three UInt16 fields + max-slot UInt16 = 9 bytes. + 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 + ) + } + + static func parseTaskPictureStateResponse(_ payload: Data) -> AhaKeyTaskPictureState? { + guard payload.count >= 10 else { return nil } + return AhaKeyTaskPictureState( + mode: Int(payload[0]), set: 0, state: Int(payload[1]), + startIndex: Int(UInt16(payload[2]) | (UInt16(payload[3]) << 8)), + picLength: Int(UInt16(payload[4]) | (UInt16(payload[5]) << 8)), + frameInterval: Int(UInt16(payload[6]) | (UInt16(payload[7]) << 8)), + allModeMaxPic: Int(UInt16(payload[8]) | (UInt16(payload[9]) << 8)), activeSet: 0 + ) + } + + static func parseTaskPictureSetResponse(_ payload: Data) -> AhaKeyTaskPictureState? { + guard payload.count >= 12 else { return nil } + return AhaKeyTaskPictureState( + mode: Int(payload[0]), set: Int(payload[1]), state: Int(payload[2]), + startIndex: Int(UInt16(payload[3]) | (UInt16(payload[4]) << 8)), + picLength: Int(UInt16(payload[5]) | (UInt16(payload[6]) << 8)), + frameInterval: Int(UInt16(payload[7]) | (UInt16(payload[8]) << 8)), + allModeMaxPic: Int(UInt16(payload[9]) | (UInt16(payload[10]) << 8)), activeSet: Int(payload[11]) + ) + } + + static func parseCapabilities(_ payload: Data) -> AhaKeyFirmwareCapabilities? { + guard payload.count >= 14 else { return nil } + func u16(_ offset: Int) -> UInt16 { + UInt16(payload[offset]) | (UInt16(payload[offset + 1]) << 8) + } + func u32(_ offset: Int) -> UInt32 { + UInt32(payload[offset]) + | (UInt32(payload[offset + 1]) << 8) + | (UInt32(payload[offset + 2]) << 16) + | (UInt32(payload[offset + 3]) << 24) + } + let hasExtendedFactoryFields = payload.count >= 22 + return AhaKeyFirmwareCapabilities( + protocolVersion: Int(payload[0]), + modeCount: Int(payload[1]), + setCount: Int(payload[2]), + stateCount: Int(payload[3]), + flags: u16(4), + maxPacketSize: Int(u16(6)), + userSlotLimit: Int(u16(8)), + factorySlotBase: hasExtendedFactoryFields ? Int(u16(10)) : Int(u16(8)), + factoryBundleVersion: hasExtendedFactoryFields ? u32(12) : 0, + factoryManifestCRC: hasExtendedFactoryFields ? u32(16) : 0, + factoryStatus: hasExtendedFactoryFields ? Int(payload[20]) : 0, + factoryError: hasExtendedFactoryFields ? Int(payload[21]) : 0, + reclaimSlotBase: payload.count >= 26 ? Int(u16(22)) : Int(u16(10)), + reclaimSlotLimit: payload.count >= 26 ? Int(u16(24)) : Int(u16(12)) + ) + } + + /// 检查是否是 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/BLE/AhaKeyUSBHIDTransport.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyUSBHIDTransport.swift new file mode 100644 index 00000000..d993ac82 --- /dev/null +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyUSBHIDTransport.swift @@ -0,0 +1,152 @@ +import Foundation +import IOKit.hid + +/// USB configuration interface exposed by the firmware alongside the keyboard HID interface. +/// Windows uses the same VID/PID and 64-byte reports on interface MI_01. +final class AhaKeyUSBHIDTransport { + static let vendorID = 0x413C + static let productID = 0x2107 + static let reportSize = 64 + static let commandChannel: UInt8 = 0xA1 + static let dataChannel: UInt8 = 0xA2 + + var onConnected: (() -> Void)? + var onDisconnected: (() -> Void)? + var onFrame: ((Data) -> Void)? + var onError: ((Error) -> Void)? + + private let manager: IOHIDManager + private var device: IOHIDDevice? + private var reportBuffer = [UInt8](repeating: 0, count: reportSize + 1) + + init() { + manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + let matching: [String: Any] = [ + kIOHIDVendorIDKey as String: Self.vendorID, + kIOHIDProductIDKey as String: Self.productID, + kIOHIDPrimaryUsagePageKey as String: 0xFF00, + ] + IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary) + IOHIDManagerRegisterDeviceMatchingCallback( + manager, + { context, _, _, device in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().attach(device) + }, + Unmanaged.passUnretained(self).toOpaque() + ) + IOHIDManagerRegisterDeviceRemovalCallback( + manager, + { context, _, _, device in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().remove(device) + }, + Unmanaged.passUnretained(self).toOpaque() + ) + IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + } + + deinit { + IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + } + + var isConnected: Bool { device != nil } + + func start() { + let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + if result != kIOReturnSuccess { + onError?(USBTransportError.openFailed(result)) + } + } + + func sendCommand(_ frame: Data) throws { + guard frame.count <= Self.reportSize - 2 else { throw USBTransportError.commandTooLarge } + try send(channel: Self.commandChannel, payload: frame) + } + + func sendData(_ data: Data) async throws { + var offset = 0 + while offset < data.count { + let end = min(offset + Self.reportSize - 2, data.count) + try send(channel: Self.dataChannel, payload: Data(data[offset ..< end])) + offset = end + try await Task.sleep(nanoseconds: 2_000_000) + } + } + + private func attach(_ candidate: IOHIDDevice) { + guard device == nil else { return } + let result = IOHIDDeviceOpen(candidate, IOOptionBits(kIOHIDOptionsTypeNone)) + guard result == kIOReturnSuccess else { + onError?(USBTransportError.openFailed(result)) + return + } + device = candidate + IOHIDDeviceRegisterInputReportCallback( + candidate, + &reportBuffer, + reportBuffer.count, + { context, _, _, _, _, report, reportLength in + guard let context else { return } + let transport = Unmanaged.fromOpaque(context).takeUnretainedValue() + transport.receive(Data(bytes: report, count: reportLength)) + }, + Unmanaged.passUnretained(self).toOpaque() + ) + onConnected?() + } + + private func remove(_ candidate: IOHIDDevice) { + guard let device, device == candidate else { return } + IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone)) + self.device = nil + onDisconnected?() + } + + private func send(channel: UInt8, payload: Data) throws { + guard let device else { throw USBTransportError.notConnected } + var report = [UInt8](repeating: 0, count: Self.reportSize) + report[0] = channel + report[1] = UInt8(payload.count) + payload.copyBytes(to: &report[2], count: payload.count) + let result = IOHIDDeviceSetReport( + device, + kIOHIDReportTypeOutput, + 0, + report, + report.count + ) + guard result == kIOReturnSuccess else { throw USBTransportError.writeFailed(result) } + } + + private func receive(_ report: Data) { + guard let start = report.indices.first(where: { index in + index + 1 < report.endIndex && report[index] == 0xAA && report[index + 1] == 0xBB + }) else { return } + var end = start + 3 + while end + 1 < report.endIndex { + if report[end] == 0xCC && report[end + 1] == 0xDD { + onFrame?(Data(report[start ... end + 1])) + return + } + end += 1 + } + } +} + +enum USBTransportError: LocalizedError { + case notConnected + case commandTooLarge + case openFailed(IOReturn) + case writeFailed(IOReturn) + + var errorDescription: String? { + switch self { + case .notConnected: return "USB HID device is not connected" + case .commandTooLarge: return "USB command exceeds 62-byte payload limit" + case let .openFailed(code): return "Could not open USB HID device (\(code))" + case let .writeFailed(code): return "USB HID write failed (\(code))" + } + } +} diff --git a/platforms/macos/Sources/Models/AhaKeyStudioModels.swift b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift similarity index 64% rename from platforms/macos/Sources/Models/AhaKeyStudioModels.swift rename to ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift index 01059f73..def69a5f 100644 --- a/platforms/macos/Sources/Models/AhaKeyStudioModels.swift +++ b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift @@ -4,28 +4,31 @@ 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)" + "Mode \(rawValue + 1)" } var shortTitle: String { - "M\(rawValue)" + "M\(rawValue + 1)" } - var name: String { + var defaultName: String { switch self { - case .mode0: - "Claude Code" - case .mode1: - "Cursor" - case .mode2: - "Codex" + 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: @@ -33,7 +36,9 @@ enum AhaKeyModeSlot: Int, CaseIterable, Codable, Identifiable { case .mode1: "Cursor · Composer Accept/Reject" case .mode2: - "Codex · CLI ↵ / Esc" + "Codex · ↵ / Esc" + case .mode3: + "custom · 自定义模式" } } @@ -42,13 +47,41 @@ 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 取消。" + 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 @@ -65,7 +98,7 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { case .lightBar: "灯条" case .oledDisplay: - "OLED 屏幕" + "LCD 屏幕" case .key1: "Key 1" case .key2: @@ -92,7 +125,7 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { case .key3: "取消键" case .key4: - "回车键" + "删除键" case .toggleSwitch: "批准方式" } @@ -101,7 +134,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: @@ -111,7 +145,7 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { case .key3: "xmark" case .key4: - "return.left" + "delete.left" case .toggleSwitch: "switch.2" } @@ -131,6 +165,8 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { nil } } + + var isKey: Bool { keyRole != nil } } enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { @@ -163,7 +199,7 @@ enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { case .reject: "取消键" case .submit: - "回车键" + "删除键" } } @@ -176,7 +212,7 @@ enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { case .reject: "xmark" case .submit: - "return.left" + "delete.left" } } @@ -189,7 +225,7 @@ enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { case .reject: "Reject" case .submit: - "Enter" + "Backspace" } } @@ -202,7 +238,7 @@ enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { case .reject: "适合拒绝、取消、停止这类相反动作。" case .submit: - "适合回车、发送、提交这类收尾动作。" + "出厂默认 Backspace,适合删除、撤销输入或清理当前内容。" } } } @@ -315,22 +351,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 中实际展示的选项(微信/豆包并入 Fn/Globe;旧 case 保留用于迁移) + static var visibleCases: [VoicePreset] { + [.macOSNative, .typeless, .custom] + } + var title: String { switch self { - case .macOSNative: - "macOS 原生语音" + case .macOSNative, .claudeCode, .kimiCode: + "macOS 原生转写" case .typeless: - "Typeless / Fn" + "Fn/Globe" case .wechat: "微信语音" - case .claudeCode: - "Claude Code" case .codex: "Codex" case .doubao: @@ -342,15 +388,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 仍会额外注册兼容。请授予输入监控与辅助功能。" + "预设对应快捷键:Typeless/微信语音/豆包输入法内仍选 Fn/Globe。本 Studio 使用 F19 作为 Fn 触发键;按下后向系统注入「按住 Fn」。旧版 F18 仍会兼容监听。请授予输入监控与辅助功能。" case .wechat: - "AhaKey Studio 会在后台把语音键的按下/松开转换成 Fn/Globe,便于接入微信语音。" - case .claudeCode: - "使用 macOS 原生语音识别,把识别结果以 ⌘V 粘贴到 Claude Code 当前光标位置。按一次开始、再按一次结束。" - case .codex, .doubao: + "AhaKey Studio 使用 F19 作为 Fn 触发键,并在后台把语音键的按下/松开转换成 Fn/Globe,便于接入微信语音。" + case .doubao: + "豆包输入法 Mac 版需要直接接收真实语音键事件。AhaKey Studio 会切到豆包输入源,并把 F18 配置为豆包长按语音快捷键;按住语音键说话,松开后由豆包提交文字。" + case .codex: "规划中,保留入口。" case .custom: "直接自己指定底层快捷键。" @@ -359,7 +405,7 @@ enum VoicePreset: String, CaseIterable, Codable, Identifiable { var availableInV1: Bool { switch self { - case .codex, .doubao: + case .codex: false default: true @@ -374,9 +420,11 @@ enum VoicePreset: String, CaseIterable, Codable, Identifiable { // 与 macOS 原生默认 F18 错开;固件可把 Typeless 档语音键设为 F19,Mode 0 另有 F18 出厂兼容路由 ShortcutBinding(keyCode: HIDUsage.f19) case .wechat: - ShortcutBinding(keyCode: HIDUsage.f18) + ShortcutBinding(keyCode: HIDUsage.f19) case .claudeCode: ShortcutBinding(keyCode: HIDUsage.f18) + case .kimiCode: + ShortcutBinding(keyCode: HIDUsage.f18) case .codex: ShortcutBinding(keyCode: HIDUsage.f18) case .doubao: @@ -436,126 +484,186 @@ enum LightBarPreviewState: String, CaseIterable, Codable, Identifiable { } enum LightEffectStyle: String, CaseIterable, Codable, Identifiable { + case off case middleLight case singleMove case breathing case rainbowMove case rainbowWave case rainbowWaveSlow - case off + 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 title: String { + var firmwareIndex: UInt8 { switch self { - case .middleLight: - "中间停住" - case .singleMove: - "来回流水" - case .breathing: - "整条呼吸" - case .rainbowMove: - "彩虹流水" - case .rainbowWave: - "彩虹波浪" - case .rainbowWaveSlow: - "彩虹慢波浪" - case .off: - "熄灭" + 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 } } - var detail: String { - switch self { - case .middleLight: - "中间最亮,两侧渐弱,适合停住提示。" - case .singleMove: - "单点来回移动,适合运行中。" - case .breathing: - "整条均匀起伏,适合等待确认。" - case .rainbowMove: - "彩色单点流水,更活跃。" - case .rainbowWave: - "整条彩色流动,更显眼。" - case .rainbowWaveSlow: - "比普通彩虹波浪更慢,适合做氛围效果。" - case .off: - "不点亮灯条。" + init?(firmwareIndex: UInt8) { + guard let match = Self.allCases.first(where: { $0.firmwareIndex == firmwareIndex }) else { + return nil } + self = match } - func previewIDEState(forSwitchState switchState: Int) -> IDEState? { - if switchState == 0 { - switch self { - case .middleLight: - return .sessionStart - case .rainbowMove: - return .postToolUse - case .rainbowWave: - return .preToolUse - case .off: - return .sessionEnd - case .singleMove, .breathing, .rainbowWaveSlow: - return nil - } - } - + var title: String { switch self { - case .middleLight: - return .stop - case .singleMove: - return .preToolUse - case .breathing: - return .permissionRequest - case .off: - return .sessionEnd - case .rainbowMove, .rainbowWave, .rainbowWaveSlow: - return nil + 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: "等待审批" } } - func previewHint(forSwitchState switchState: Int) -> String { - if let ideState = previewIDEState(forSwitchState: switchState) { - return "预览到设备时会按当前固件映射发送 \(ideState.label)。" - } - - if switchState == 0 { - return "当前拨杆在“自动批准”,设备固件只能直接预览彩虹流水、彩虹波浪、中间停住和熄灭。" + 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: "琥珀色呼吸 + 中心闪烁,等待用户操作。" } - - return "当前拨杆在“手动批准”,设备固件只能直接预览来回流水、整条呼吸、中间停住和熄灭。" } } struct AhaKeyLightStateDraft: Codable, Equatable, Identifiable { - var state: LightBarPreviewState + var state: IDEState var effect: LightEffectStyle - var id: LightBarPreviewState { state } + 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: LightBarPreviewState) -> LightEffectStyle { + func effect(for state: IDEState) -> LightEffectStyle { stateMappings.first(where: { $0.state == state })?.effect ?? .singleMove } - /// 固件 state → 灯效只有一张全局表,三 mode 共用同一套默认值。 - /// 参数 `mode` 保留是为了避免调用点改动,内容实际与 mode 无关。 - static func `default`(for mode: AhaKeyModeSlot) -> AhaKeyLightBarDraft { - _ = mode - return AhaKeyLightBarDraft(stateMappings: [ - AhaKeyLightStateDraft(state: .aiRunning, effect: .singleMove), - AhaKeyLightStateDraft(state: .waitingApproval, effect: .breathing), - AhaKeyLightStateDraft(state: .stopped, effect: .middleLight), - AhaKeyLightStateDraft(state: .taskCompleted, effect: .middleLight), - ]) + private enum CodingKeys: String, CodingKey { + case stateMappings, brightness } - /// 与出厂 `default` 表一致,供 Studio 只读展示与画布(用户不可在软件中改映射)。 - static func hardwareEffect(for state: LightBarPreviewState) -> LightEffectStyle { - AhaKeyLightBarDraft.default(for: .mode0).effect(for: state) + 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) } } @@ -742,28 +850,130 @@ struct AhaKeyKeyDraft: Codable, Equatable, Identifiable { } } +enum AhaKeyTaskDisplayState: Int, Codable, CaseIterable, Identifiable { + case idle = 0 + case working = 1 + case waiting = 2 + case done = 3 + + var id: Int { rawValue } + var title: String { + switch self { + case .idle: return "待机" + case .working: return "进行中" + case .waiting: return "待批准" + case .done: return "已完成" + } + } +} + +struct AhaKeyTaskGIFAssetDraft: Codable, Equatable, Identifiable { + var state: AhaKeyTaskDisplayState + var localAssetPath: String? + var framesPerSecond: Int + var deviceSchemaVersion: Int? + var id: Int { state.rawValue } + + init( + state: AhaKeyTaskDisplayState, + localAssetPath: String? = nil, + framesPerSecond: Int = 12, + deviceSchemaVersion: Int? = nil + ) { + self.state = state + self.localAssetPath = localAssetPath + self.framesPerSecond = min(20, max(5, framesPerSecond)) + self.deviceSchemaVersion = deviceSchemaVersion + } +} + +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 { + AhaKeyTaskGIFAssetDraft(state: $0, localAssetPath: $0 == .done ? assetPath : nil, framesPerSecond: framesPerSecond) + }) + } + + static func emptySet() -> AhaKeyTaskGIFSetDraft { + AhaKeyTaskGIFSetDraft(assets: AhaKeyTaskDisplayState.allCases.map { AhaKeyTaskGIFAssetDraft(state: $0) }) + } + + 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 + if let existing = candidates.first(where: { $0.state == state }) { + return existing + } + if state == .idle, let working = candidates.first(where: { $0.state == .working }) { + return AhaKeyTaskGIFAssetDraft( + state: .idle, + localAssetPath: working.localAssetPath, + framesPerSecond: working.framesPerSecond, + deviceSchemaVersion: working.deviceSchemaVersion + ) + } + return AhaKeyTaskGIFAssetDraft(state: state) + } + } +} + struct AhaKeyOLEDDraft: Codable, Equatable { var localAssetPath: String? var statusLine: String var framesPerSecond: Int + var taskGIFSets: [AhaKeyTaskGIFSetDraft] + var activeGIFSet: Int + /// 0 is an existing one-GIF draft that needs migration on its first device write. + 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 = 0 + ) { 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)) + taskGIFSets = Self.normalizedSets(try container.decodeIfPresent([AhaKeyTaskGIFSetDraft].self, forKey: .taskGIFSets) ?? [], 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) + // Schema 1 duplicated the legacy animation into Set B during + // migration. Remove only exact duplicates; independently configured + // Set B assets are preserved. + if taskGIFSchemaVersion < 2, taskGIFSets.count > 1, taskGIFSets[1] == taskGIFSets[0] { + taskGIFSets[1] = .emptySet() + } } func encode(to encoder: Encoder) throws { @@ -771,6 +981,37 @@ 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) + } + + 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 == .done { + 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)) + if result.isEmpty { result.append(.defaultSet(assetPath: legacyAssetPath, framesPerSecond: legacyFramesPerSecond)) } + while result.count < 2 { result.append(.emptySet()) } + return result } static func `default`(for mode: AhaKeyModeSlot) -> AhaKeyOLEDDraft { @@ -781,7 +1022,9 @@ struct AhaKeyOLEDDraft: Codable, Equatable { case .mode1: statusLine = "Cursor · ↵ 接受改动 / ⌫ 拒绝改动。" case .mode2: - statusLine = "Codex · CLI 审批 ↵ / Esc。" + statusLine = "Codex · 审批 ↵ / Esc。" + case .mode3: + statusLine = "自定义模式。" } return AhaKeyOLEDDraft( localAssetPath: DefaultOLEDAssets.bundledAssetPath(for: mode), @@ -880,6 +1123,11 @@ struct AhaKeyModeDraft: Codable, Equatable, Identifiable { 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( @@ -906,7 +1154,7 @@ struct AhaKeyModeDraft: Codable, Equatable, Identifiable { ), AhaKeyKeyDraft( role: .submit, - shortcut: ShortcutBinding(keyCode: HIDUsage.enter), + shortcut: ShortcutBinding(keyCode: HIDUsage.backspace), description: AhaKeyKeyRole.submit.defaultDescription, voicePreset: nil ), @@ -937,14 +1185,16 @@ struct AhaKeyStudioDraft: Codable, Equatable { enum AhaKeyStudioStore { private static let key = "ahakey.studio.draft.v1" + private static let syncBaselineKey = "ahakey.studio.sync-baseline.v1" static func load() -> AhaKeyStudioDraft? { guard let data = UserDefaults.standard.data(forKey: key), - let draft = try? JSONDecoder().decode(AhaKeyStudioDraft.self, from: data) else { + var draft = try? JSONDecoder().decode(AhaKeyStudioDraft.self, from: data) else { return nil } - guard draft.modes.count == AhaKeyModeSlot.allCases.count 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) } @@ -954,6 +1204,16 @@ enum AhaKeyStudioStore { UserDefaults.standard.set(data, forKey: key) } + static func loadSyncBaseline() -> AhaKeyStudioDraft? { + guard let data = UserDefaults.standard.data(forKey: syncBaselineKey) else { return nil } + return try? JSONDecoder().decode(AhaKeyStudioDraft.self, from: data) + } + + static func saveSyncBaseline(_ draft: AhaKeyStudioDraft) { + guard let data = try? JSONEncoder().encode(draft) else { return } + UserDefaults.standard.set(data, forKey: syncBaselineKey) + } + private static func migratedDraft(from draft: AhaKeyStudioDraft) -> AhaKeyStudioDraft { var next = draft var mode0 = next.draft(for: .mode0) @@ -980,6 +1240,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) @@ -1027,11 +1298,11 @@ enum AhaKeyStudioStore { let legacyOLEDStatusLines: Set = [ "当前仅支持动图", - "切换模式时会先显示按键描述,再回到 Mode 0 默认动图。", + "切换模式时会先显示按键描述,再回到 Mode 1 默认动图。", "当前模式还未上传动图,后续可替换成你的自定义 GIF。", "Cursor · ⌘↵ 接受改动 / ⌘⌫ 拒绝改动。", "Claude Code · 终端权限菜单 Y/N。", - "Codex · CLI 审批 ↵ / Esc。", + "Codex · 审批 ↵ / Esc。", ] let legacyApproveBinding = ShortcutBinding(keyCode: HIDUsage.enter) let legacyRejectBinding = ShortcutBinding(keyCode: HIDUsage.escape) @@ -1046,7 +1317,7 @@ enum AhaKeyStudioStore { modeDraft.oled.statusLine = AhaKeyOLEDDraft.default(for: mode).statusLine } - // OLED 素材路径自愈:用户没选过自定义 GIF(为 nil)或引用的是旧 bundle 路径时, + // LCD 素材路径自愈:用户没选过自定义 GIF(为 nil)或引用的是旧 bundle 路径时, // 刷成当前构建下内置 GIF 的绝对路径;用户自选的外部路径原样保留。 if let bundled = DefaultOLEDAssets.bundledAssetPath(for: mode) { if modeDraft.oled.localAssetPath == nil @@ -1060,6 +1331,11 @@ enum AhaKeyStudioStore { } 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 @@ -1067,6 +1343,37 @@ enum AhaKeyStudioStore { 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)允许用户**有意**改组合键,若继续套用下面规则会在每次启动时改回出厂 ↵/⌫,表现为改键不保存。 @@ -1171,16 +1478,6 @@ enum AhaKeyStudioStore { next.updateMode(modeDraft) } - // 灯条为出厂固件内建映射,已不在 App 中自定义;三 mode 均固定为同一张出厂表。 - let factoryLightBar = AhaKeyLightBarDraft.default(for: .mode0) - for mode in AhaKeyModeSlot.allCases { - var modeDraft = next.draft(for: mode) - if modeDraft.lightBar != factoryLightBar { - modeDraft.lightBar = factoryLightBar - next.updateMode(modeDraft) - } - } - return next } } diff --git a/platforms/macos/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift similarity index 66% rename from platforms/macos/Sources/Utilities/AgentManager.swift rename to ahakeyconfig-mac/Sources/Utilities/AgentManager.swift index 73a6db4a..84a51e4b 100644 --- a/platforms/macos/Sources/Utilities/AgentManager.swift +++ b/ahakeyconfig-mac/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 @@ -72,9 +73,20 @@ final class AgentManager: ObservableObject { } private var agentBinaryPath: String { - // agent 安装到 app bundle 内部(发版须将 ahakeyconfig-agent 与主程序一并复制到 Contents/MacOS/) - let appPath = Bundle.main.bundlePath - return "\(appPath)/Contents/MacOS/ahakeyconfig-agent" + // 发版优先使用 app bundle 内部的 agent。`swift run AhaKeyConfig` 是裸可执行文件, + // 开发时回退到同一 SwiftPM build 目录里的 sibling agent,便于从源码安装 LaunchAgent。 + let bundled = "\(Bundle.main.bundlePath)/Contents/MacOS/ahakeyconfig-agent" + if FileManager.default.isExecutableFile(atPath: bundled) { + return bundled + } + if Bundle.main.bundleURL.pathExtension != "app", + let executableDirectory = Bundle.main.executableURL?.deletingLastPathComponent() { + let development = executableDirectory.appendingPathComponent("ahakeyconfig-agent").path + if FileManager.default.isExecutableFile(atPath: development) { + return development + } + } + return bundled } /// 供界面判断:包内是否带有 agent 可执行文件(发版缺拷贝时 LaunchAgent 无法真正运行)。 @@ -107,6 +119,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 +156,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 +174,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) 不再发送旧 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 已连上键盘。 /// 同步执行,需在后台线程调用。 - 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 +376,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 +405,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 +430,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 +483,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 +498,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 +515,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 +540,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 +610,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 } @@ -530,7 +638,16 @@ final class AgentManager: ObservableObject { func readUserCodexConfigForDisplay() -> String { let path = codexConfigPath guard FileManager.default.fileExists(atPath: path) else { - return "(文件不存在:\(path))\n\n可先点「安装 Codex Hooks」创建并合并 `[features].codex_hooks` 与 AhaKey hook block。" + 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))" } @@ -673,7 +790,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 +808,12 @@ final class AgentManager: ObservableObject { "PreToolUse", "PostToolUse", "Stop", + "SubagentStop", // Claude Code 拆分后:手动终止任务时触发此事件 "SessionStart", "SessionEnd", "UserPromptSubmit", "TaskCompleted", + "PreCompact", ] /// Shell 安全地引用一个路径(单引号包裹 + 转义内部单引号) @@ -821,6 +940,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 +1001,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 +1084,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 +1145,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 @@ -1058,7 +1226,7 @@ final class AgentManager: ObservableObject { guard let start = featuresStart else { var next = config.trimmingCharacters(in: .whitespacesAndNewlines) if !next.isEmpty { next += "\n\n" } - next += "[features]\ncodex_hooks = true\n" + next += "[features]\nhooks = true\n" return next } @@ -1073,18 +1241,28 @@ final class AgentManager: ObservableObject { } } - let keyPattern = #"^\s*codex_hooks\s*="# + // Codex 新版用 `hooks`;旧版写过已废弃的 `codex_hooks`(会触发 deprecation 警告)。 + // 两者都识别:保留/改写第一个命中为 `hooks = true`,其余(含所有 `codex_hooks`)删除。 + let keyPattern = #"^\s*(codex_)?hooks\s*="# let regex = try? NSRegularExpression(pattern: keyPattern) + var matchedIndices: [Int] = [] for idx in (start + 1).. 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) diff --git a/platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift b/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift similarity index 95% rename from platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift rename to ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift index d46fbf8e..3c08f878 100644 --- a/platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift +++ b/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift @@ -8,7 +8,7 @@ final class AhaTypeTextOptimizer: ObservableObject { @Published private(set) var statusMessage = "AhaType 未启用。" @Published private(set) var lastQuotaSummary = "尚未读取 AhaType 配置。" - private let fallbackAPIBase = "https://typeless-220629-6-1398334410.sh.run.tcloudbase.com" + private let fallbackAPIBase = "https://956798.xyz/prod-api" private init() { refreshFromDisk() @@ -110,8 +110,9 @@ final class AhaTypeTextOptimizer: ObservableObject { statusMessage = "AhaType 返回非 JSON,已写入原始转写。" return text } - guard intValue(object["code"]) == 0 else { - let message = stringValue(object["errorMsg"]) + let code = intValue(object["code"]) + guard code == 0 || code == 200 else { + let message = responseMessage(object) statusMessage = message.isEmpty ? "AhaType 处理失败,已写入原始转写。" : "AhaType 处理失败:\(message)" return text } @@ -309,6 +310,14 @@ final class AhaTypeTextOptimizer: ObservableObject { } } + 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: diff --git a/platforms/macos/Sources/Utilities/CloudAccountManager.swift b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift similarity index 59% rename from platforms/macos/Sources/Utilities/CloudAccountManager.swift rename to ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift index b71bf550..64df5888 100644 --- a/platforms/macos/Sources/Utilities/CloudAccountManager.swift +++ b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift @@ -15,7 +15,7 @@ final class CloudAccountManager: ObservableObject { @Published private(set) var statusMessage = "尚未登录。" @Published var alertMessage: String? - private let fallbackAPIBase = "https://typeless-220629-6-1398334410.sh.run.tcloudbase.com" + 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" @@ -35,11 +35,11 @@ final class CloudAccountManager: ObservableObject { } func login() { - authenticate(path: "api/v1/auth/login", successMessage: "登录成功。") + authenticate(path: "api/v1/auth/login", successMessage: "登录成功。", fallbackError: "登录失败。") } func register() { - authenticate(path: "api/v1/auth/register", successMessage: "注册成功。") + authenticate(path: "api/v1/auth/register", successMessage: "注册成功。", fallbackError: "注册失败。") } func logout() { @@ -57,7 +57,7 @@ final class CloudAccountManager: ObservableObject { statusMessage = "请输入账号密码重新登录。" } - func refreshProfile() { + func refreshProfile(showAlertOnFailure: Bool = true) { guard !accessToken.isEmpty else { logout() return @@ -67,7 +67,7 @@ final class CloudAccountManager: ObservableObject { Task { defer { Task { @MainActor in self.isBusy = false } } do { - let object = try await request(path: "api/v1/users/me", method: "GET", body: nil, authorized: true) + let object = try await request(path: "api/v1/auth/users/me", method: "GET", body: nil, authorized: true) let data = try payloadData(from: object, fallbackError: "获取用户信息失败") await MainActor.run { self.applyProfile(data) @@ -75,9 +75,13 @@ final class CloudAccountManager: ObservableObject { } } catch { await MainActor.run { - self.alertMessage = error.localizedDescription - self.statusMessage = "刷新失败。" - if (error as? CloudAccountError)?.statusCode == 401 { + if showAlertOnFailure { + self.alertMessage = error.localizedDescription + self.statusMessage = "刷新失败。" + } else { + self.statusMessage = "已登录,用户信息稍后可刷新。" + } + if showAlertOnFailure, (error as? CloudAccountError)?.statusCode == 401 { self.logout() } } @@ -130,12 +134,12 @@ final class CloudAccountManager: ObservableObject { authorized: true ) let data = try payloadData(from: object, fallbackError: "创建支付订单失败") - let codeURL = stringValue(data["code_url"]) - let h5URL = stringValue(data["h5_url"]) - let outTradeNo = stringValue(data["out_trade_no"]) + 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 = intValue(data["amount_fen"]) + let amountFen = firstInt(in: data, keys: ["amount_fen", "amountFen"]) await MainActor.run { self.paymentOrder = CloudPaymentOrder( plan: plan, @@ -162,6 +166,29 @@ final class CloudAccountManager: ObservableObject { 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"]) @@ -198,29 +225,11 @@ final class CloudAccountManager: ObservableObject { try? await Task.sleep(nanoseconds: 2_000_000_000) if self.paymentOrder?.outTradeNo != outTradeNo { return } do { - let path = "api/v1/payment/wechat/order-status?out_trade_no=\(outTradeNo.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? outTradeNo)" - let object = try await request(path: path, method: "GET", body: nil, authorized: true) - let data = try payloadData(from: object, fallbackError: "查询订单状态失败") - let status = stringValue(data["status"]).lowercased() - await MainActor.run { - if var order = self.paymentOrder, order.outTradeNo == outTradeNo { - order.status = status.isEmpty ? order.status : status - self.paymentOrder = order - } + let status = try await fetchPaymentStatus(outTradeNo: outTradeNo) + let finished = await MainActor.run { + self.applyPaymentStatus(status, outTradeNo: outTradeNo, notifyPending: false) } - if status == "paid" { - await MainActor.run { - self.statusMessage = "充值成功,正在刷新额度。" - self.paymentOrder = nil - self.refreshProfile() - } - return - } - if status == "failed" { - await MainActor.run { - self.statusMessage = "订单支付失败。" - self.alertMessage = "订单已标记为失败,请重新发起充值。" - } + if finished { return } } catch { @@ -236,7 +245,40 @@ final class CloudAccountManager: ObservableObject { } } - private func authenticate(path: String, successMessage: String) { + private func fetchPaymentStatus(outTradeNo: String) async throws -> String { + let encoded = outTradeNo.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? outTradeNo + let path = "api/v1/payment/wechat/order-status?outTradeNo=\(encoded)" + let object = try await request(path: path, method: "GET", body: nil, authorized: 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() + 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 = "请输入手机号和密码。" @@ -248,15 +290,15 @@ final class CloudAccountManager: ObservableObject { 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: successMessage) - let token = stringValue(data["access_token"]) + 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) + self.saveLogin(token: token, authData: data) self.statusMessage = successMessage } await MainActor.run { - self.refreshProfile() + self.refreshProfile(showAlertOnFailure: false) } } catch { await MainActor.run { @@ -267,7 +309,7 @@ final class CloudAccountManager: ObservableObject { } } - private func saveLogin(token: String) { + private func saveLogin(token: String, authData: [String: Any] = [:]) { let defaults = UserDefaults.standard defaults.set(token, forKey: tokenKey) defaults.set(rememberPassword, forKey: rememberKey) @@ -278,16 +320,81 @@ final class CloudAccountManager: ObservableObject { defaults.removeObject(forKey: passwordKey) } AhaTypeTextOptimizer.shared.patchCloudToken(token) + seedLocalProfile(token: token, authData: authData) isLoggedIn = true } private func applyProfile(_ profile: [String: Any]) { - self.profile = profile + 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) async throws -> [String: Any] { guard let url = URL(string: "\(apiBase)/\(path)") else { throw CloudAccountError("云端地址无效。") @@ -313,14 +420,15 @@ final class CloudAccountManager: ObservableObject { throw CloudAccountError("服务器返回非 JSON。", statusCode: statusCode) } if statusCode != 200 { - throw CloudAccountError(stringValue(object["errorMsg"]).isEmpty ? "请求失败(HTTP \(statusCode))。" : stringValue(object["errorMsg"]), statusCode: statusCode) + throw CloudAccountError(responseMessage(object).isEmpty ? "请求失败(HTTP \(statusCode))。" : responseMessage(object), statusCode: statusCode) } return object } private func payloadData(from object: [String: Any], fallbackError: String) throws -> [String: Any] { - guard intValue(object["code"]) == 0 else { - let msg = stringValue(object["errorMsg"]) + 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] ?? [:] @@ -355,6 +463,90 @@ final class CloudAccountManager: ObservableObject { } } + 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 diff --git a/platforms/macos/Sources/Utilities/DebugSigningFixer.swift b/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift similarity index 100% rename from platforms/macos/Sources/Utilities/DebugSigningFixer.swift rename to ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift diff --git a/platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift b/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift similarity index 92% rename from platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift rename to ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift index 653d2a3d..bf419616 100644 --- a/platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift +++ b/ahakeyconfig-mac/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,8 +13,10 @@ enum DefaultOLEDAssets { case .mode0: return "claude_0" case .mode1: - return "cursor_0" + return "cursor" case .mode2: + return "codex" + case .mode3: return nil } } diff --git a/platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift similarity index 54% rename from platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift rename to ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift index 6b6be447..c7191f81 100644 --- a/platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift +++ b/ahakeyconfig-mac/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? @@ -23,7 +45,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { private var currentTranscript = "" /// 防止 `isFinal`、1s 超时、`error` 回调各触发一次,导致同一段被 ⌘V 多遍 private var hasCommittedThisRecording = false - private var didRequestPermissionsThisLaunch = false + private let syntheticEventUserData: Int64 = 0x4148414B @@ -31,12 +53,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { func start() { AhaTypeTextOptimizer.shared.refreshFromDisk() - if !didRequestPermissionsThisLaunch { - didRequestPermissionsThisLaunch = true - refreshPermissions(requestIfNeeded: true) - } else { - refreshPermissions() - } + refreshPermissions(requestIfNeeded: false) } /// - Parameter deferredTCCRequery: 与 `VoiceRelayService` 一致:用户点「重新检查」时延后一拍再读,避免 TCC 状态未刷新时界面像「没反应」。 @@ -61,19 +78,36 @@ final class NativeSpeechTranscriptionService: ObservableObject { } private func performPermissionRead(requestIfNeeded: Bool) { - let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - let currentMicGranted = micStatus == .authorized + 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 micStatus == .notDetermined { - AVCaptureDevice.requestAccess(for: .audio) { _ in + 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 { @@ -88,37 +122,225 @@ 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() { + 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) - 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 +352,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 +502,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 +515,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 +523,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 +535,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { } } - private func finalizeCurrentTranscriptIfNeeded(reason: String) { + private func finalizeCurrentTranscriptIfNeeded(reason: String, bypassAhaType: Bool = false) { finalizeWorkItem?.cancel() finalizeWorkItem = nil @@ -324,12 +555,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 +610,73 @@ 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("语音转写") + } + 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() } } - return "还缺语音转写权限。请先在系统设置里给 AhaKey Studio 打开语音转写,再按一次语音键。" + } + + 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/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift similarity index 56% rename from platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift rename to ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift index 5b5aa327..19eea9cb 100644 --- a/platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift +++ b/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift @@ -7,6 +7,7 @@ enum OLEDFrameEncodingError: LocalizedError { case noFrames case cannotCreateContext case sourceFileTooLarge(fileSize: Int, maxBytes: Int) + case tooManyFrames(count: Int, max: Int) var errorDescription: String? { switch self { @@ -15,14 +16,16 @@ 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] f.countStyle = .file let a = f.string(fromByteCount: Int64(fileSize)) let b = f.string(fromByteCount: Int64(maxBytes)) - return "GIF 源文件约 \(a),超过单文件上限 \(b)。请压缩分辨率、减少帧数或缩短动图后再试。" + return "图片源文件约 \(a),超过单文件上限 \(b)。请压缩分辨率、减少帧数或缩短动图后再试。" + case .tooManyFrames(let count, let max): + return "当前动图共有 \(count) 帧,超过单模式上限 \(max) 帧。请减少帧数或缩短动图后再试。" } } } @@ -30,7 +33,7 @@ enum OLEDFrameEncodingError: LocalizedError { enum OLEDFrameEncoder { static func frameCount(at url: URL) -> Int { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return 0 } - return min(CGImageSourceGetCount(source), AhaKeyCommand.oledMaxFrames) + return CGImageSourceGetCount(source) } /// 源 GIF 文件字节数;无法读取时返回 `nil`。 @@ -43,38 +46,90 @@ enum OLEDFrameEncoder { return nil } - /// 若超过 `AhaKeyCommand.oledMaxSourceFileBytes` 则抛出 `sourceFileTooLarge`。 - static func validateGIFSourceFileSize(at url: URL) throws { + static func validateSourceFileSize(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) + let animated = frameCount(at: url) > 1 + let limit = animated + ? AhaKeyCommand.animatedOLEDMaxSourceFileBytes + : AhaKeyCommand.staticOLEDMaxSourceFileBytes + guard n <= limit else { + throw OLEDFrameEncodingError.sourceFileTooLarge(fileSize: n, maxBytes: limit) + } + } + + static func validateGIFSourceFileSize(at url: URL) throws { + try validateSourceFileSize(at: url) + } + + static func validateFrameCount( + at url: URL, + maxFrames: Int = AhaKeyCommand.oledMaxFramesPerMode + ) 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 <= maxFrames else { + throw OLEDFrameEncodingError.tooManyFrames(count: count, max: maxFrames) } } - static func frames(fromGIFAt url: URL) throws -> [Data] { - try validateGIFSourceFileSize(at: url) + static func frames(fromGIFAt url: URL, maxFrames: Int = AhaKeyCommand.oledMaxFramesPerMode) throws -> [Data] { + try validateSourceFileSize(at: url) guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { throw OLEDFrameEncodingError.cannotCreateImageSource } - let count = min(CGImageSourceGetCount(source), AhaKeyCommand.oledMaxFrames) + let count = CGImageSourceGetCount(source) guard count > 0 else { throw OLEDFrameEncodingError.noFrames } + let cappedCount = max(1, maxFrames) + let indexes: [Int] + if count <= cappedCount { + indexes = Array(0 ..< count) + } else if cappedCount == 1 { + indexes = [0] + } else { + indexes = (0 ..< cappedCount).map { + Int((Double($0) * Double(count - 1) / Double(cappedCount - 1)).rounded()) + } + } var frames: [Data] = [] - frames.reserveCapacity(count) - for index in 0 ..< count { - guard let image = CGImageSourceCreateImageAtIndex(source, index, nil) else { continue } + frames.reserveCapacity(indexes.count) + for index in indexes { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max( + AhaKeyCommand.oledWidth, + AhaKeyCommand.oledHeight + ), + kCGImageSourceShouldCacheImmediately: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex( + source, + index, + options as CFDictionary + ) else { continue } frames.append(try encodeFrame(image)) } guard !frames.isEmpty else { throw OLEDFrameEncodingError.noFrames } - return frames + return collapsingStaticFrames(frames) + } + + static func collapsingStaticFrames(_ frames: [Data]) -> [Data] { + guard let first = frames.first, frames.count > 1 else { return frames } + return frames.dropFirst().allSatisfy { $0 == first } ? [first] : frames } private static func encodeFrame(_ image: CGImage) throws -> Data { @@ -113,7 +168,7 @@ enum OLEDFrameEncoder { // 每帧恰好 160*80*2 = 25600 字节 RGB565 大端,原厂 Python 也不做 padding。 // flash 物理帧槽是 28672 字节,剩下的 3072 字节由 address 递增自然留空。 - var data = Data(capacity: width * height * 2) + var data = Data(capacity: AhaKeyCommand.oledEncodedFrameBytes) for pixel in stride(from: 0, to: rgba.count, by: bytesPerPixel) { let red = UInt16(rgba[pixel]) let green = UInt16(rgba[pixel + 1]) @@ -122,6 +177,7 @@ enum OLEDFrameEncoder { data.append(UInt8((rgb565 >> 8) & 0xFF)) data.append(UInt8(rgb565 & 0xFF)) } + assert(data.count == AhaKeyCommand.oledEncodedFrameBytes) 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/platforms/macos/Sources/Utilities/VoiceRelayService.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift similarity index 78% rename from platforms/macos/Sources/Utilities/VoiceRelayService.swift rename to ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift index e8422a25..87770396 100644 --- a/platforms/macos/Sources/Utilities/VoiceRelayService.swift +++ b/ahakeyconfig-mac/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,15 +32,22 @@ private enum VoiceRouteAction: Hashable { "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 usesFactoryFallback: Bool + let compatibilityLabel: String? } final class VoiceRelayService: ObservableObject { @@ -63,7 +72,8 @@ final class VoiceRelayService: ObservableObject { /// 会把 F17/F18 翻译成 xterm CSI 转义序列,用户看起来就是"乱码")。 private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? - private var didRequestPermissionsThisLaunch = false + + private var suppressPermissionOnboardingUntil: Date? private var shadowSuppressUntil: TimeInterval = 0 @@ -100,12 +110,7 @@ final class VoiceRelayService: ObservableObject { // MARK: - Public func start() { - if !didRequestPermissionsThisLaunch { - didRequestPermissionsThisLaunch = true - refreshPermissions(requestIfNeeded: true) - } else { - refreshPermissions() - } + refreshPermissions(requestIfNeeded: false) } /// - Parameter deferredTCCRequery: 用户点「重新检查」时置 true。仅 Preflight 在刚改完系统设置、仍停留本 App 时可能仍读到旧值;改为稍后使用 Request API 再读一次,并略延长等待。 @@ -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,7 +198,31 @@ final class VoiceRelayService: ObservableObject { showsPermissionOnboarding = false } - /// Inspector 调试:模拟当前模式下按一次实体语音键(Typeless/微信 = 切换 Fn 按住;macOS 原生 = 切换系统转写)。 + 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 } @@ -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 = "当前模式没有语音路由:Fn 请选 Fn/Globe,或把「自定义快捷键」设为 F19。" } return } @@ -214,11 +243,13 @@ final class VoiceRelayService: ObservableObject { case .functionRelay: toggleFunctionRelayHold(for: route) Task { @MainActor in - if route.action.title == "微信语音" { - lastInspectorSimulateHint = "已切换 Fn 按住状态;请在聚焦 App 里试用微信语音。再点一次为松开。" - } else { - lastInspectorSimulateHint = "已切换 Fn 按住状态;Typeless 请在 App 内把随声写设为 Fn/Globe(本 Studio 默认监听 F19,出厂语音键 F18 仍兼容)。再点一次为松开。" - } + 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)") @@ -226,6 +257,7 @@ final class VoiceRelayService: ObservableObject { func updateRoutes(from draft: AhaKeyStudioDraft) { let builtRoutes = Self.buildRoutes(from: draft) + let needsDoubaoPreparation = builtRoutes.contains { $0.action == .doubaoPassThrough } // 只有路由集合真的变化时才释放"按住"状态,避免 SwiftUI 频繁重建/无关 onChange // 间接把 functionRelay 的 hold 状态冲掉(典型表现:微信按住说话过几秒自动结束)。 @@ -239,7 +271,7 @@ final class VoiceRelayService: ObservableObject { let summary = builtRoutes.isEmpty ? "未配置语音软件。" : builtRoutes.map { route in - let fallback = route.usesFactoryFallback ? " · 出厂 F18 兼容" : "" + let fallback = route.compatibilityLabel.map { " · \($0)" } ?? "" return "\(route.mode.title) \(route.action.title) ← \(route.binding.displayLabel)\(fallback)" }.joined(separator: " / ") @@ -248,6 +280,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 +403,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.toggleRecordingFromVoiceKey() + 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 { @@ -520,6 +575,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() { @@ -538,19 +672,19 @@ final class VoiceRelayService: ObservableObject { return } - statusMessage = "后台监听已启动。Mode 0 出厂 F18 也会被接管到你选中的语音软件。" + 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, - preset != .custom, - let action = action(for: preset), + let action = action(for: preset, shortcut: voiceKey.shortcut), let binding = macBinding(for: voiceKey.shortcut) else { continue } @@ -559,17 +693,28 @@ final class VoiceRelayService: ObservableObject { binding: binding, action: action, mode: mode, - usesFactoryFallback: false + 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, - usesFactoryFallback: true + compatibilityLabel: "旧版 F18 兼容" ) ) } @@ -578,23 +723,34 @@ final class VoiceRelayService: ObservableObject { return orderedRoutes } - private static func action(for preset: VoicePreset) -> VoiceRouteAction? { + private static func action(for preset: VoicePreset, shortcut: ShortcutBinding) -> VoiceRouteAction? { switch preset { case .macOSNative: .macOSDictation case .typeless: - .functionRelay(appName: "Typeless / Fn") + .functionRelay(appName: "Fn/Globe") case .wechat: - .functionRelay(appName: "微信语音") + .functionRelay(appName: "Fn/Globe") case .claudeCode: // Claude Code preset 复用 macOS 原生 ASR:录音 → 识别 → ⌘V 粘到当前光标。 // 这样按键会被我们的 monitor 吃掉,不会漏到 Claude CLI 终端里变成 CSI 乱码。 .macOSDictation - case .codex, .doubao, .custom: + 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 diff --git a/platforms/macos/Sources/Utilities/VoiceStatusHUDController.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift similarity index 100% rename from platforms/macos/Sources/Utilities/VoiceStatusHUDController.swift rename to ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift new file mode 100644 index 00000000..3062f9b6 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift @@ -0,0 +1,5322 @@ +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 + @State private var isCancellingDeviceWrite = false + @State private var completedTaskResourceCount = 0 + // Human-readable descriptions of task images that failed during the most + // recent device write. A non-empty list means the write completed as a + // partial success: the other images and the key/LED config still went + // through, and only these slots need to be retried on the next sync. + @State private var lastTaskUploadFailures: [String] = [] + @State private var deviceWriteTask: Task? + // AhaKeyStudio 交还蓝牙给 Agent 的过渡期:保持"已连接"显示,直到 Agent 接管或超时。 + @State private var isTransitioningToKeyboardControl = false + @State private var showsOLEDPlaybackPreview = false + @State private var selectedOLEDGIFSet = 0 + @State private var selectedOLEDTaskState: AhaKeyTaskDisplayState = .idle + @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: AhaKeyStudioStore.loadSyncBaseline() ?? 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.firmwareCapabilities) { capabilities in + handleFactoryResourceChange(capabilities) + if capabilities?.supportsIdleTaskPicture != true, + selectedOLEDTaskState == .idle { + selectedOLEDTaskState = .working + } + } + .onChange(of: bleManager.protocolMode) { mode in + if mode != .current, selectedOLEDTaskState == .idle { + selectedOLEDTaskState = .working + } + } + .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: currentOLEDTaskAsset.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) + } + + if isSyncing { + Button { + cancelCurrentDeviceWrite() + } label: { + Label(isCancellingDeviceWrite ? "正在取消…" : "取消写入", systemImage: "xmark") + .font(.callout.weight(.medium)) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(isCancellingDeviceWrite) + } + + 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("任务状态动图") { + VStack(alignment: .leading, spacing: 14) { + Text("设备按当前状态自动切换待机、进行中、待批准、已完成图片。") + .font(.caption) + .foregroundStyle(.secondary) + + Picker("", selection: $selectedOLEDGIFSet) { + Text("套图 A").tag(0) + Text("套图 B").tag(1) + } + .pickerStyle(.segmented) + + Text("双击键盘电源键可在套图 A / B 间切换当前模式的整套状态图。") + .font(.caption) + .foregroundStyle(.secondary) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) { + ForEach(bleManager.supportedTaskDisplayStates) { state in + Button { selectedOLEDTaskState = state } label: { + Text(state.title) + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity, minHeight: 32) + .background(RoundedRectangle(cornerRadius: 6).fill(state == selectedOLEDTaskState ? Color.accentColor.opacity(0.16) : Color(nsColor: .controlBackgroundColor))) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(state == selectedOLEDTaskState ? Color.accentColor : Color.black.opacity(0.08), lineWidth: state == selectedOLEDTaskState ? 1.5 : 1)) + } + .buttonStyle(.plain) + } + } + 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("支持 GIF、PNG、JPG") + .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(currentOLEDTaskAsset.localAssetPath == nil) + + Button("清空") { + clearCurrentOLED() + } + .buttonStyle(.bordered) + + Spacer() + + Text(selectedOLEDTaskState.title) + .font(.caption) + .foregroundStyle(.secondary) + } + + Stepper(value: oledFramesPerSecondBinding, in: 5 ... 20) { + Text("播放速度 \(currentOLEDTaskAsset.framesPerSecond) FPS") + } + + Text("任务状态图会自动适配 160×80;最多上传 30 帧(超出自动均匀抽帧),源文件 ≤ 50 MB,FPS 5–20。") + .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 currentOLEDTaskAsset: AhaKeyTaskGIFAssetDraft { + currentModeDraft.oled.taskAsset(set: selectedOLEDGIFSet, state: selectedOLEDTaskState) + } + + private var currentOLEDPreviewImage: NSImage? { + guard let path = currentOLEDTaskAsset.localAssetPath else { return nil } + return NSImage(contentsOfFile: path) + } + + private var currentOLEDAssetURL: URL? { + guard let path = currentOLEDTaskAsset.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: { currentOLEDTaskAsset.framesPerSecond }, + set: { newValue in + updateCurrentMode { mode in + var asset = mode.oled.taskAsset(set: selectedOLEDGIFSet, state: selectedOLEDTaskState) + asset.framesPerSecond = min(20, max(5, newValue)) + mode.oled.updateTaskAsset(set: selectedOLEDGIFSet, asset: asset) + } + } + ) + } + + 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 + 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),写入设备后生效。" + } + } + + 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] + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + do { + try OLEDFrameEncoder.validateSourceFileSize(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 + 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)) 帧图片:套图 \(selectedOLEDGIFSet + 1) · \(selectedOLEDTaskState.title)。" + } + syncStatusMessage = "已更新 \(selectedMode.title) 套图 \(selectedOLEDGIFSet + 1) 的 \(selectedOLEDTaskState.title) 预览;写入设备请使用底部通用按钮。" + } + } + + 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 + isCancellingDeviceWrite = false + completedTaskResourceCount = 0 + syncStatusMessage = "正在准备写入设备…" + let returnAgent = returnToKeyboardControlWhenDone + + deviceWriteTask?.cancel() + deviceWriteTask = Task { @MainActor in + defer { self.deviceWriteTask = nil } + 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) + let failures = self.lastTaskUploadFailures + if failures.isEmpty { + self.lastSyncedDraft = self.studioDraft + AhaKeyStudioStore.saveSyncBaseline(self.lastSyncedDraft) + } + // On partial failure keep the per-slot baseline built during + // upload (markTaskPictureSynced ran only for the images that + // succeeded) so the next write retries only the failed images + // instead of re-sending everything. + self.lastSyncDate = Date() + self.isSyncing = false + self.isCancellingDeviceWrite = false + if failures.isEmpty { + self.syncStatusMessage = "已全部写入设备并保存。" + if showResultAlert { + self.writeResultAlertMessage = "配置已成功写入键盘。" + self.showsWriteResultAlert = true + } + } else { + let list = failures.joined(separator: "\n") + self.syncStatusMessage = "键位/灯效已保存;有 \(failures.count) 张图片未写入。其余图片不受影响,可再次点击写入仅重试失败项。" + if showResultAlert { + self.writeResultAlertMessage = "部分完成:键位与灯效已写入,但以下图片未写入:\n\(list)\n其余图片不受影响,可再次点击写入仅重试失败项。" + self.showsWriteResultAlert = true + } + } + if returnAgent { + self.returnToKeyboardControl() + } + } + } + } catch { + let message = error.localizedDescription + self.isSyncing = false + self.isCancellingDeviceWrite = false + self.syncStatusMessage = message + if showResultAlert { + self.writeResultAlertMessage = "写入键盘失败:\(message)" + self.showsWriteResultAlert = true + } + } + } + } + + private func cancelCurrentDeviceWrite() { + guard isSyncing, !isCancellingDeviceWrite else { return } + isCancellingDeviceWrite = true + syncStatusMessage = "正在停止当前图片写入;已完成的图片会保留…" + bleManager.cancelOLEDUpload() + deviceWriteTask?.cancel() + } + + private func uploadChangedOLEDsToDevice() async throws -> Int { + var uploadCount = 0 + lastTaskUploadFailures = [] + var deviceStates = try await bleManager.readAllTaskPictureStates() + var defaultPictureStates: [AhaKeyPictureState] = [] + for mode in AhaKeyModeSlot.allCases { + do { + defaultPictureStates.append(try await bleManager.readPictureState(mode: UInt8(mode.rawValue))) + } catch { + // Older firmware variants may not expose a parseable 0x83 response. + // The resource upload itself remains valid without this optional reservation. + bleManager.appendCommLogLine("默认动画槽位读取不可用,跳过占用校验:\(error.localizedDescription)", isError: true) + } + } + let defaultOccupiedRegions = defaultPictureStates + .filter { $0.picLength > 0 } + .map { (start: $0.startIndex, end: $0.startIndex + $0.picLength) } + let taskSlotsOverlappingDefaultPicture = Set(deviceStates.compactMap { taskState -> KeyboardTaskPictureSlot? in + guard taskState.picLength > 0 else { return nil } + let taskStart = taskState.startIndex + let taskEnd = taskStart + taskState.picLength + let overlapsDefaultPicture = defaultOccupiedRegions.contains { region in + taskStart < region.end && region.start < taskEnd + } + guard overlapsDefaultPicture else { return nil } + return KeyboardTaskPictureSlot(mode: taskState.mode, set: taskState.set, state: taskState.state) + }) + if !taskSlotsOverlappingDefaultPicture.isEmpty { + bleManager.appendCommLogLine("检测到旧版任务 GIF 与普通动画槽位重叠;本次将迁移受影响资源。", isError: false) + } + let maxFrames = bleManager.firmwareCapabilities?.userSlotLimit + ?? (AhaKeyCommand.oledFactoryReservedSlots + AhaKeyCommand.oledModeCount * AhaKeyCommand.oledMaxFramesPerMode) + + guard bleManager.allowsTaskPictureConfiguration else { + throw OLEDUploadError.unsupportedFirmwareProtocol + } + + for mode in AhaKeyModeSlot.allCases { + let currentOLED = studioDraft.draft(for: mode).oled + let baselineOLED = lastSyncedDraft.draft(for: mode).oled + for set in 0 ..< 2 { + for state in bleManager.supportedTaskDisplayStates { + let asset = currentOLED.taskAsset(set: set, state: state) + let previous = baselineOLED.taskAsset(set: set, state: state) + let target = KeyboardTaskPictureSlot(mode: mode.rawValue, set: set, state: state.rawValue) + let deviceState = deviceStates.first { $0.mode == target.mode && $0.set == target.set && $0.state == target.state } + let factoryBase = bleManager.firmwareCapabilities?.factorySlotBase ?? Int.max + let reclaimBase = bleManager.firmwareCapabilities?.reclaimSlotBase ?? Int.max + let reclaimLimit = bleManager.firmwareCapabilities?.reclaimSlotLimit ?? Int.max + let deviceUsesReclaimedSlot = + (deviceState?.startIndex ?? 0) >= reclaimBase + && (deviceState?.startIndex ?? 0) < reclaimLimit + let deviceUsesFactoryAsset = + (deviceState?.picLength ?? 0) > 0 + && (deviceState?.startIndex ?? 0) >= factoryBase + && !deviceUsesReclaimedSlot + let needsClear = + asset.localAssetPath == nil + && (deviceState?.picLength ?? 0) > 0 + && !deviceUsesFactoryAsset + let needsUpload = asset.localAssetPath != nil && (deviceState?.picLength ?? 0) == 0 + let needsSlotMigration = taskSlotsOverlappingDefaultPicture.contains(target) + let assetChanged = asset != previous + let schemaMigration = + asset.localAssetPath != nil + && (asset.deviceSchemaVersion ?? 0) < 3 + guard assetChanged || needsClear || needsUpload || needsSlotMigration || schemaMigration else { continue } + + // Record exactly why this slot is being (re)written so a + // "why did it upload N images" report is answerable from the + // comm log instead of guesswork. + var reasons: [String] = [] + if assetChanged { reasons.append("内容已改") } + if needsUpload { reasons.append("设备该槽为空") } + if needsClear { reasons.append("需清除") } + if needsSlotMigration { reasons.append("槽位与默认动画重叠迁移") } + if schemaMigration { reasons.append("旧版数据迁移(schema<3)") } + bleManager.appendCommLogLine("待写入 \(mode.title)·套图\(set == 0 ? "A" : "B")·\(state.title):\(reasons.joined(separator: "、"))", isError: false) + + syncStatusMessage = "正在处理第 \(completedTaskResourceCount + 1) 张:\(mode.title) · 套图 \(set == 0 ? "A" : "B") · \(state.title)…" + + // Isolate each image. A single slot's failure must NOT abort + // the rest of the sync, and must NOT force the others to be + // re-uploaded next time: a failed slot is simply left + // un-marked so its `asset != previous / needsUpload` guard + // re-triggers only that slot on the next sync. User-driven + // cancellation and a lost connection still abort everything. + do { + guard let assetPath = asset.localAssetPath else { + try await bleManager.clearTaskPicture(mode: UInt8(mode.rawValue), set: UInt8(set), state: UInt8(state.rawValue)) + if let index = deviceStates.firstIndex(where: { $0.mode == target.mode && $0.set == target.set && $0.state == target.state }) { + let old = deviceStates[index] + deviceStates[index] = AhaKeyTaskPictureState(mode: old.mode, set: old.set, state: old.state, startIndex: 0, picLength: 0, frameInterval: 0, allModeMaxPic: old.allModeMaxPic, activeSet: old.activeSet) + } + try await bleManager.saveConfigAwaitingResponse() + markTaskPictureSynced(mode: mode, set: set, state: state, asset: asset) + completedTaskResourceCount += 1 + continue + } + + let assetURL = URL(fileURLWithPath: assetPath) + try OLEDFrameEncoder.validateGIFSourceFileSize(at: assetURL) + let frames = try OLEDFrameEncoder.frames(fromGIFAt: assetURL, maxFrames: AhaKeyCommand.taskOLEDMaxFrames) + let occupied = (deviceStates.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) } + + defaultOccupiedRegions) + .sorted { $0.start < $1.start } + let primaryRange = + AhaKeyCommand.oledFactoryReservedSlots ..< maxFrames + var allowedRanges = [primaryRange] + if reclaimBase < reclaimLimit { + allowedRanges.append(reclaimBase ..< reclaimLimit) + } + var allocatedStart: Int? + for allowedRange in allowedRanges { + var candidate = allowedRange.lowerBound + for region in occupied { + if region.end <= allowedRange.lowerBound || + region.start >= allowedRange.upperBound { + continue + } + if candidate + frames.count <= region.start { + break + } + candidate = max(candidate, region.end) + } + if candidate + frames.count <= allowedRange.upperBound { + allocatedStart = candidate + break + } + } + guard let start = allocatedStart else { + throw OLEDUploadError.noAvailablePictureSlot( + needed: frames.count, + max: max(maxFrames, reclaimLimit) + ) + } + + try await bleManager.uploadTaskOLEDFrames(frames, fps: asset.framesPerSecond, mode: UInt8(mode.rawValue), set: UInt8(set), state: UInt8(state.rawValue), startIndex: UInt16(start)) + let updated = AhaKeyTaskPictureState(mode: mode.rawValue, set: set, state: state.rawValue, startIndex: start, picLength: frames.count, frameInterval: max(1, 1000 / asset.framesPerSecond), allModeMaxPic: maxFrames, activeSet: bleManager.activeTaskPictureSets[mode.rawValue] ?? 0) + if let index = deviceStates.firstIndex(where: { $0.mode == target.mode && $0.set == target.set && $0.state == target.state }) { deviceStates[index] = updated } + else { deviceStates.append(updated) } + try await bleManager.saveConfigAwaitingResponse() + markTaskPictureSynced(mode: mode, set: set, state: state, asset: asset) + completedTaskResourceCount += 1 + uploadCount += 1 + } catch OLEDUploadError.cancelled { + throw OLEDUploadError.cancelled + } catch OLEDUploadError.connectionLost { + throw OLEDUploadError.connectionLost + } catch is CancellationError { + throw OLEDUploadError.cancelled + } catch { + let label = "\(mode.title) · 套图 \(set == 0 ? "A" : "B") · \(state.title)" + lastTaskUploadFailures.append("\(label):\(error.localizedDescription)") + bleManager.appendCommLogLine("第 \(completedTaskResourceCount + 1) 张「\(label)」写入失败,跳过并继续:\(error.localizedDescription)", isError: true) + completedTaskResourceCount += 1 + continue + } + } + } + updateMode(mode) { modeDraft in + modeDraft.oled.taskGIFSchemaVersion = 3 + modeDraft.oled.statusLine = "任务状态动图已写入设备。" + } + } + + return uploadCount + } + + private func markTaskPictureSynced(mode: AhaKeyModeSlot, set: Int, state: AhaKeyTaskDisplayState, asset: AhaKeyTaskGIFAssetDraft) { + var syncedAsset = asset + syncedAsset.deviceSchemaVersion = 3 + var baseline = lastSyncedDraft + var baselineMode = baseline.draft(for: mode) + baselineMode.oled.updateTaskAsset(set: set, asset: syncedAsset) + baseline.updateMode(baselineMode) + lastSyncedDraft = baseline + AhaKeyStudioStore.saveSyncBaseline(lastSyncedDraft) + + var current = studioDraft + var currentMode = current.draft(for: mode) + currentMode.oled.updateTaskAsset(set: set, asset: syncedAsset) + current.updateMode(currentMode) + studioDraft = current + } + + private func handleFactoryResourceChange(_ capabilities: AhaKeyFirmwareCapabilities?) { + guard let capabilities, capabilities.factoryManifestCRC != 0 else { return } + let key = "ahakey.factory.manifest.last-seen" + let previous = UInt32(truncatingIfNeeded: UserDefaults.standard.integer(forKey: key)) + guard previous != capabilities.factoryManifestCRC else { return } + + var baseline = lastSyncedDraft + for mode in AhaKeyModeSlot.allCases { + var modeDraft = baseline.draft(for: mode) + for set in 0 ..< 2 { + for state in AhaKeyTaskDisplayState.allCases { + var asset = modeDraft.oled.taskAsset(set: set, state: state) + asset.localAssetPath = nil + modeDraft.oled.updateTaskAsset(set: set, asset: asset) + } + } + modeDraft.oled.taskGIFSchemaVersion = 3 + baseline.updateMode(modeDraft) + } + lastSyncedDraft = baseline + AhaKeyStudioStore.saveSyncBaseline(baseline) + UserDefaults.standard.set(Int(capabilities.factoryManifestCRC), forKey: key) + syncStatusMessage = previous == 0 + ? "已识别犀牛固件工厂图片;本地图片草稿保持不变。" + : "检测到固件工厂图片已更新;旧本地图片已标记为待写入草稿。" + } + + 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、PNG 或 JPG(源文件 ≤50 MB,自动适配 160×80),可先在虚拟屏幕里预览 + 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/platforms/macos/Sources/Views/CompatLabeledContent.swift b/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift similarity index 100% rename from platforms/macos/Sources/Views/CompatLabeledContent.swift rename to ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift 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/platforms/macos/Sources/Views/DeviceInfoView.swift b/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift similarity index 86% rename from platforms/macos/Sources/Views/DeviceInfoView.swift rename to ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift index 4a929868..d4be221f 100644 --- a/platforms/macos/Sources/Views/DeviceInfoView.swift +++ b/ahakeyconfig-mac/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、同步、本机灯效测试(支持蓝牙或 USB 有线配置)" + : "Claude/Cursor/Codex/Kimi Hook、灯条状态、拨杆查询") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.leading) @@ -75,7 +75,9 @@ struct DeviceInfoView: View { } CompatLabeledContent("当前") { HStack(spacing: 6) { - Text(bleManager.isConnected ? "本 App 已连接蓝牙" : "本 App 未连接") + Text(bleManager.isWiredConnected + ? "本 App 已通过 USB 有线连接" + : (bleManager.isConnected ? "本 App 已连接蓝牙" : "本 App 未连接")) Text("·") .foregroundStyle(.tertiary) Text(agentBluetoothStatusText()) @@ -85,8 +87,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 +107,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 +127,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 +205,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 +242,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 +444,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 +496,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 +503,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 未连接" } @@ -526,9 +512,10 @@ struct DeviceInfoView: View { private func workModeName(_ mode: Int) -> String { switch mode { - case 0: return "Mode 0" - case 1: return "Mode 1" - case 2: return "Mode 2" + 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)" } } @@ -557,6 +544,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/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift similarity index 97% rename from platforms/macos/Sources/Views/KeyMappingView.swift rename to ahakeyconfig-mac/Sources/Views/KeyMappingView.swift index be8fcfbc..92ad9c8b 100644 --- a/platforms/macos/Sources/Views/KeyMappingView.swift +++ b/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift @@ -36,11 +36,11 @@ struct KeyMappingView: View { KeyConfig(hidCode: HIDUsage.capsLock, description: "录音"), KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), KeyConfig(hidCode: HIDUsage.escape, description: "取消"), - KeyConfig(hidCode: HIDUsage.enter, description: "Enter"), + 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↵"] + private let keyLabels = ["Key 1\n🎤", "Key 2\n✓", "Key 3\n✗", "Key 4\n⌫"] var body: some View { Form { @@ -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/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift similarity index 100% rename from platforms/macos/Sources/Views/OLEDManagerView.swift rename to ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift 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/Tests/AhaKeyConfigTests/AhaKeyProtocolTests.swift b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeyProtocolTests.swift new file mode 100644 index 00000000..21b082c3 --- /dev/null +++ b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeyProtocolTests.swift @@ -0,0 +1,128 @@ +import XCTest +@testable import AhaKeyConfig + +final class AhaKeyProtocolTests: XCTestCase { + func testParsesRhinoCapabilities() { + let payload = Data([ + 3, 4, 2, 4, + 0x3F, 0x00, + 0xC8, 0x00, + 0x20, 0x01, + 0x30, 0x01, + 0x02, 0x00, 0x00, 0x00, + 0xF6, 0x5D, 0x2C, 0x82, + 2, 0, + 0x28, 0x01, + 0x30, 0x01, + ]) + + let capabilities = AhaKeyResponseParser.parseCapabilities(payload) + + XCTAssertEqual(capabilities?.protocolVersion, 3) + XCTAssertEqual(capabilities?.modeCount, 4) + XCTAssertEqual(capabilities?.setCount, 2) + XCTAssertEqual(capabilities?.stateCount, 4) + XCTAssertEqual(capabilities?.maxPacketSize, 200) + XCTAssertEqual(capabilities?.userSlotLimit, 288) + XCTAssertEqual(capabilities?.factorySlotBase, 304) + XCTAssertEqual(capabilities?.factoryBundleVersion, 2) + XCTAssertEqual(capabilities?.factoryManifestCRC, 0x822C5DF6) + XCTAssertEqual(capabilities?.factoryStatus, 2) + XCTAssertEqual(capabilities?.reclaimSlotBase, 296) + XCTAssertEqual(capabilities?.reclaimSlotLimit, 304) + XCTAssertTrue(capabilities?.supportsIdleTaskPicture == true) + XCTAssertTrue(capabilities?.supportsSessionUpload == true) + } + + func testRejectsTruncatedCapabilities() { + XCTAssertNil(AhaKeyResponseParser.parseCapabilities(Data(repeating: 0, count: 13))) + } + + func testParsesCompactCapabilitiesThatFitDefaultBLEMTU() { + let payload = Data([ + 3, 4, 2, 4, + 0x3F, 0x00, + 0xC8, 0x00, + 0x14, 0x01, + 0x1C, 0x01, + 0x24, 0x01, + ]) + + let capabilities = AhaKeyResponseParser.parseCapabilities(payload) + + XCTAssertEqual(capabilities?.protocolVersion, 3) + XCTAssertEqual(capabilities?.modeCount, 4) + XCTAssertEqual(capabilities?.stateCount, 4) + XCTAssertEqual(capabilities?.userSlotLimit, 276) + XCTAssertEqual(capabilities?.reclaimSlotBase, 284) + XCTAssertEqual(capabilities?.reclaimSlotLimit, 292) + XCTAssertTrue(capabilities?.supportsIdleTaskPicture == true) + } + + func testSessionCommandsUseLittleEndianFields() { + XCTAssertEqual( + Array(AhaKeyCommand.prepareSessionWrite( + sessionID: 0x1234, + chunkLength: 4096, + address: 0x00123000 + )), + [ + 0xAA, 0xBB, 0x9B, + 0x34, 0x12, + 0x00, 0x10, + 0x00, 0x30, 0x12, 0x00, + 0xCC, 0xDD, + ] + ) + XCTAssertEqual( + Array(AhaKeyCommand.abortPictureWrite(sessionID: 0x1234)), + [0xAA, 0xBB, 0x9A, 0x34, 0x12, 0xCC, 0xDD] + ) + } + + func testImageSourceLimitsMatchNegotiatedPolicy() { + XCTAssertEqual(AhaKeyCommand.staticOLEDMaxSourceFileBytes, 50 * 1024 * 1024) + XCTAssertEqual(AhaKeyCommand.animatedOLEDMaxSourceFileBytes, 50 * 1024 * 1024) + XCTAssertEqual(AhaKeyCommand.taskOLEDMaxFrames, 30) + XCTAssertEqual(AhaKeyCommand.oledEncodedFrameBytes, 25_600) + } + + func testStaticGIFFramesCollapseBeforeUpload() { + let frame = Data(repeating: 0x5A, count: AhaKeyCommand.oledEncodedFrameBytes) + XCTAssertEqual( + OLEDFrameEncoder.collapsingStaticFrames([frame, frame, frame]), + [frame] + ) + XCTAssertEqual( + OLEDFrameEncoder.collapsingStaticFrames([ + frame, + Data(repeating: 0, count: frame.count), + ]).count, + 2 + ) + } + + func testRhinoCapabilitiesExposeAllFourTaskStates() { + let capabilities = AhaKeyFirmwareCapabilities( + protocolVersion: 3, + modeCount: 4, + setCount: 2, + stateCount: 4, + flags: 0x003F, + maxPacketSize: 244, + userSlotLimit: 276, + factorySlotBase: 276, + factoryBundleVersion: 1, + factoryManifestCRC: 0, + factoryStatus: 2, + factoryError: 0, + reclaimSlotBase: 284, + reclaimSlotLimit: 292 + ) + + XCTAssertEqual( + capabilities.supportedTaskDisplayStates, + [.idle, .working, .waiting, .done] + ) + } +} 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..c560ff15 --- /dev/null +++ b/ahakeyconfig-mac/scripts/build.sh @@ -0,0 +1,237 @@ +#!/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)" +# 版本号:缺省 0.1.0(本地开发),release.yml 打 tag(vX.Y.Z) 时经 APP_VERSION 注入真实版本, +# 否则所有 Release 都会是同一个写死的版本号,用户无法区分是否已更新。 +APP_VERSION_STRING="${APP_VERSION:-0.1.0}" + +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 + ${APP_VERSION_STRING} + 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_app.sh b/ahakeyconfig-mac/scripts/package_app.sh new file mode 100755 index 00000000..de625323 --- /dev/null +++ b/ahakeyconfig-mac/scripts/package_app.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# 把 AhaKeyConfig 组装成 ad-hoc 签名的 "AhaKey Studio.app"。 +# 本地与 CI 共用;产物落在 ahakeyconfig-mac/dist/。 +# +# APP_VERSION=1.2.3 APP_BUILD=42 scripts/package_app.sh +# +# 版本号可用环境变量覆盖,缺省时 version=0.0.0、build=git 提交数。 +set -euo pipefail + +cd "$(dirname "$0")/.." # -> ahakeyconfig-mac + +CONFIG="release" +APP_NAME="AhaKey Studio" +EXEC="AhaKeyConfig" +DIST="dist" +APP="${DIST}/${APP_NAME}.app" + +echo "==> swift build -c ${CONFIG} --product ${EXEC}" +swift build -c "${CONFIG}" --product "${EXEC}" +BIN_PATH="$(swift build -c "${CONFIG}" --show-bin-path)" + +echo "==> assembling ${APP}" +rm -rf "${APP}" +mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" +cp "${BIN_PATH}/${EXEC}" "${APP}/Contents/MacOS/${EXEC}" + +VERSION="${APP_VERSION:-0.0.0}" +BUILD="${APP_BUILD:-$(git rev-list --count HEAD 2>/dev/null || echo 1)}" + +# EmbeddedInfo.plist 只含 TCC 权限键(已嵌进二进制的 __info_plist 段); +# bundle 的 Contents/Info.plist 还需要 CFBundleExecutable 等键,这里补全。 +cat > "${APP}/Contents/Info.plist" < + + + + CFBundleName${APP_NAME} + CFBundleDisplayName${APP_NAME} + CFBundleIdentifierlab.jawa.ahakeyconfig + CFBundleExecutable${EXEC} + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${BUILD} + LSMinimumSystemVersion12.0 + NSHighResolutionCapable + NSBluetoothAlwaysUsageDescriptionAhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescriptionAhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescriptionAhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + +PLIST + +# 默认资源(OLED 动图等),存在才拷。 +if [ -d "Resources" ]; then + cp -R "Resources/." "${APP}/Contents/Resources/" 2>/dev/null || true +fi + +# ad-hoc 签名,让产物在本地/CI 检查时可被 Gatekeeper 识别为已签名(非公证)。 +codesign --force --deep --sign - "${APP}" 2>/dev/null || echo "warn: codesign skipped (no codesign available)" + +echo "==> done: ${APP} (version ${VERSION}, build ${BUILD})" diff --git a/ahakeyconfig-mac/scripts/package_dmg.sh b/ahakeyconfig-mac/scripts/package_dmg.sh new file mode 100755 index 00000000..17b4f2e2 --- /dev/null +++ b/ahakeyconfig-mac/scripts/package_dmg.sh @@ -0,0 +1,205 @@ +#!/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}" +# HFS+ is case-insensitive; avoid a volume name that shares the app bundle prefix. +DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-Install AhaKey Studio}" +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..a8bfbfb2 --- /dev/null +++ b/ahakeyconfig-ubuntu-java/pom.xml @@ -0,0 +1,162 @@ + + + 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 + + + + + net.java.dev.jna + jna + 5.14.0 + + + + + + + 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 中的
+