From 33eea3b2a5e21af3e6b7ff3bb19984f8a07300db Mon Sep 17 00:00:00 2001 From: Yuhan Jian Date: Thu, 24 Sep 2026 13:03:56 +0800 Subject: [PATCH] Add remote selection settings and automatic protocol matching --- Helpers/hci_helper.py | 97 ++++++- Installer/configure.py | 3 +- Installer/install-system.sh | 7 +- README.en.md | 6 + README.md | 5 + Resources/en.lproj/Localizable.strings | 34 +++ Resources/zh-Hans.lproj/Localizable.strings | 34 +++ Sources/RemoteBuddy/App/AppDelegate.swift | 96 ++++++- .../RemoteBuddy/Bluetooth/BLEController.swift | 49 +++- .../Bluetooth/RemoteDeviceDiscovery.swift | 82 ++++++ .../RemoteBuddy/Input/HCISocketSource.swift | 46 +++- .../Input/RemoteButtonController.swift | 17 +- .../Settings/MappingSettingsWindow.swift | 6 + .../Settings/RemoteConfiguration.swift | 69 +++++ .../Settings/RemoteSettingsWindow.swift | 242 ++++++++++++++++++ .../RemoteBuddy/Support/RemoteIdentity.swift | 10 +- Tests/Python/test_remote_configuration.py | 141 ++++++++++ .../RemoteSelectionTests.swift | 78 ++++++ .../RemoteBuddyTests/VoiceRecoveryTests.swift | 13 + docs/DEVELOPMENT.md | 5 + docs/DEVELOPMENT.zh-CN.md | 3 + docs/INSTALL.en.md | 31 ++- docs/INSTALL.zh-CN.md | 22 +- docs/PROTOCOL.md | 17 +- docs/PROTOCOL.zh-CN.md | 12 +- 25 files changed, 1070 insertions(+), 55 deletions(-) create mode 100644 Sources/RemoteBuddy/Bluetooth/RemoteDeviceDiscovery.swift create mode 100644 Sources/RemoteBuddy/Settings/RemoteConfiguration.swift create mode 100644 Sources/RemoteBuddy/Settings/RemoteSettingsWindow.swift create mode 100644 Tests/Python/test_remote_configuration.py create mode 100644 Tests/RemoteBuddyTests/RemoteSelectionTests.swift diff --git a/Helpers/hci_helper.py b/Helpers/hci_helper.py index 0854793..8bf8757 100644 --- a/Helpers/hci_helper.py +++ b/Helpers/hci_helper.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Root capture service. No commands from clients; only one allowed user's socket. +"""Root capture service restricted to one allowed user's socket. The Apple-signed PacketLogger writes binary records into a private FIFO. Raw traffic stays in memory. Only the configured remote's HID notifications leave @@ -10,6 +10,7 @@ import json import os import pathlib +import re import selectors import signal import socket @@ -18,6 +19,61 @@ import subprocess import tempfile import time +import uuid + + +def remote_selection(request): + """Only device selection fields may cross the user/root boundary.""" + allowed = {"type", "address", "attribute", "report_format", "peripheral_id", "auto_detect"} + if not isinstance(request, dict) or set(request) - allowed or request.get("type") != "configure": + raise ValueError("Invalid configuration request") + address = request.get("address") + attribute = request.get("attribute") + report_format = request.get("report_format") + automatic = request.get("auto_detect", False) + if not isinstance(address, str) or not re.fullmatch(r"[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}", address): + raise ValueError("Invalid remote address") + if type(attribute) is not int or not 1 <= attribute <= 0xffff: + raise ValueError("Invalid ATT handle") + if report_format not in ("indexed", "consumer16") or type(automatic) is not bool: + raise ValueError("Invalid report format or detection mode") + result = dict(address=address.upper(), attribute=attribute, report_format=report_format, auto_detect=automatic) + identifier = request.get("peripheral_id") + if identifier is not None: + if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", identifier): + raise ValueError("Invalid peripheral identifier") + result["peripheral_id"] = str(uuid.UUID(identifier)).upper() + return result + + +def read_configuration(path): + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise ValueError("Helper configuration must be a root-owned regular file, not writable by other users") + return json.loads(path.read_text()) + + +def save_selection(path, current, selection): + """Replace only the fixed service config; never accept a path or command.""" + on_disk = read_configuration(path) + if on_disk != current: + raise ValueError("Configuration changed; reconnect before saving") + updated = {**current, **selection} + if "peripheral_id" not in selection: + updated.pop("peripheral_id", None) + descriptor, temporary = tempfile.mkstemp(prefix=".remote-config-", dir=path.parent) + try: + with os.fdopen(descriptor, "w") as output: + os.fchmod(output.fileno(), 0o644) + json.dump(updated, output, indent=2) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return updated class Frames: @@ -175,7 +231,7 @@ def peer_uid(connection): return uid.value -def session(connection, config): +def session(connection, config, config_path): decoder = RemoteReports(config["address"], config["attribute"], config.get("report_format", "indexed")) capture = Capture(config["packetlogger"]) selector = selectors.DefaultSelector() @@ -185,18 +241,37 @@ def session(connection, config): last_heartbeat = 0 started = time.monotonic() reports_seen = 0 + request_buffer = bytearray() def send(kind, **fields): message = {"type": kind, "address": config["address"], "received_at": time.time(), **fields} connection.sendall(json.dumps(message, separators=(",", ":")).encode() + b"\n") try: - send("connected") + send("connected", protocol_version=2) while True: for key, _ in selector.select(timeout=0.5): if key.data == "client": - # EOF closes capture; any client command is rejected. - connection.recv(256) + chunk = connection.recv(4097) + if not chunk: + return + request_buffer.extend(chunk) + if len(request_buffer) > 4096: + send("configuration_error") + return + if b"\n" not in request_buffer: + continue + try: + # One bounded JSON message; extra messages are rejected. + request = json.loads(request_buffer) + selection = remote_selection(request) + save_selection(config_path, config, selection) + except (OSError, ValueError, TypeError): + send("configuration_error") + return + send("configured", configuration=selection) + # Restart capture on the next connection, so the new + # decoder receives PacketLogger's connection metadata. return for timestamp, kind, body in capture.read(): payload = decoder.accept(kind, body) @@ -253,10 +328,7 @@ def terminate(*_): self_test(args.self_test_stream, args.address, args.attribute) return config_path = pathlib.Path(args.config) - info = config_path.stat() - if info.st_uid != 0 or info.st_mode & 0o022: - raise SystemExit("Helper configuration must be root-owned and not writable by other users") - config = json.loads(config_path.read_text()) + config = read_configuration(config_path) path = config["socket"] if os.path.lexists(path): if not stat.S_ISSOCK(os.lstat(path).st_mode): @@ -274,7 +346,12 @@ def terminate(*_): connection, _ = server.accept() try: if peer_uid(connection) == config["uid"]: - session(connection, config) + latest = read_configuration(config_path) + # A selection update must not change service ownership, + # socket location or the executable used for capture. + if any(latest[key] != config[key] for key in ("uid", "gid", "socket", "packetlogger")): + raise ValueError("Service settings changed; restart the helper") + session(connection, latest, config_path) except (OSError, RuntimeError, ValueError) as error: print(f"Session ended: {error}", flush=True) time.sleep(1) diff --git a/Installer/configure.py b/Installer/configure.py index fddbab2..ec05985 100644 --- a/Installer/configure.py +++ b/Installer/configure.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Generate launchd/configuration files without writing to system locations. -Only install-system.sh installs these files. This module is also used in tests. +install-system.sh installs these files; the helper can later update only the +device-selection fields. This module is also used in tests. """ import argparse import json diff --git a/Installer/install-system.sh b/Installer/install-system.sh index b4f46de..c9a36b1 100755 --- a/Installer/install-system.sh +++ b/Installer/install-system.sh @@ -63,5 +63,10 @@ cp "$RB_ROOT/generated/daemon.plist" "/Library/LaunchDaemons/$RB_LABEL.hci.plist chown root:wheel "$RB_SERVICE" "$RB_SERVICE/hci_helper.py" "$RB_SERVICE/hci-config.json" "/Library/LaunchDaemons/$RB_LABEL.hci.plist" chmod 755 "$RB_SERVICE" chmod 644 "$RB_SERVICE/hci_helper.py" "$RB_SERVICE/hci-config.json" "/Library/LaunchDaemons/$RB_LABEL.hci.plist" -launchctl bootstrap system "/Library/LaunchDaemons/$RB_LABEL.hci.plist" +# bootout can return before launchd has fully removed the old registration. +for rb_attempt in {1..10}; do + if launchctl bootstrap system "/Library/LaunchDaemons/$RB_LABEL.hci.plist"; then break; fi + [[ "$rb_attempt" -lt 10 ]] || rb_fail 'Could not restart the HCI helper.' + sleep 0.5 +done print 'System components installed. Bluetooth capture starts only when the app connects.' diff --git a/README.en.md b/README.en.md index 74b520b..46dfcdf 100644 --- a/README.en.md +++ b/README.en.md @@ -104,6 +104,12 @@ Disabled actions remain no-ops. Repeats target a 65 ms interval with independent per-button timers and stop on release. A two-second forced release remains; release and press again to continue. Voice tap/hold behavior is unchanged. +Use **Remote Settings…** to refresh and select a connected remote or enter its +Bluetooth address. Verified ABBEY / 22.2 and Jieli `hid_mouse` / 0.0.1 devices +automatically select their report format and handle. Saving switches buttons and +voice together and preserves mappings. Unknown models have a manual option; +using this feature requires a helper update. See [Replacing a remote](docs/INSTALL.en.md#replacing-a-remote). + ## Build from source ```sh diff --git a/README.md b/README.md index a840b89..3cead43 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ GitHub 的源码 ZIP 不含已构建应用,需要先按下文构建。 连发目标间隔为 65 毫秒,各键独立计时;松开即停止。按住超过两秒会强制释放,松开再按可继续。 语音键保持短按切换收音、按住说话的行为。 +更换设备可打开菜单栏 **遥控器设置…**:刷新并选择已连接的遥控器,或手动输入蓝牙地址。 +已验证的 ABBEY / 22.2 与 Jieli `hid_mouse` / 0.0.1 会自动匹配按键格式和句柄, +保存后按键与语音一起切换,保留个人映射。未知型号保留手动设置;首次使用此功能需升级辅助服务。 +详见[更换遥控器](docs/INSTALL.zh-CN.md#更换遥控器)。 + ## 源码与构建 ```sh diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 7b9af6c..2963c9b 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -106,3 +106,37 @@ "已识别:%@ · 按下" = "Detected: %@ · Pressed"; "已识别:%@ · 已松开(%.2f 秒)" = "Detected: %@ · Released (%.2f s)"; "已恢复初始映射,点击保存后应用。" = "Defaults restored. Click Save & Apply to use them."; +"请填写有效的蓝牙地址,例如 AA:BB:CC:DD:EE:FF。" = "Enter a valid Bluetooth address, such as AA:BB:CC:DD:EE:FF."; +"按键句柄需为 1–65535,也可填写 0x 开头的十六进制值。" = "The report handle must be between 1 and 65535. Hexadecimal values starting with 0x are also accepted."; +"无法自动识别此遥控器。请先连接后刷新,或关闭自动识别并填写已确认的参数。" = "This remote could not be identified. Connect it and refresh, or turn off automatic detection and enter verified settings."; +"辅助服务未连接,请稍后重试或重新运行安装器。" = "The helper is not connected. Try again shortly or run the installer again."; +"辅助服务需要更新,请运行新版安装器后再更换遥控器。" = "The helper needs an update. Run the new installer before changing remotes."; +"遥控器配置未能保存,请检查辅助服务后重试。" = "Could not save the remote settings. Check the helper and try again."; +"请在遥控器设置中选择已连接的设备。" = "Choose a connected device in Remote Settings."; +"自动识别兼容参数" = "Detect compatibility settings automatically"; +"刷新设备" = "Refresh"; +"保存并连接" = "Save & Connect"; +"报告格式" = "Report format"; +"按键句柄" = "Report handle"; +"RemoteBuddy · 遥控器设置" = "RemoteBuddy · Remote Settings"; +"选择遥控器" = "Choose a Remote"; +"先在系统蓝牙设置中配对并连接遥控器,然后刷新列表。也可手动填写蓝牙地址。" = "Pair and connect your remote in Bluetooth settings, then refresh this list. You can also enter its Bluetooth address."; +"已连接设备" = "Connected device"; +"蓝牙地址" = "Bluetooth address"; +"保存后按键和语音一起切换,原有按键映射会保留。关闭设置窗口后恢复使用。" = "Saving switches both buttons and voice to this remote and keeps your button mappings. Close settings to resume using it."; +"打开蓝牙设置…" = "Open Bluetooth Settings…"; +"关闭" = "Close"; +"选择设备,或在下方填写地址" = "Choose a device, or enter an address below"; +"未知" = "Unknown"; +"厂商:%@" = "Manufacturer: %@"; +"型号:%@" = "Model: %@"; +"固件:%@" = "Firmware: %@"; +"设备未连接。连接后刷新即可读取厂商、型号和固件。" = "The device is not connected. Connect it and refresh to read its manufacturer, model, and firmware."; +"已自动匹配:%@" = "Automatically matched: %@"; +"兼容参数已识别,可以保存并连接。" = "Compatibility settings found. You can save and connect."; +"尚未匹配兼容参数" = "No compatibility match yet"; +"手动配置 · 仅填写已确认的参数" = "Manual setup · Use verified settings only"; +"手动模式可用于更换同型号遥控器的地址。不同型号建议先尝试自动识别。" = "Use manual setup to change the address for the same remote model. Try automatic detection first for a different model."; +"正在保存并切换遥控器…" = "Saving and switching remotes…"; +"已保存并开始连接。关闭设置窗口后即可使用。" = "Saved and connecting. Close settings to use your remote."; +"遥控器设置…" = "Remote Settings…"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 5e3385c..28c62bd 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -106,3 +106,37 @@ "已识别:%@ · 按下" = "已识别:%@ · 按下"; "已识别:%@ · 已松开(%.2f 秒)" = "已识别:%@ · 已松开(%.2f 秒)"; "已恢复初始映射,点击保存后应用。" = "已恢复初始映射,点击保存后应用。"; +"请填写有效的蓝牙地址,例如 AA:BB:CC:DD:EE:FF。" = "请填写有效的蓝牙地址,例如 AA:BB:CC:DD:EE:FF。"; +"按键句柄需为 1–65535,也可填写 0x 开头的十六进制值。" = "按键句柄需为 1–65535,也可填写 0x 开头的十六进制值。"; +"无法自动识别此遥控器。请先连接后刷新,或关闭自动识别并填写已确认的参数。" = "无法自动识别此遥控器。请先连接后刷新,或关闭自动识别并填写已确认的参数。"; +"辅助服务未连接,请稍后重试或重新运行安装器。" = "辅助服务未连接,请稍后重试或重新运行安装器。"; +"辅助服务需要更新,请运行新版安装器后再更换遥控器。" = "辅助服务需要更新,请运行新版安装器后再更换遥控器。"; +"遥控器配置未能保存,请检查辅助服务后重试。" = "遥控器配置未能保存,请检查辅助服务后重试。"; +"请在遥控器设置中选择已连接的设备。" = "请在遥控器设置中选择已连接的设备。"; +"自动识别兼容参数" = "自动识别兼容参数"; +"刷新设备" = "刷新设备"; +"保存并连接" = "保存并连接"; +"报告格式" = "报告格式"; +"按键句柄" = "按键句柄"; +"RemoteBuddy · 遥控器设置" = "RemoteBuddy · 遥控器设置"; +"选择遥控器" = "选择遥控器"; +"先在系统蓝牙设置中配对并连接遥控器,然后刷新列表。也可手动填写蓝牙地址。" = "先在系统蓝牙设置中配对并连接遥控器,然后刷新列表。也可手动填写蓝牙地址。"; +"已连接设备" = "已连接设备"; +"蓝牙地址" = "蓝牙地址"; +"保存后按键和语音一起切换,原有按键映射会保留。关闭设置窗口后恢复使用。" = "保存后按键和语音一起切换,原有按键映射会保留。关闭设置窗口后恢复使用。"; +"打开蓝牙设置…" = "打开蓝牙设置…"; +"关闭" = "关闭"; +"选择设备,或在下方填写地址" = "选择设备,或在下方填写地址"; +"未知" = "未知"; +"厂商:%@" = "厂商:%@"; +"型号:%@" = "型号:%@"; +"固件:%@" = "固件:%@"; +"设备未连接。连接后刷新即可读取厂商、型号和固件。" = "设备未连接。连接后刷新即可读取厂商、型号和固件。"; +"已自动匹配:%@" = "已自动匹配:%@"; +"兼容参数已识别,可以保存并连接。" = "兼容参数已识别,可以保存并连接。"; +"尚未匹配兼容参数" = "尚未匹配兼容参数"; +"手动配置 · 仅填写已确认的参数" = "手动配置 · 仅填写已确认的参数"; +"手动模式可用于更换同型号遥控器的地址。不同型号建议先尝试自动识别。" = "手动模式可用于更换同型号遥控器的地址。不同型号建议先尝试自动识别。"; +"正在保存并切换遥控器…" = "正在保存并切换遥控器…"; +"已保存并开始连接。关闭设置窗口后即可使用。" = "已保存并开始连接。关闭设置窗口后即可使用。"; +"遥控器设置…" = "遥控器设置…"; diff --git a/Sources/RemoteBuddy/App/AppDelegate.swift b/Sources/RemoteBuddy/App/AppDelegate.swift index b898c84..8cbd517 100644 --- a/Sources/RemoteBuddy/App/AppDelegate.swift +++ b/Sources/RemoteBuddy/App/AppDelegate.swift @@ -11,6 +11,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let hciSource = HCIReportSource() private let hciService = HCISocketSource() private var mappingWindow: MappingSettingsWindow? + private var remoteWindow: RemoteSettingsWindow? + private var mappingSettingsVisible = false + private var remoteSettingsVisible = false + private var renderingPreview = false private var statusItem: NSStatusItem! private lazy var statusIcon: NSImage? = { let image = (NSImage(named: "StatusBarTemplate")?.copy() as? NSImage) @@ -27,8 +31,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) func applicationDidFinishLaunching(_ notification: Notification) { + if let index = CommandLine.arguments.firstIndex(of: "--render-remote-settings"), + CommandLine.arguments.indices.contains(index + 1) { + renderingPreview = true + let sample = DiscoveredRemote(name: "Chromecast Remote", address: "AA:BB:CC:DD:EE:FF", + peripheralID: UUID(uuidString: "11111111-2222-3333-4444-555555555555"), + manufacturer: "zhuhai_jieli", model: "hid_mouse", firmware: "0.0.1", vendorID: 0x18d1, productID: 0x9450) + let preview = RemoteSettingsWindow(discover: { [sample] }, load: { + if CommandLine.arguments.contains("--preview-remote-manual") { + return try? RemoteConfiguration(address: sample.address, attribute: 0x2b, + reportFormat: .consumer16, peripheralID: sample.peripheralID) + } + return try? sample.automaticConfiguration() + }) + remoteWindow = preview + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + do { try preview.renderPreview(to: URL(fileURLWithPath: CommandLine.arguments[index + 1])) } + catch { print(error.localizedDescription) } + NSApplication.shared.terminate(nil) + } + return + } if let index = CommandLine.arguments.firstIndex(of: "--render-settings"), CommandLine.arguments.indices.contains(index + 1) { + renderingPreview = true let preview = MappingSettingsWindow() mappingWindow = preview preview.showWindow(nil) @@ -55,6 +81,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let mapping = NSMenuItem(title: L10n.tr("按键设置…"), action: #selector(openMappingSettings), keyEquivalent: ",") mapping.target = self menu.addItem(mapping) + let remote = NSMenuItem(title: L10n.tr("遥控器设置…"), action: #selector(openRemoteSettings), keyEquivalent: "") + remote.target = self + menu.addItem(remote) menu.addItem(.separator()) let sound = NSMenuItem(title: L10n.tr("打开声音设置…"), action: #selector(openSoundSettings), keyEquivalent: "") sound.target = self @@ -74,12 +103,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { do { try audio.start() setStatus(L10n.format("音频:%@ · 正在连接遥控器…", audio.deviceName)) - let controller = BLEController(audio: audio) - controller.onStatus = { [weak self] in self?.setStatus($0) } - controller.onStreaming = { [weak self] active in - self?.updateStatusIcon(streaming: active) - } - bluetooth = controller + startVoiceController() MappingStore.shared.willChange = { [weak self] in self?.buttons.mappingWillChange() self?.bluetooth?.prepareForMappingChange() @@ -87,9 +111,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { buttons.onButtonActivity = { [weak self] button, pressed in self?.mappingWindow?.noteActivity(button: button, pressed: pressed) } - controller.onVoiceButtonActivity = { [weak self] pressed in - self?.mappingWindow?.noteActivity(button: nil, pressed: pressed) - } buttons.onStatus = { [weak self] in self?.setButtonStatus($0) } if CommandLine.arguments.contains("--diagnose-corehid"), #available(macOS 15, *) { let probe = CoreHIDProbe() @@ -151,6 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + if renderingPreview { return .terminateNow } bluetooth?.stop() // Fn+Space consists of events scheduled over 70 ms. Let its release // finish before exiting so WeChat input is not left recording/holding Fn. @@ -185,13 +207,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if mappingWindow == nil { let window = MappingSettingsWindow() window.onVisibilityChange = { [weak self] visible in - self?.buttons.setConfiguring(visible) - self?.bluetooth?.setConfiguring(visible) + self?.mappingSettingsVisible = visible + self?.updateSettingsActivity() } + window.onRemoteSettings = { [weak self] in self?.openRemoteSettings() } mappingWindow = window } mappingWindow?.present() } + private func startVoiceController() { + let controller = BLEController(audio: audio) + controller.onStatus = { [weak self] in self?.setStatus($0) } + controller.onStreaming = { [weak self] in self?.updateStatusIcon(streaming: $0) } + controller.onVoiceButtonActivity = { [weak self] in self?.mappingWindow?.noteActivity(button: nil, pressed: $0) } + controller.shortcutsSuspended = mappingSettingsVisible || remoteSettingsVisible + bluetooth = controller + } + + private func updateSettingsActivity() { + let visible = mappingSettingsVisible || remoteSettingsVisible + buttons.setConfiguring(visible) + bluetooth?.setConfiguring(visible) + } + + @objc private func openRemoteSettings() { + if remoteWindow == nil { + let window = RemoteSettingsWindow() + window.onVisibilityChange = { [weak self] visible in + self?.remoteSettingsVisible = visible + self?.updateSettingsActivity() + } + window.onApply = { [weak self] value, completion in + self?.applyRemoteConfiguration(value, completion: completion) + } + remoteWindow = window + } + remoteWindow?.present() + } + + private func applyRemoteConfiguration(_ value: RemoteConfiguration, + completion: @escaping (Result) -> Void) { + hciService.configure(value) { [weak self] response in + guard let self else { return } + // Read back the root-owned file. This also handles a lost socket + // acknowledgement after the atomic write succeeded. + guard RemoteIdentity.configuration == value else { + if case .failure(let error) = response { completion(.failure(error)) } + else { completion(.failure(RemoteSettingsError.saveFailed)) } + return + } + self.bluetooth?.stop() + self.bluetooth = nil + self.hciService.stop() + self.buttons.start() + self.buttons.setConfiguring(self.mappingSettingsVisible || self.remoteSettingsVisible) + self.startVoiceController() + self.hciService.start() + completion(.success(value)) + } + } } diff --git a/Sources/RemoteBuddy/Bluetooth/BLEController.swift b/Sources/RemoteBuddy/Bluetooth/BLEController.swift index baaf89d..2827824 100644 --- a/Sources/RemoteBuddy/Bluetooth/BLEController.swift +++ b/Sources/RemoteBuddy/Bluetooth/BLEController.swift @@ -17,6 +17,9 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg private let audio: AudioOutput private var central: CBCentralManager! private var peripheral: CBPeripheral? + private let remoteConfiguration = RemoteIdentity.configuration + private var reconnectWorkItem: DispatchWorkItem? + private var stopped = false private var commandCharacteristic: CBCharacteristic? private var subscribed = Set() private var requestedCapabilities = false @@ -69,6 +72,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard !stopped else { return } guard central.state == .poweredOn else { resetVoice(closeMicrophone: false) onStatus?(L10n.tr("蓝牙未就绪")) @@ -78,22 +82,39 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } private func reconnect() { + guard !stopped, central.state == .poweredOn else { return } + guard let identifier = RemoteDeviceDiscovery.selectedPeripheral(for: remoteConfiguration, + devices: RemoteDeviceDiscovery.connectedRemotes()) else { + onStatus?(L10n.tr("请在遥控器设置中选择已连接的设备。")) + scheduleReconnect() + return + } onStatus?(L10n.tr("正在查找 Chromecast Remote…")) - if let found = central.retrieveConnectedPeripherals(withServices: [service]) - .first(where: { $0.name?.localizedCaseInsensitiveContains("Chromecast Remote") == true }) { + if let found = central.retrievePeripherals(withIdentifiers: [identifier]).first { connect(found) } else { - central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + central.scanForPeripherals(withServices: [service], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + scheduleReconnect() } } + private func scheduleReconnect() { + reconnectWorkItem?.cancel() + guard !stopped else { return } + let work = DispatchWorkItem { [weak self] in self?.reconnect() } + reconnectWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work) + } + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard peripheral.name?.localizedCaseInsensitiveContains("Chromecast Remote") == true else { return } + guard !stopped, peripheral.identifier == RemoteDeviceDiscovery.selectedPeripheral(for: remoteConfiguration, + devices: RemoteDeviceDiscovery.connectedRemotes()) else { return } connect(peripheral) } private func connect(_ device: CBPeripheral) { + reconnectWorkItem?.cancel() central.stopScan() peripheral = device device.delegate = self @@ -102,6 +123,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard !stopped, peripheral.identifier == self.peripheral?.identifier else { return } onStatus?(L10n.tr("正在初始化语音服务…")) commandCharacteristic = nil subscribed.removeAll() @@ -112,8 +134,9 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + guard !stopped else { return } onStatus?(L10n.tr("连接失败,正在重试…")) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in self?.reconnect() } + scheduleReconnect() } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, @@ -126,10 +149,11 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } private func handleDisconnection() { + guard !stopped else { return } resetVoice(closeMicrophone: false) commandCharacteristic = nil onStatus?(L10n.tr("遥控器已断开,正在重连…")) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in self?.reconnect() } + scheduleReconnect() } func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { @@ -293,6 +317,10 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg onStreaming?(true) onStatus?(shortcutsSuspended ? L10n.tr("按键设置:正在识别语音键") : L10n.tr("正在传输遥控器麦克风")) case .audioStop(let reason): + // Closing settings or switching devices can acknowledge an idle + // MIC_CLOSE. Do not replace the ready status with an empty-stream + // error, or disturb a tail that is already draining. + if !streaming, !voiceGesture.isPressed, !voiceGesture.toggleActive { return } let frames = streamFrameCount let peak = streamPeak audioDiagnostics.notice("Voice stop reason=\(reason) packets=\(self.receivedAudioPackets) decodedFrames=\(frames) peak=\(peak) suspended=\(self.shortcutsSuspended) \(self.audio.diagnosticSummary, privacy: .public)") @@ -496,12 +524,21 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } func stop() { + stopped = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil resetVoice() + central?.stopScan() + peripheral?.delegate = nil + if let peripheral { central?.cancelPeripheralConnection(peripheral) } + central?.delegate = nil + peripheral = nil serviceTimer?.cancel() serviceTimer = nil } deinit { + reconnectWorkItem?.cancel() serviceTimer?.cancel() holdWorkItem?.cancel() reopenWorkItem?.cancel() diff --git a/Sources/RemoteBuddy/Bluetooth/RemoteDeviceDiscovery.swift b/Sources/RemoteBuddy/Bluetooth/RemoteDeviceDiscovery.swift new file mode 100644 index 0000000..0f6cf04 --- /dev/null +++ b/Sources/RemoteBuddy/Bluetooth/RemoteDeviceDiscovery.swift @@ -0,0 +1,82 @@ +import Foundation +import IOKit + +enum RemoteButtonProfile: String { + case abbey22, jieliHIDMouse001 + + var attribute: Int { self == .abbey22 ? 0x46 : 0x2b } + var reportFormat: RemoteReportFormat { self == .abbey22 ? .indexed : .consumer16 } + var title: String { self == .abbey22 ? "ABBEY / 22.2" : "Jieli hid_mouse / 0.0.1" } +} + +struct DiscoveredRemote: Equatable { + let name: String + let address: String + let peripheralID: UUID? + let manufacturer: String + let model: String + let firmware: String + let vendorID: Int + let productID: Int + + var buttonProfile: RemoteButtonProfile? { + guard vendorID == 0x18d1, productID == 0x9450 else { return nil } + var info = RemoteDeviceInformation() + info.update(uuid: "2A29", value: Data(manufacturer.utf8)) + info.update(uuid: "2A24", value: Data(model.utf8)) + info.update(uuid: "2A26", value: Data(firmware.utf8)) + if info.compatibilityProfile == .jieliHIDMouse001 { return .jieliHIDMouse001 } + // Google's assigned VID/PID identify the original manufacturer. The + // model and firmware must also match the verified report layout. + if info.model == "abbey", info.firmware == "22.2" { return .abbey22 } + return nil + } + + func automaticConfiguration() throws -> RemoteConfiguration { + guard let profile = buttonProfile else { throw RemoteSettingsError.unknownDevice } + return try RemoteConfiguration(address: address, attribute: profile.attribute, + reportFormat: profile.reportFormat, peripheralID: peripheralID, automatic: true) + } + + static func fromRegistry(_ properties: [String: Any]) -> DiscoveredRemote? { + let name = properties["Product"] as? String ?? "" + let vendor = properties["VendorID"] as? Int ?? 0 + let product = properties["ProductID"] as? Int ?? 0 + guard (properties["Transport"] as? String)?.hasPrefix("Bluetooth") == true, + (vendor == 0x18d1 && product == 0x9450) || name.localizedCaseInsensitiveContains("Chromecast Remote"), + let rawAddress = properties["DeviceAddress"] as? String, + let address = RemoteConfiguration.enteredAddress(rawAddress) else { return nil } + return DiscoveredRemote(name: name, address: address, + peripheralID: (properties["PhysicalDeviceUniqueID"] as? String).flatMap(UUID.init(uuidString:)), + manufacturer: properties["Manufacturer"] as? String ?? "", + model: properties["ModelNumber"] as? String ?? "", + firmware: properties["kBTFirmwareRevisionKey"] as? String ?? "", + vendorID: vendor, productID: product) + } +} + +enum RemoteDeviceDiscovery { + /// Read metadata without opening/seizing HID devices or requesting Input + /// Monitoring. PhysicalDeviceUniqueID links the MAC to CoreBluetooth's UUID. + static func connectedRemotes() -> [DiscoveredRemote] { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOHIDDevice"), &iterator) == KERN_SUCCESS else { return [] } + defer { IOObjectRelease(iterator) } + var found: [String: DiscoveredRemote] = [:] + while case let entry = IOIteratorNext(iterator), entry != 0 { + defer { IOObjectRelease(entry) } + var properties: Unmanaged? + guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS, + let properties = properties?.takeRetainedValue() as? [String: Any], + let remote = DiscoveredRemote.fromRegistry(properties) else { continue } + if found[remote.address] == nil || remote.peripheralID != nil { found[remote.address] = remote } + } + return found.values.sorted { $0.address < $1.address } + } + + static func selectedPeripheral(for configuration: RemoteConfiguration?, + devices: [DiscoveredRemote]) -> UUID? { + guard let configuration else { return nil } + return devices.first { $0.address == configuration.address }?.peripheralID ?? configuration.peripheralID + } +} diff --git a/Sources/RemoteBuddy/Input/HCISocketSource.swift b/Sources/RemoteBuddy/Input/HCISocketSource.swift index 3532cc4..b45b928 100644 --- a/Sources/RemoteBuddy/Input/HCISocketSource.swift +++ b/Sources/RemoteBuddy/Input/HCISocketSource.swift @@ -2,7 +2,8 @@ import Darwin import Foundation import OSLog -/// User-side client. The root-owned service exposes only addressed HID reports. +/// User-side client. The service exposes addressed reports and a bounded, +/// validated device-selection operation; it never accepts executable commands. final class HCISocketSource { var onReport: (([UInt8]) -> Void)? var onActive: ((Bool) -> Void)? @@ -17,6 +18,9 @@ final class HCISocketSource { private var expectedAddress: String? private var lastMessage = ProcessInfo.processInfo.systemUptime private var status = "" + private var supportsConfiguration = false + private var configurationRequest: (value: RemoteConfiguration, started: TimeInterval, + completion: (Result) -> Void)? private struct Message: Decodable { let type: String @@ -26,6 +30,8 @@ final class HCISocketSource { let bytes: [UInt8]? let ready: Bool? let remote_connected: Bool? + let protocol_version: Int? + let configuration: RemoteConfiguration? } func start() { @@ -35,7 +41,8 @@ final class HCISocketSource { guard let self else { return } if self.descriptor < 0 { self.connect() - } else if ProcessInfo.processInfo.systemUptime - self.lastMessage > 4 { + } else if ProcessInfo.processInfo.systemUptime - self.lastMessage > 4 || + self.configurationRequest.map({ ProcessInfo.processInfo.systemUptime - $0.started > 4 }) == true { self.disconnect() } } @@ -49,6 +56,24 @@ final class HCISocketSource { disconnect() } + func configure(_ value: RemoteConfiguration, completion: @escaping (Result) -> Void) { + guard descriptor >= 0 else { completion(.failure(RemoteSettingsError.unavailableService)); return } + guard supportsConfiguration else { completion(.failure(RemoteSettingsError.outdatedService)); return } + guard configurationRequest == nil else { completion(.failure(RemoteSettingsError.saveFailed)); return } + do { + let encoded = try JSONEncoder().encode(value) + guard var object = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { + throw RemoteSettingsError.saveFailed + } + object["type"] = "configure" + var request = try JSONSerialization.data(withJSONObject: object) + request.append(10) + configurationRequest = (value, ProcessInfo.processInfo.systemUptime, completion) + let sent = request.withUnsafeBytes { Darwin.send(descriptor, $0.baseAddress, $0.count, 0) } + if sent != request.count { disconnect() } + } catch { completion(.failure(error)) } + } + private func connect() { guard let configuredAddress = RemoteIdentity.configuredAddress else { setStatus(L10n.tr("按键:等待兼容辅助服务")) @@ -57,6 +82,8 @@ final class HCISocketSource { expectedAddress = configuredAddress let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return } + var noSignal: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) guard fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { Darwin.close(fd); return } var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) @@ -89,11 +116,15 @@ final class HCISocketSource { } private func disconnect() { + let request = configurationRequest + configurationRequest = nil + supportsConfiguration = false source?.cancel() source = nil descriptor = -1 pending.removeAll() setActive(false) + request?.completion(.failure(RemoteSettingsError.saveFailed)) } private func readAvailable() { @@ -118,11 +149,22 @@ final class HCISocketSource { return } lastMessage = ProcessInfo.processInfo.systemUptime + if message.type == "configured" || message.type == "configuration_error" { + let request = configurationRequest + configurationRequest = nil + if let request, message.type == "configured", message.configuration == request.value { + request.completion(.success(request.value)) + } else { + request?.completion(.failure(RemoteSettingsError.saveFailed)) + } + return + } let now = Date().timeIntervalSince1970 let age = now - message.received_at guard age >= -0.1, age < 0.25 else { onReset?(); continue } switch message.type { case "connected": + supportsConfiguration = (message.protocol_version ?? 1) >= 2 setStatus(L10n.tr("按键:正在等待遥控器数据")) case "heartbeat": if message.ready == true, message.remote_connected == true { diff --git a/Sources/RemoteBuddy/Input/RemoteButtonController.swift b/Sources/RemoteBuddy/Input/RemoteButtonController.swift index 1f300c5..32a4d8f 100644 --- a/Sources/RemoteBuddy/Input/RemoteButtonController.swift +++ b/Sources/RemoteBuddy/Input/RemoteButtonController.swift @@ -19,10 +19,12 @@ private func remoteButtonRemoved(context: UnsafeMutableRawPointer?, result: IORe private func remoteButtonReportReceived(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, type: IOHIDReportType, reportID: UInt32, report: UnsafeMutablePointer, reportLength: CFIndex, timeStamp: UInt64) { - guard let context, result == kIOReturnSuccess, type == kIOHIDReportTypeInput, + guard let context, let sender, result == kIOReturnSuccess, type == kIOHIDReportTypeInput, reportID == 1, reportLength > 0 else { return } - Unmanaged.fromOpaque(context).takeUnretainedValue() - .handleNativeReport(Array(UnsafeBufferPointer(start: report, count: reportLength)), timestamp: timeStamp) + let controller = Unmanaged.fromOpaque(context).takeUnretainedValue() + let device = Unmanaged.fromOpaque(sender).takeUnretainedValue() + guard controller.acceptsNativeDevice(device) else { return } + controller.handleNativeReport(Array(UnsafeBufferPointer(start: report, count: reportLength)), timestamp: timeStamp) } final class RemoteButtonController { @@ -44,6 +46,7 @@ final class RemoteButtonController { private var reading = false private var hciBridge = false private var connectedDevice: IOHIDDevice? + private var remoteConfiguration: RemoteConfiguration? private(set) var experimentalReading = false private var timebase: mach_timebase_info_data_t = { var info = mach_timebase_info_data_t() @@ -53,6 +56,7 @@ final class RemoteButtonController { func start() { stop() + remoteConfiguration = RemoteIdentity.configuration // The HCI bridge also needs repeat/release timers and error reporting, // even when macOS refuses access to the native HID device. actionSender.onError = { [weak self] in self?.onStatus?($0) } @@ -100,6 +104,7 @@ final class RemoteButtonController { } fileprivate func deviceMatched(_ device: IOHIDDevice) { + guard acceptsNativeDevice(device) else { return } logger.notice("Native HID report listener ready; single report source; hold watchdog 2s") connectedDevice = device apply(state.reset()) @@ -125,6 +130,12 @@ final class RemoteButtonController { setExperimentalReading(experimentalReading) } + fileprivate func acceptsNativeDevice(_ device: IOHIDDevice) -> Bool { + guard let configuration = remoteConfiguration, configuration.reportFormat == .indexed, + let raw = IOHIDDeviceGetProperty(device, "DeviceAddress" as CFString) as? String else { return false } + return RemoteConfiguration.enteredAddress(raw) == configuration.address + } + func setExperimentalReading(_ enabled: Bool) { experimentalReading = enabled reader.stop() diff --git a/Sources/RemoteBuddy/Settings/MappingSettingsWindow.swift b/Sources/RemoteBuddy/Settings/MappingSettingsWindow.swift index e3a2b83..20dc9aa 100644 --- a/Sources/RemoteBuddy/Settings/MappingSettingsWindow.swift +++ b/Sources/RemoteBuddy/Settings/MappingSettingsWindow.swift @@ -208,6 +208,7 @@ private final class MappingRowCell: NSTableCellView { final class MappingSettingsWindow: NSWindowController, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate { var onVisibilityChange: ((Bool) -> Void)? + var onRemoteSettings: (() -> Void)? private let store: MappingStore private var draft: MappingConfiguration private let table = NSTableView() @@ -240,6 +241,10 @@ final class MappingSettingsWindow: NSWindowController, NSWindowDelegate, NSTable private func build() { guard let content = window?.contentView else { return } content.addSubview(settingsLabel(L10n.tr("按键设置"), frame: NSRect(x: 24, y: 610, width: 832, height: 32), size: 24)) + let remote = NSButton(title: L10n.tr("遥控器设置…"), target: self, action: #selector(openRemoteSettings)) + remote.frame = NSRect(x: 706, y: 610, width: 150, height: 32) + remote.bezelStyle = .rounded + content.addSubview(remote) let intro = settingsLabel(L10n.tr("为每个按键选择动作。保存后立即生效,重启也会保留。"), frame: NSRect(x: 24, y: 579, width: 832, height: 24)) intro.textColor = .secondaryLabelColor content.addSubview(intro) @@ -364,6 +369,7 @@ final class MappingSettingsWindow: NSWindowController, NSWindowDelegate, NSTable } } @objc private func restoreDefaults() { draft = .defaults; table.reloadData(); showSelection(); errorLabel.stringValue = L10n.tr("已恢复初始映射,点击保存后应用。") } + @objc private func openRemoteSettings() { onRemoteSettings?() } @objc private func cancel() { window?.close() } @objc private func save() { window?.makeFirstResponder(nil) diff --git a/Sources/RemoteBuddy/Settings/RemoteConfiguration.swift b/Sources/RemoteBuddy/Settings/RemoteConfiguration.swift new file mode 100644 index 0000000..0a7a617 --- /dev/null +++ b/Sources/RemoteBuddy/Settings/RemoteConfiguration.swift @@ -0,0 +1,69 @@ +import Foundation + +enum RemoteReportFormat: String, Codable, CaseIterable { + case indexed, consumer16 +} + +struct RemoteConfiguration: Codable, Equatable { + let address: String + let attribute: Int + let reportFormat: RemoteReportFormat + let peripheralID: UUID? + let automatic: Bool + + enum CodingKeys: String, CodingKey { + case address, attribute + case reportFormat = "report_format" + case peripheralID = "peripheral_id" + case automatic = "auto_detect" + } + + init(address: String, attribute: Int, reportFormat: RemoteReportFormat, + peripheralID: UUID? = nil, automatic: Bool = false) throws { + guard let address = RemoteIdentity.canonicalAddress(address) else { + throw RemoteSettingsError.invalidAddress + } + guard (1...65535).contains(attribute) else { throw RemoteSettingsError.invalidAttribute } + self.address = address + self.attribute = attribute + self.reportFormat = reportFormat + self.peripheralID = peripheralID + self.automatic = automatic + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(address: values.decode(String.self, forKey: .address), + attribute: values.decode(Int.self, forKey: .attribute), + reportFormat: values.decodeIfPresent(RemoteReportFormat.self, forKey: .reportFormat) ?? .indexed, + peripheralID: values.decodeIfPresent(UUID.self, forKey: .peripheralID), + automatic: values.decodeIfPresent(Bool.self, forKey: .automatic) ?? false) + } + + static func enteredAddress(_ text: String) -> String? { + RemoteIdentity.canonicalAddress(text.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "-", with: ":")) + } + + static func enteredAttribute(_ text: String) -> Int? { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let number = value.hasPrefix("0x") ? Int(value.dropFirst(2), radix: 16) : Int(value), + (1...65535).contains(number) else { return nil } + return number + } +} + +enum RemoteSettingsError: LocalizedError { + case invalidAddress, invalidAttribute, unknownDevice, unavailableService, outdatedService, saveFailed + + var errorDescription: String? { + switch self { + case .invalidAddress: return L10n.tr("请填写有效的蓝牙地址,例如 AA:BB:CC:DD:EE:FF。") + case .invalidAttribute: return L10n.tr("按键句柄需为 1–65535,也可填写 0x 开头的十六进制值。") + case .unknownDevice: return L10n.tr("无法自动识别此遥控器。请先连接后刷新,或关闭自动识别并填写已确认的参数。") + case .unavailableService: return L10n.tr("辅助服务未连接,请稍后重试或重新运行安装器。") + case .outdatedService: return L10n.tr("辅助服务需要更新,请运行新版安装器后再更换遥控器。") + case .saveFailed: return L10n.tr("遥控器配置未能保存,请检查辅助服务后重试。") + } + } +} diff --git a/Sources/RemoteBuddy/Settings/RemoteSettingsWindow.swift b/Sources/RemoteBuddy/Settings/RemoteSettingsWindow.swift new file mode 100644 index 0000000..4df261a --- /dev/null +++ b/Sources/RemoteBuddy/Settings/RemoteSettingsWindow.swift @@ -0,0 +1,242 @@ +import AppKit + +final class RemoteSettingsWindow: NSWindowController, NSWindowDelegate, NSTextFieldDelegate { + var onVisibilityChange: ((Bool) -> Void)? + var onApply: ((RemoteConfiguration, @escaping (Result) -> Void) -> Void)? + private let discover: () -> [DiscoveredRemote] + private let load: () -> RemoteConfiguration? + private var devices: [DiscoveredRemote] = [] + private var showing = false + private var saving = false + private let picker = NSPopUpButton(frame: NSRect(x: 130, y: 462, width: 470, height: 30)) + private let address = NSTextField(frame: NSRect(x: 130, y: 410, width: 470, height: 28)) + private let automatic = NSButton(checkboxWithTitle: L10n.tr("自动识别兼容参数"), target: nil, action: nil) + private let format = NSPopUpButton(frame: NSRect(x: 130, y: 210, width: 220, height: 28)) + private let attribute = NSTextField(frame: NSRect(x: 484, y: 210, width: 116, height: 26)) + private let details = NSTextField(wrappingLabelWithString: "") + private let result = NSTextField(wrappingLabelWithString: "") + private let status = NSTextField(wrappingLabelWithString: "") + private let refreshButton = NSButton(title: L10n.tr("刷新设备"), target: nil, action: nil) + private let saveButton = NSButton(title: L10n.tr("保存并连接"), target: nil, action: nil) + private let protocolLabel = NSTextField(labelWithString: L10n.tr("报告格式")) + private let handleLabel = NSTextField(labelWithString: L10n.tr("按键句柄")) + + init(discover: @escaping () -> [DiscoveredRemote] = RemoteDeviceDiscovery.connectedRemotes, + load: @escaping () -> RemoteConfiguration? = { RemoteIdentity.configuration }) { + self.discover = discover + self.load = load + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 760, height: 610), + styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false) + window.title = L10n.tr("RemoteBuddy · 遥控器设置") + window.isReleasedWhenClosed = false + super.init(window: window) + window.delegate = self + window.contentView?.wantsLayer = true + window.contentView?.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + window.center() + build() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func label(_ text: String, _ frame: NSRect, size: CGFloat = 13, secondary: Bool = false) { + let view = NSTextField(wrappingLabelWithString: text) + view.frame = frame + view.font = .systemFont(ofSize: size) + if secondary { view.textColor = .secondaryLabelColor } + window?.contentView?.addSubview(view) + } + + private func build() { + guard let content = window?.contentView else { return } + label(L10n.tr("选择遥控器"), NSRect(x: 24, y: 550, width: 710, height: 34), size: 24) + label(L10n.tr("先在系统蓝牙设置中配对并连接遥控器,然后刷新列表。也可手动填写蓝牙地址。"), + NSRect(x: 24, y: 506, width: 710, height: 38), secondary: true) + label(L10n.tr("已连接设备"), NSRect(x: 24, y: 456, width: 100, height: 36)) + picker.target = self + picker.action = #selector(selectedDevice) + picker.setAccessibilityLabel(L10n.tr("已连接设备")) + content.addSubview(picker) + refreshButton.frame = NSRect(x: 620, y: 462, width: 116, height: 30) + refreshButton.bezelStyle = .rounded + refreshButton.target = self + refreshButton.action = #selector(refresh) + content.addSubview(refreshButton) + label(L10n.tr("蓝牙地址"), NSRect(x: 24, y: 402, width: 100, height: 36)) + address.placeholderString = "AA:BB:CC:DD:EE:FF" + address.setAccessibilityLabel(L10n.tr("蓝牙地址")) + address.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + address.delegate = self + content.addSubview(address) + details.frame = NSRect(x: 130, y: 314, width: 600, height: 74) + details.font = .systemFont(ofSize: 13) + details.textColor = .secondaryLabelColor + content.addSubview(details) + automatic.frame = NSRect(x: 24, y: 278, width: 700, height: 26) + automatic.target = self + automatic.action = #selector(modeChanged) + content.addSubview(automatic) + result.frame = NSRect(x: 130, y: 246, width: 590, height: 28) + result.font = .systemFont(ofSize: 13) + content.addSubview(result) + // Protocol details are editable only when automatic recognition is off. + protocolLabel.frame = NSRect(x: 24, y: 214, width: 100, height: 22) + handleLabel.frame = NSRect(x: 380, y: 214, width: 100, height: 22) + for view in [protocolLabel, handleLabel, format, attribute] { content.addSubview(view) } + format.addItems(withTitles: RemoteReportFormat.allCases.map(\.rawValue)) + format.setAccessibilityLabel(L10n.tr("报告格式")) + attribute.setAccessibilityLabel(L10n.tr("按键句柄")) + attribute.placeholderString = "0x46" + status.frame = NSRect(x: 24, y: 100, width: 710, height: 82) + status.font = .systemFont(ofSize: 13) + content.addSubview(status) + label(L10n.tr("保存后按键和语音一起切换,原有按键映射会保留。关闭设置窗口后恢复使用。"), + NSRect(x: 24, y: 57, width: 710, height: 34), size: 12, secondary: true) + let bluetooth = NSButton(title: L10n.tr("打开蓝牙设置…"), target: self, action: #selector(openBluetooth)) + bluetooth.frame = NSRect(x: 24, y: 16, width: 220, height: 32) + bluetooth.bezelStyle = .rounded + content.addSubview(bluetooth) + let close = NSButton(title: L10n.tr("关闭"), target: self, action: #selector(closeSettings)) + close.frame = NSRect(x: 502, y: 16, width: 90, height: 32) + close.bezelStyle = .rounded + close.keyEquivalent = "\u{1b}" + content.addSubview(close) + saveButton.frame = NSRect(x: 602, y: 16, width: 134, height: 32) + saveButton.bezelStyle = .rounded + saveButton.keyEquivalent = "\r" + saveButton.target = self + saveButton.action = #selector(save) + content.addSubview(saveButton) + } + + func present() { + if !showing { + showing = true + let current = load() + address.stringValue = current?.address ?? "" + attribute.stringValue = String(format: "0x%02X", current?.attribute ?? 0x46) + format.selectItem(at: RemoteReportFormat.allCases.firstIndex(of: current?.reportFormat ?? .indexed) ?? 0) + automatic.state = current.map { $0.automatic ? .on : .off } ?? .on + onVisibilityChange?(true) + refresh() + } + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @objc private func refresh() { + guard !saving else { return } + devices = discover() + picker.removeAllItems() + picker.addItem(withTitle: L10n.tr("选择设备,或在下方填写地址")) + for device in devices { picker.addItem(withTitle: "\(device.name) · \(device.address)") } + showIdentity() + } + + @objc private func selectedDevice() { + let index = picker.indexOfSelectedItem - 1 + guard devices.indices.contains(index) else { return } + address.stringValue = devices[index].address + automatic.state = .on + showIdentity() + } + + func controlTextDidChange(_ obj: Notification) { showIdentity() } + @objc private func modeChanged() { showIdentity() } + + private var selectedRemote: DiscoveredRemote? { + devices.first { $0.address == RemoteConfiguration.enteredAddress(address.stringValue) } + } + + private func showIdentity() { + let remote = selectedRemote + if let remote, let index = devices.firstIndex(of: remote) { picker.selectItem(at: index + 1) } + else { picker.selectItem(at: 0) } + let unknown = L10n.tr("未知") + if let remote { + details.stringValue = L10n.format("厂商:%@", remote.manufacturer.isEmpty ? unknown : remote.manufacturer) + "\n" + + L10n.format("型号:%@", remote.model.isEmpty ? unknown : remote.model) + "\n" + + L10n.format("固件:%@", remote.firmware.isEmpty ? unknown : remote.firmware) + } else { details.stringValue = L10n.tr("设备未连接。连接后刷新即可读取厂商、型号和固件。") } + let isAuto = automatic.state == .on + format.isHidden = isAuto + attribute.isHidden = isAuto + protocolLabel.isHidden = isAuto + handleLabel.isHidden = isAuto + if isAuto, let profile = remote?.buttonProfile { + attribute.stringValue = String(format: "0x%02X", profile.attribute) + format.selectItem(at: RemoteReportFormat.allCases.firstIndex(of: profile.reportFormat) ?? 0) + result.stringValue = L10n.format("已自动匹配:%@", profile.title) + setStatus(L10n.tr("兼容参数已识别,可以保存并连接。")) + } else if isAuto { + result.stringValue = L10n.tr("尚未匹配兼容参数") + setStatus(RemoteSettingsError.unknownDevice.localizedDescription, error: true) + } else { + result.stringValue = L10n.tr("手动配置 · 仅填写已确认的参数") + setStatus(L10n.tr("手动模式可用于更换同型号遥控器的地址。不同型号建议先尝试自动识别。")) + } + saveButton.isEnabled = !saving && RemoteConfiguration.enteredAddress(address.stringValue) != nil && (!isAuto || remote?.buttonProfile != nil) + } + + @objc private func save() { + guard !saving else { return } + window?.makeFirstResponder(nil) + do { + guard let normalized = RemoteConfiguration.enteredAddress(address.stringValue) else { throw RemoteSettingsError.invalidAddress } + let value: RemoteConfiguration + if automatic.state == .on { + guard let selectedRemote else { throw RemoteSettingsError.unknownDevice } + value = try selectedRemote.automaticConfiguration() + } else { + guard let handle = RemoteConfiguration.enteredAttribute(attribute.stringValue) else { throw RemoteSettingsError.invalidAttribute } + let current = load() + let identifier = selectedRemote?.peripheralID ?? (current?.address == normalized ? current?.peripheralID : nil) + value = try RemoteConfiguration(address: normalized, attribute: handle, + reportFormat: RemoteReportFormat.allCases[format.indexOfSelectedItem], peripheralID: identifier) + } + guard let onApply else { throw RemoteSettingsError.unavailableService } + setSaving(true) + setStatus(L10n.tr("正在保存并切换遥控器…")) + onApply(value) { [weak self] response in + guard let self else { return } + self.setSaving(false) + switch response { + case .success: self.setStatus(L10n.tr("已保存并开始连接。关闭设置窗口后即可使用。")) + case .failure(let error): self.setStatus(error.localizedDescription, error: true) + } + } + } catch { setStatus(error.localizedDescription, error: true) } + } + + private func setSaving(_ value: Bool) { + saving = value + let controls: [NSControl] = [picker, address, automatic, format, attribute, refreshButton, saveButton] + for control in controls { control.isEnabled = !value } + window?.standardWindowButton(.closeButton)?.isEnabled = !value + } + + private func setStatus(_ text: String, error: Bool = false) { + status.stringValue = text + status.textColor = error ? .systemRed : .secondaryLabelColor + } + + @objc private func openBluetooth() { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.BluetoothSettings")!) + } + @objc private func closeSettings() { if !saving { window?.close() } } + func windowShouldClose(_ sender: NSWindow) -> Bool { !saving } + func windowWillClose(_ notification: Notification) { showing = false; onVisibilityChange?(false) } + + func renderPreview(to url: URL) throws { + present() + window?.makeFirstResponder(nil) + window?.contentView?.layoutSubtreeIfNeeded() + window?.displayIfNeeded() + guard let content = window?.contentView, + let image = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return } + content.cacheDisplay(in: content.bounds, to: image) + try image.representation(using: .png, properties: [:])?.write(to: url) + window?.close() + } +} diff --git a/Sources/RemoteBuddy/Support/RemoteIdentity.swift b/Sources/RemoteBuddy/Support/RemoteIdentity.swift index 43d8f5b..561b0b8 100644 --- a/Sources/RemoteBuddy/Support/RemoteIdentity.swift +++ b/Sources/RemoteBuddy/Support/RemoteIdentity.swift @@ -10,14 +10,14 @@ enum RemoteIdentity { return value.uppercased() } - static var configuredAddress: String? { + static var configuration: RemoteConfiguration? { guard let attributes = try? FileManager.default.attributesOfItem(atPath: configurationURL.path), (attributes[.ownerAccountID] as? NSNumber)?.uint32Value == 0, let permissions = attributes[.posixPermissions] as? NSNumber, permissions.intValue & 0o022 == 0, - let data = try? Data(contentsOf: configurationURL), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let address = object["address"] as? String else { return nil } - return canonicalAddress(address) + let data = try? Data(contentsOf: configurationURL) else { return nil } + return try? JSONDecoder().decode(RemoteConfiguration.self, from: data) } + + static var configuredAddress: String? { configuration?.address } } diff --git a/Tests/Python/test_remote_configuration.py b/Tests/Python/test_remote_configuration.py new file mode 100644 index 0000000..a2dd543 --- /dev/null +++ b/Tests/Python/test_remote_configuration.py @@ -0,0 +1,141 @@ +import importlib.util +import json +import os +import pathlib +import socket +import stat +import tempfile +import threading +import types +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("helper", ROOT / "Helpers/hci_helper.py") +helper = importlib.util.module_from_spec(spec) +spec.loader.exec_module(helper) + + +class RemoteConfigurationTests(unittest.TestCase): + def request(self, **changes): + return {"type": "configure", "address": "aa:bb:cc:dd:ee:ff", "attribute": 43, + "report_format": "consumer16", "auto_detect": True, + "peripheral_id": "11111111-2222-3333-4444-555555555555", **changes} + + def config(self): + return dict(uid=501, gid=20, address="11:22:33:44:55:66", attribute=70, + report_format="indexed", socket="/var/run/test.sock", packetlogger="/fixed/tool", + peripheral_id="AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") + + def test_selection_is_canonical_and_cannot_change_privileged_fields(self): + result = helper.remote_selection(self.request()) + self.assertEqual(result["address"], "AA:BB:CC:DD:EE:FF") + self.assertNotIn("type", result) + for changes in [{"uid": 0}, {"gid": 0}, {"socket": "/tmp/other"}, {"packetlogger": "/tmp/command"}, + {"path": "/tmp/config"}, {"type": "execute"}, {"attribute": True}, {"attribute": 0}, + {"attribute": 65536}, {"attribute": "43"}, {"address": "AA:BB:CC:DD:EE:FF\n"}, + {"address": "$(command)"}, {"report_format": "other"}, {"auto_detect": 1}, + {"peripheral_id": "bad"}, {"peripheral_id": 7}]: + with self.subTest(changes=changes), self.assertRaises(ValueError): + helper.remote_selection(self.request(**changes)) + for request in [[], None, {}, "configure"]: + with self.assertRaises(ValueError): + helper.remote_selection(request) + + def test_atomic_save_preserves_service_fields_and_clears_old_voice_identity(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "config.json" + original = self.config() + path.write_text(json.dumps(original)) + selection = helper.remote_selection(self.request(peripheral_id=None)) + with mock.patch.object(helper, "read_configuration", return_value=original): + updated = helper.save_selection(path, original, selection) + self.assertEqual(json.loads(path.read_text()), updated) + for field in ["uid", "gid", "socket", "packetlogger"]: + self.assertEqual(updated[field], original[field]) + self.assertNotIn("peripheral_id", updated) + self.assertEqual(updated["attribute"], 43) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o644) + self.assertEqual(list(path.parent.iterdir()), [path]) + + def test_failed_or_stale_save_leaves_original_configuration_intact(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "config.json" + original = self.config() + wire = json.dumps(original) + path.write_text(wire) + selection = helper.remote_selection(self.request()) + with mock.patch.object(helper, "read_configuration", return_value=original), \ + mock.patch.object(helper.os, "replace", side_effect=OSError("failed")): + with self.assertRaises(OSError): + helper.save_selection(path, original, selection) + self.assertEqual(path.read_text(), wire) + self.assertEqual(list(path.parent.iterdir()), [path]) + with mock.patch.object(helper, "read_configuration", return_value={**original, "attribute": 99}): + with self.assertRaises(ValueError): + helper.save_selection(path, original, selection) + self.assertEqual(path.read_text(), wire) + + def test_config_rejects_links_non_root_owner_and_group_writable_files(self): + for mode, owner in [(stat.S_IFLNK | 0o777, 0), (stat.S_IFREG | 0o644, 501), + (stat.S_IFREG | 0o664, 0), (stat.S_IFDIR | 0o755, 0)]: + path = mock.Mock() + path.lstat.return_value = types.SimpleNamespace(st_mode=mode, st_uid=owner) + with self.assertRaises(ValueError): + helper.read_configuration(path) + path.read_text.assert_not_called() + + def test_socket_protocol_applies_one_selection_and_closes_capture(self): + class Capture: + def __init__(self, tool): + self.fd, self.writer = os.pipe() + self.count = 1 + self.process = types.SimpleNamespace(poll=lambda: None) + + def close(self): + os.close(self.fd) + os.close(self.writer) + + for wire, expected_type in [ + (json.dumps(self.request()).encode() + b"\n", "configured"), + (json.dumps(self.request(uid=0)).encode() + b"\n", "configuration_error"), + (b"x" * 4097, "configuration_error"), + (b'{}\n{}\n', "configuration_error"), + ]: + with self.subTest(expected=expected_type), tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "config.json" + original = self.config() + path.write_text(json.dumps(original)) + server, client = socket.socketpair() + client.settimeout(3) + failures = [] + + def run(): + try: + helper.session(server, original, path) + except Exception as error: + failures.append(error) + finally: + server.close() + + with mock.patch.object(helper, "Capture", Capture), \ + mock.patch.object(helper, "read_configuration", side_effect=lambda p: json.loads(p.read_text())): + thread = threading.Thread(target=run, daemon=True) + thread.start() + stream = client.makefile("rb") + self.assertEqual(json.loads(stream.readline())["protocol_version"], 2) + client.sendall(wire) + messages = [json.loads(line) for line in stream] + thread.join(timeout=3) + stream.close() + client.close() + self.assertFalse(thread.is_alive()) + self.assertEqual(failures, []) + self.assertEqual(messages[-1]["type"], expected_type) + updated = json.loads(path.read_text()) + self.assertEqual(updated["address"], "AA:BB:CC:DD:EE:FF" if expected_type == "configured" else original["address"]) + self.assertEqual(updated["uid"], 501) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/RemoteBuddyTests/RemoteSelectionTests.swift b/Tests/RemoteBuddyTests/RemoteSelectionTests.swift new file mode 100644 index 0000000..837bdad --- /dev/null +++ b/Tests/RemoteBuddyTests/RemoteSelectionTests.swift @@ -0,0 +1,78 @@ +import Foundation +import XCTest +@testable import RemoteBuddy + +final class RemoteSelectionTests: XCTestCase { + private func device(_ changes: [String: Any] = [:]) -> DiscoveredRemote? { + let defaults: [String: Any] = ["Product": "Chromecast Remote", "Transport": "Bluetooth Low Energy", + "DeviceAddress": "aa-bb-cc-dd-ee-ff", "PhysicalDeviceUniqueID": "11111111-2222-3333-4444-555555555555", + "Manufacturer": "zhuhai_jieli", "ModelNumber": "hid_mouse", "kBTFirmwareRevisionKey": "0.0.1", + "VendorID": 0x18d1, "ProductID": 0x9450] + return DiscoveredRemote.fromRegistry(defaults.merging(changes) { _, new in new }) + } + + func testKnownDevicesChooseTheirOwnReportFormatAndHandle() throws { + let jieli = try XCTUnwrap(device()) + let configuration = try jieli.automaticConfiguration() + XCTAssertEqual(configuration.address, "AA:BB:CC:DD:EE:FF") + XCTAssertEqual(configuration.attribute, 0x2b) + XCTAssertEqual(configuration.reportFormat, .consumer16) + XCTAssertTrue(configuration.automatic) + XCTAssertEqual(configuration.peripheralID, jieli.peripheralID) + let abbey = try XCTUnwrap(device(["Manufacturer": "Google", "ModelNumber": "ABBEY", "kBTFirmwareRevisionKey": "22.2"])) + XCTAssertEqual(try abbey.automaticConfiguration().attribute, 0x46) + XCTAssertEqual(try abbey.automaticConfiguration().reportFormat, .indexed) + } + + func testUnknownDevicesNeverGuessAReportLayout() throws { + for changes: [String: Any] in [["Manufacturer": "other"], ["ModelNumber": "unknown"], + ["kBTFirmwareRevisionKey": "0.0.2"], ["kBTFirmwareRevisionKey": ""], + ["VendorID": 1], ["ProductID": 1]] { + let remote = try XCTUnwrap(device(changes)) + XCTAssertNil(remote.buttonProfile) + XCTAssertThrowsError(try remote.automaticConfiguration()) + } + XCTAssertNil(device(["Transport": "USB"])) + XCTAssertNil(device(["DeviceAddress": "bad address"])) + } + + func testSameNameDevicesAreSelectedByAddressNotEnumerationOrder() throws { + let first = try XCTUnwrap(device()) + let second = try XCTUnwrap(device(["DeviceAddress": "11:22:33:44:55:66", + "PhysicalDeviceUniqueID": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"])) + let old = try RemoteConfiguration(address: second.address, attribute: 0x46, reportFormat: .indexed) + XCTAssertEqual(RemoteDeviceDiscovery.selectedPeripheral(for: old, devices: [first, second]), second.peripheralID) + XCTAssertNil(RemoteDeviceDiscovery.selectedPeripheral(for: old, devices: [first])) + XCTAssertNil(RemoteDeviceDiscovery.selectedPeripheral(for: nil, devices: [first])) + let saved = try second.automaticConfiguration() + XCTAssertEqual(RemoteDeviceDiscovery.selectedPeripheral(for: saved, devices: []), second.peripheralID) + let rePaired = try XCTUnwrap(device(["DeviceAddress": second.address, + "PhysicalDeviceUniqueID": "BBBBBBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF"])) + XCTAssertEqual(RemoteDeviceDiscovery.selectedPeripheral(for: saved, devices: [rePaired]), rePaired.peripheralID) + } + + func testLegacyAndNewConfigurationsRoundTrip() throws { + let old = Data(#"{"address":"AA:BB:CC:DD:EE:FF","attribute":70,"uid":501,"packetlogger":"/fixed/tool"}"#.utf8) + let decoded = try JSONDecoder().decode(RemoteConfiguration.self, from: old) + XCTAssertEqual(decoded.reportFormat, .indexed) + XCTAssertFalse(decoded.automatic) + XCTAssertNil(decoded.peripheralID) + let current = try XCTUnwrap(device()).automaticConfiguration() + XCTAssertEqual(try JSONDecoder().decode(RemoteConfiguration.self, from: JSONEncoder().encode(current)), current) + for invalid in [#"{"address":"invalid","attribute":70}"#, + #"{"address":"AA:BB:CC:DD:EE:FF","attribute":0}"#, + #"{"address":"AA:BB:CC:DD:EE:FF","attribute":70,"report_format":"other"}"#] { + XCTAssertThrowsError(try JSONDecoder().decode(RemoteConfiguration.self, from: Data(invalid.utf8))) + } + } + + func testManualEntryValidation() { + XCTAssertEqual(RemoteConfiguration.enteredAddress(" aa-bb-cc-dd-ee-ff \n"), "AA:BB:CC:DD:EE:FF") + XCTAssertNil(RemoteConfiguration.enteredAddress("AA:BB:CC:DD:EE:FF; command")) + XCTAssertEqual(RemoteConfiguration.enteredAttribute("0x2b"), 43) + XCTAssertEqual(RemoteConfiguration.enteredAttribute("70"), 70) + for value in ["", "0", "65536", "0x", "-1", "0xFFFFF"] { + XCTAssertNil(RemoteConfiguration.enteredAttribute(value)) + } + } +} diff --git a/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift b/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift index f0fbdcf..0a60632 100644 --- a/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift +++ b/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift @@ -219,6 +219,19 @@ final class VoiceRecoveryTests: XCTestCase { ble.stop() } + func testIdleStopAcknowledgementDoesNotOverwriteSettingsStatus() { + let ble = controller(AudioOutput(), Keyboard()) + var messages: [String] = [] + ble.onStatus = { messages.append($0) } + ble.setConfiguring(true) + ble.setConfiguring(false) + let expected = messages + ble.handle(.audioStop(reason: 0)) + ble.handle(.audioStop(reason: 2)) + XCTAssertEqual(messages, expected) + ble.stop() + } + func testRemoteHoldReleaseDoesNotCloseAnAlreadyStoppedStream() { let audio = AudioOutput() let keyboard = Keyboard() diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7fa5b0f..e6d14e1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -71,6 +71,11 @@ Internal `local.codex.RemoteMic` IDs and the `RemoteMic` config directory are ke to preserve existing installations. User home/UID/GID/remote address are generated at install time. The user-side client reads the remote identity from the root-owned helper config. It never learns a remote identity from arbitrary socket messages. +Protocol v2 permits the configured user to update only validated device-selection +fields. The app reads back the root-owned file before switching both button and +voice connections. User mappings and privileged service paths are not editable +through that operation. Test format validation, atomic-write failure, peer +ownership, old helpers, and same-name device selection when changing this path. PacketLogger records begin with cached metadata timestamps as well as live packets. Do not calibrate live events from the first record. The FIFO is live-only; IPC diff --git a/docs/DEVELOPMENT.zh-CN.md b/docs/DEVELOPMENT.zh-CN.md index 3ac9312..24e8cd6 100644 --- a/docs/DEVELOPMENT.zh-CN.md +++ b/docs/DEVELOPMENT.zh-CN.md @@ -73,6 +73,9 @@ PacketLogger 是当前按键兼容通道的运行依赖,不只是日志查看 内部 `local.codex.RemoteMic` 标识及 `RemoteMic` 设置目录保留以兼容已有安装。 用户目录、UID、GID 和遥控器地址在安装时生成。应用从 root 所有的配置文件读取预期设备地址, 不会信任任意 socket 消息提供的新设备身份。 +协议 v2 允许配置用户更新经过验证的设备选择字段,应用回读 root 配置后才同步切换按键和语音。 +该操作不能修改个人映射或系统服务路径。修改此流程时,应检查格式验证、原子写入失败、 +连接方身份、旧版辅助服务兼容,以及同名设备选择。 PacketLogger 的起始记录同时可能含缓存的元数据时间和实时包时间。 不要用第一条记录去统一校准所有实时事件。FIFO 只读取实时数据;IPC 带当前接收时间,应用拒绝过期输入。 diff --git a/docs/INSTALL.en.md b/docs/INSTALL.en.md index e7b0fa6..bb43092 100644 --- a/docs/INSTALL.en.md +++ b/docs/INSTALL.en.md @@ -86,6 +86,19 @@ Replace the sample address. The optional third argument is the ATT report handle ### Replacing a remote +After updating the app and helper, use **Remote Settings…** in the menu bar +(also available from Button Settings): + +1. Pair and connect the new remote in macOS Bluetooth settings. +2. Click **Refresh** and select the remote. Addresses distinguish devices with the same name. +3. Leave **Detect compatibility settings automatically** enabled and check the manufacturer, model, firmware, and match. +4. Click **Save & Connect**, close settings, and test buttons and voice. No reinstall or manual restart is needed, and mappings are preserved. + +The list shows connected Chromecast remotes; pairing remains in macOS Bluetooth +settings. Automatic detection supports the verified combinations below. It does +not guess from names or probe unknown handles. Refresh and identify again after +a firmware update. + Identical Bluetooth names do not guarantee identical firmware or report formats. ABBEY / 22.2 uses `0x46` and `indexed`. The supported `zhuhai_jieli` / `hid_mouse` / 0.0.1 variant uses `0x2b` and `consumer16`; the helper translates @@ -96,13 +109,17 @@ With a release containing this support, select the format using the fourth argum ./Install.command "$HOME/Applications/PacketLogger.app" 'AA:BB:CC:DD:EE:FF' 0x2b consumer16 ``` -For an existing installation, device settings are in -`/Library/Application Support/RemoteMic/hci-config.json`. Back it up, update -`address`, and set `attribute` (`43` in JSON for `0x2b`) and `report_format` to -match the device. Missing `report_format` defaults to `indexed`. Keep the file -root-owned and writable only by root, restart the system helper and RemoteBuddy, -then reconnect the remote. Changing only the address cannot fix a different -report format; diagnose other firmware before selecting its parameters. +You can also enter an address manually, using colons or hyphens. Keep verified +settings when replacing a remote with the same model. For an unknown model, +disable automatic detection and enter a verified format and handle; the app will +not automatically apply the old protocol. If the helper is too old, run the new +installer once. Replacing only the `.app` does not upgrade the helper. + +Settings remain in `/Library/Application Support/RemoteMic/hci-config.json`. +The helper atomically updates the root-owned, mode-0644 file. Device fields are +`address`, `attribute`, `report_format`, `peripheral_id` (voice device identity), +and `auto_detect`. Legacy configurations need no migration; a missing format +still defaults to `indexed`. The voice release-delay workaround is selected automatically from the remote's manufacturer, model, and firmware. All three must match the tested Jieli variant; diff --git a/docs/INSTALL.zh-CN.md b/docs/INSTALL.zh-CN.md index f818097..8030cb8 100644 --- a/docs/INSTALL.zh-CN.md +++ b/docs/INSTALL.zh-CN.md @@ -75,6 +75,16 @@ PacketLogger 提供按键兼容通道需要的实时 HCI 数据,不是可随 ### 更换遥控器 +更新应用和辅助服务后,直接使用菜单栏 **遥控器设置…**(按键设置窗口中也有入口): + +1. 在系统蓝牙设置中配对并连接新遥控器。 +2. 点击 **刷新设备**,选择列表中的遥控器;列表会同时显示地址,区分同名设备。 +3. 保持 **自动识别兼容参数** 开启,确认显示的厂商、型号、固件和匹配结果。 +4. 点击 **保存并连接**,关闭设置窗口后测试按键和语音。无需重新安装或手动重启,个人映射会保留。 + +列表读取已连接的 Chromecast 遥控器,不替代系统蓝牙配对。自动识别目前支持下列已验证的组合, +不是根据蓝牙名称猜测协议,也不扫描未知句柄。固件更新后请刷新并重新识别。 + 设备名称相同不代表固件和按键格式相同。原版 ABBEY / 22.2 使用 `0x46` 和 `indexed`;已适配的 `zhuhai_jieli` / `hid_mouse` / 0.0.1 使用 `0x2b` 和 `consumer16`。后者会转换为原有的按键编号,保留个人映射。 @@ -85,11 +95,13 @@ PacketLogger 提供按键兼容通道需要的实时 HCI 数据,不是可随 ./Install.command "$HOME/Applications/PacketLogger.app" 'AA:BB:CC:DD:EE:FF' 0x2b consumer16 ``` -已安装用户的设备参数位于 `/Library/Application Support/RemoteMic/hci-config.json`: -更新 `address`,并按实际固件设置 `attribute`(JSON 中 `0x2b` 写作 `43`)和 -`report_format`。旧配置没有 `report_format` 时默认使用 `indexed`。修改前备份配置, -修改后重启系统辅助服务和 RemoteBuddy,并重新连接遥控器。配置需保持 root 所有且仅 root 可写。 -只更新地址不能解决固件格式变化;其他固件需要先核对实际报告。 +也可在设置中手动输入地址,支持冒号或连字符分隔。同型号更换地址时可沿用已确认的参数; +未知型号需关闭自动识别,手动填写已核对的格式和句柄。应用不会为未知设备自动套用旧协议。 +如果提示辅助服务版本过旧,请运行新版安装器更新一次辅助服务,仅替换 `.app` 不够。 + +配置仍保存于 `/Library/Application Support/RemoteMic/hci-config.json`,由辅助服务原子更新, +保持 root 所有和 0644 权限。设备字段包括 `address`、`attribute`、`report_format`、 +`peripheral_id`(语音设备标识)和 `auto_detect`。旧配置无需迁移,缺失格式时默认 `indexed`。 语音松键延迟补偿会自动读取厂商、型号和固件,三者全部匹配上述已验证的 Jieli 设备才启用; 其他设备保留原来的手势计时逻辑。详见[协议兼容说明](PROTOCOL.zh-CN.md)。 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1b31466..51b05b2 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -36,8 +36,21 @@ Other manufacturers, models, firmware versions, or incomplete device information keep the standard elapsed-time gesture detection. Matching ignores case and surrounding string padding but does not use prefixes or the Bluetooth display name. Identity is cleared when connecting to a device. This automatic voice -profile does not change the helper's explicitly configured HID handle or format; -an unknown device still requires protocol verification before adding support. +profile handles voice timing independently of the selected button protocol. +Remote Settings reads connected HID metadata through IOKit, including +`DeviceAddress`, `PhysicalDeviceUniqueID`, manufacturer, model, firmware, and +VID/PID. The physical UUID selects the matching CoreBluetooth peripheral, never +the first device with the same name. Verified VID/PID plus ABBEY / 22.2 selects +`0x46` / `indexed`; the full Jieli identity plus VID/PID selects `0x2b` / +`consumer16`. Unknown identities require verified manual settings. + +Helper protocol v2 accepts one bounded device-selection JSON message from its +configured peer UID. It validates a strict field allowlist, address, format, +handle, optional peripheral UUID, and detection flag. It cannot change UID/GID, +socket or executable paths. The root-owned config is atomically replaced, then +the client reads it back and restarts both input paths. Capture restarts with +fresh connection metadata. Malformed requests and failed writes preserve the +old file. Older helpers are detected before sending a configuration request. A remote-initiated voice stop ends the local gesture without sending another MIC_CLOSE. Stop acknowledgements reset local state without further commands, diff --git a/docs/PROTOCOL.zh-CN.md b/docs/PROTOCOL.zh-CN.md index 90dd1b8..4049cae 100644 --- a/docs/PROTOCOL.zh-CN.md +++ b/docs/PROTOCOL.zh-CN.md @@ -27,8 +27,16 @@ HCI 捕获中存在完整的按下和松开事件,说明丢失位于蓝牙接 其他厂商、型号、固件版本以及设备信息缺失的情况,继续使用原来的按下时长判断。 匹配时忽略大小写及字符串首尾填充,但不做前缀匹配,也不依赖蓝牙显示名称。 -连接设备时会清空旧身份信息。这项自动语音适配不会改动辅助服务显式配置的按键句柄和格式; -未知设备仍需先核对协议再添加支持。 +连接设备时会清空旧身份信息。语音补偿独立于按键协议选择。 +遥控器设置通过 IOKit 读取已连接 HID 设备的 `DeviceAddress`、`PhysicalDeviceUniqueID`、 +厂商、型号、固件及 VID/PID。物理 UUID 用于选中对应的 CoreBluetooth 语音设备,不会选取第一个同名设备。 +已验证的 VID/PID 加 ABBEY / 22.2 匹配 `0x46` / `indexed`;完整 Jieli 身份加 VID/PID +匹配 `0x2b` / `consumer16`。未知身份只允许使用已经核对的手动参数。 + +辅助服务协议 v2 只接受配置用户 UID 发来的一条限长设备选择 JSON,严格验证字段白名单、 +地址、格式、句柄、可选语音 UUID 和自动识别标志,不允许修改 UID/GID、socket 或可执行文件路径。 +配置保持 root 所有并原子替换;应用回读验证后重连按键和语音,重新开始捕获以取得新的连接元数据。 +非法请求和写入失败保留原文件。应用会先识别旧版辅助服务,不向其发送更新请求。 语音停止通知不能触发重复的关麦命令:遥控器主动松键时只结束本地手势, 停止确认只复位本地状态,避免在每次 MIC_CLOSE 都会产生 AUDIO_STOP 的固件上形成循环。