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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 87 additions & 10 deletions Helpers/hci_helper.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +10,7 @@
import json
import os
import pathlib
import re
import selectors
import signal
import socket
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Installer/configure.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 6 additions & 1 deletion Installer/install-system.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
6 changes: 6 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ GitHub 的源码 ZIP 不含已构建应用,需要先按下文构建。
连发目标间隔为 65 毫秒,各键独立计时;松开即停止。按住超过两秒会强制释放,松开再按可继续。
语音键保持短按切换收音、按住说话的行为。

更换设备可打开菜单栏 **遥控器设置…**:刷新并选择已连接的遥控器,或手动输入蓝牙地址。
已验证的 ABBEY / 22.2 与 Jieli `hid_mouse` / 0.0.1 会自动匹配按键格式和句柄,
保存后按键与语音一起切换,保留个人映射。未知型号保留手动设置;首次使用此功能需升级辅助服务。
详见[更换遥控器](docs/INSTALL.zh-CN.md#更换遥控器)。

## 源码与构建

```sh
Expand Down
34 changes: 34 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
34 changes: 34 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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 · 遥控器设置";
"选择遥控器" = "选择遥控器";
"先在系统蓝牙设置中配对并连接遥控器,然后刷新列表。也可手动填写蓝牙地址。" = "先在系统蓝牙设置中配对并连接遥控器,然后刷新列表。也可手动填写蓝牙地址。";
"已连接设备" = "已连接设备";
"蓝牙地址" = "蓝牙地址";
"保存后按键和语音一起切换,原有按键映射会保留。关闭设置窗口后恢复使用。" = "保存后按键和语音一起切换,原有按键映射会保留。关闭设置窗口后恢复使用。";
"打开蓝牙设置…" = "打开蓝牙设置…";
"关闭" = "关闭";
"选择设备,或在下方填写地址" = "选择设备,或在下方填写地址";
"未知" = "未知";
"厂商:%@" = "厂商:%@";
"型号:%@" = "型号:%@";
"固件:%@" = "固件:%@";
"设备未连接。连接后刷新即可读取厂商、型号和固件。" = "设备未连接。连接后刷新即可读取厂商、型号和固件。";
"已自动匹配:%@" = "已自动匹配:%@";
"兼容参数已识别,可以保存并连接。" = "兼容参数已识别,可以保存并连接。";
"尚未匹配兼容参数" = "尚未匹配兼容参数";
"手动配置 · 仅填写已确认的参数" = "手动配置 · 仅填写已确认的参数";
"手动模式可用于更换同型号遥控器的地址。不同型号建议先尝试自动识别。" = "手动模式可用于更换同型号遥控器的地址。不同型号建议先尝试自动识别。";
"正在保存并切换遥控器…" = "正在保存并切换遥控器…";
"已保存并开始连接。关闭设置窗口后即可使用。" = "已保存并开始连接。关闭设置窗口后即可使用。";
"遥控器设置…" = "遥控器设置…";
Loading
Loading