vibe-mote v1.1.0 — Google TV 遥控器 → Windows 语音键 + 快捷键面板

This commit is contained in:
Tilkmilk committed 2026-09-25 19:54:04 +08:00
commit fb30e47969
23 files changed
+14440

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# 换行符规则 —— 这个项目对启动脚本的换行有硬要求,必须显式钉住。
#
# 为什么:`启动遥控器.vbs` 由 wscript 解析,要求 **纯 ASCII + CRLF**。
# 如果谁在 Linux/macOS 上 clone 或从 GitHub 下 zip,拿到 LF 版本,
# 轻则报「未结束的字符串常量」,重则双击毫无反应(见 AGENTS.md 第 8 节)。
#
# text=auto:其余文件按文本处理,仓库内存 LF、检出时按平台。
* text=auto
# 启动脚本强制 CRLF(跟平台无关)
启动遥控器.vbs text eol=crlf
# 二进制,别动
*.png binary
*.ico binary
*.zip binary
*.whl binary
+40
View File
@@ -0,0 +1,40 @@
# 运行时生成、或跟本机绑定的东西 —— 不要提交
.venv/
config.json
# 写 config.json 时的临时文件。⚠ 必须显式写上:它**带着用户完整的配置**
# (蓝牙地址 + 整套键位),实测真的在程序目录里被留下过,而原来没被忽略,
# `git status` 会把它当新文件(手一抖就提交上去了)。
config.json.*.tmp
*.tmp
python.txt
__pycache__/
*.pyc
*.log
_uninstall_result.json
qwen_optout
cable_autotried
_ready.txt
_task.xml
# 离线 wheel 包(自己按需下载,别塞进仓库)
wheels/
# 离线全量包的产物(内置 Python 运行时,解压后 ~200MB,绝对不能进仓库)
runtime/
../vibe-mote-offline-v1.zip
../vibe-mote-offline-voice-v1.zip
_offline_build/
# 打包产物
../vibe-mote-v1.zip
# 开发/测试脚本、临时文件(本地保留,不进公开仓库)
# 用通配 `_*` 而不是逐个后缀:临时产物什么形式都有(脚本、目录、json、txt…),
# 挨个列后缀迟早漏一个 —— 仓库里没有任何以 `_` 开头的正经文件,所以这条是安全的。
_*
# 本地开发笔记(不进公开仓库)
DEVELOPMENT-NOTES.md
# VB-CABLE 安装器:第三方二进制,不随仓库分发(用户自行从 vb-audio.com 下载)
vbcable/
+170
View File
@@ -0,0 +1,170 @@
# AGENTS.en.md — Installation manual for AI assistants
[中文](AGENTS.md) | **English**
> This file is written for an **AI assistant**. The user will send you this repo's URL and ask you to help install it.
> Follow the steps given here, and **do not mix in generic tutorials from the web** — this project deliberately avoids
> the whole "install a driver / enable test signing" approach, and doing that will actually break things.
## 0. What this is
A small Windows tool that turns a Google TV / Chromecast Bluetooth remote into a "push-to-talk + hotkey" device.
Purely user mode: **no drivers, nothing written to system directories, no resident administrator**. ⚠ But **key mapping** (D-pad/OK/volume) needs **one** elevation: Frida has to inject into the Bluetooth HID driver host and register a temporary service; after one "One-click optimization" a highest-privilege scheduled task covers it and it never prompts again. **Voice needs no administrator at all** — never describe both features with a single blanket "no admin needed" claim.
The program starts a web console on `127.0.0.1:8787` (localhost only).
**Confirm three things with the user first** (missing any one of them means it cannot be installed):
1. Is the system **Windows 10/11 x64**?
2. Is the remote a **Google TV / Chromecast Bluetooth remote** (other remotes use different report formats)?
3. Which package will be used?
- `vibe-mote-offline-v1.zip` (~61MB, **bundles Python, recommended**) → nothing to install
- `vibe-mote-v1.zip` (~900KB) → requires **Python 3.10+**; have the user install it from
[python.org](https://www.python.org/downloads/), **be sure to check `Add python.exe to PATH`**,
and do not use the Microsoft Store version
## 1. Installation
**Offline bundle**: unzip → double-click `启动遥控器.vbs` → done (no other steps).
**Light package** (when the target machine has Python):
```powershell
python -m venv .venv
.venv\Scripts\python.exe -m pip install --upgrade pip
.venv\Scripts\python.exe -m pip install -r requirements.txt
# Optional: the D-pad/OK/volume need frida (~130MB) — ask the user first
.venv\Scripts\python.exe -m pip install -r requirements-keys.txt
```
If dependency installation hangs or times out (common in China), switch sources in this order and stop at the first success:
```powershell
$mirrors = @(
"https://mirrors.cloud.tencent.com/pypi/simple",
"https://mirrors.ustc.edu.cn/pypi/simple",
"https://mirrors.aliyun.com/pypi/simple",
"https://mirrors.huaweicloud.com/pypi/simple",
"https://pypi.org/simple"
)
foreach ($m in $mirrors) {
.venv\Scripts\python.exe -m pip install -r requirements.txt -i $m --timeout 30 --retries 2
if ($LASTEXITCODE -eq 0) { break }
}
```
> Note: TUNA returns an empty index (`from versions: none`), so it is last;
> NetEase 163's PyPI mirror has stopped syncing, so **do not use it**.
> The "Install all" button in the UI does the above automatically and is usually less trouble than typing it by hand.
> ⚠ **Key mapping is optional — ask before installing it.** It needs frida, and frida injects a module into the system's
> Bluetooth HID driver host (one elevation, may be flagged/quarantined by AV, a few games with kernel anti-cheat may
> refuse to start). **If the user only wants voice, do not install it.**
> Two offline packages exist: `-voice` (~14MB, **no frida**) and the full one (~61MB, frida included).
> If they took `-voice` and later want key mapping, there are two routes:
> ① **online**: click "Settings → Diagnostics → Install key-mapping dependencies" (Chinese mirrors, ~47MB download);
> ② **offline**: have them drop `frida-*-win_amd64.whl` into the program folder's `wheels\` and click the same button
> (bundled wheels are preferred). Either way the first attach needs **one** administrator approval (or run "One-click
> optimization" first).
## 2. Verify the installation
```powershell
# 2.1 Launch (same as double-clicking 启动遥控器.vbs; --silent means don't open a browser)
.venv\Scripts\pythonw.exe app.py --silent
# 2.2 Wait 5 seconds, then ask the API for status
Invoke-RestMethod http://127.0.0.1:8787/api/state | ConvertTo-Json -Depth 5
```
Reading `/api/state`:
| Field | Expected | What to do if it is wrong |
|---|---|---|
| `running` | `true` | Call `/api/action {"action":"start"}`, or have the user click "Start" in the UI |
| `deps.ok` | `true` | Dependencies are incomplete; check `deps.missing` |
| `deps.keys_ok` | `true` only if frida is installed | Not having frida installed is normal |
| `voice.connected` | `true` | The remote is asleep → **have the user press any button on the remote** |
| `keys.ready` | `true` only if frida is installed | See above |
"Settings → **Environment check**" in the UI (or `POST /api/action {"action":"check"}`) lists everything item by item and is the least trouble.
Logs are also written to `client.log` in the program directory. If nothing happens after a double-click, **read that file first** —
on startup failure the launcher shows a prompt and opens it automatically.
## 3. Three things to tell the user after installation
1. **Launch**: double-click `启动遥控器.vbs` (no console window); the browser opens the console automatically
2. **Set the microphone in the input method editor to `CABLE Output (VB-Audio Virtual Cable)`** (otherwise voice input produces no text).
⚠ **Change it only there**: leave your real microphone as the system default recording device — if the default is the
virtual cable, WeChat voice messages / Voice Recorder pick up that line too, and the "mic passthrough" feeds the cable's
own output back into it (the input method editor then hears the voice plus a ~115 ms delayed copy, which clearly hurts accuracy)
3. **Qianwen IME**: the program handles this **automatically** on first launch (turns on "allow injection" and restarts the Qianwen process once),
so the user needs to configure nothing. If the voice button still does nothing, have them click that button once on the "Voice" page.
To stop UAC from appearing: have the user click "Settings → **One-click optimization**" (the **last** UAC prompt appears,
after which autostart uses a highest-privilege scheduled task and no prompt appears at boot).
## 4. Troubleshooting
```
The first step is always: POST /api/action {"action":"check"} and see which layer it says is broken
```
| Log symptom | Real cause | Handling |
|---|---|---|
| Constant `connection timeout` | Remote is asleep (it does not advertise while idle; it only wakes for a moment after a key press) | Press any button on the remote — the program **retries immediately whenever it sees a remote report**, and backs off to at most a 45-second retry interval while it keeps failing |
| `missing ATVV characteristic` + only 4 services enumerated | Another client is holding the Bluetooth channel | Close other clients (including older versions), then click "Restart" |
| `attach failed: PermissionDenied` | Frida's one-time elevation request was denied | Have the user click "One-click optimization", or retry once |
| Pressing the voice button produces no "start speaking" in the log | Bluetooth is not connected | Check `voice.connected` |
| `voice.connected` stays false and the log keeps printing `connection timeout`, **but the remote's buttons still work** | A BLE device accepts only one connection and Windows' HID stack holds that single link, so the remote stops advertising (`BleakScanner` cannot see it, while other BLE devices scan fine) | Have the user click "Settings → Diagnostics → **Reset Bluetooth radio**", or take the remote's battery out for 10 seconds. **Do not** touch pairing/drivers |
| "start speaking" appears but the input method editor does nothing | The IME rejects program-injected keystrokes | Qianwen: enable "allow injection"; Doubao: unsupported, switch to another IME |
| frida is installed but `keys.ready` stays false / the log says frida is missing | **Antivirus silently quarantined frida** (it is ~47MB of the offline bundle; `frida-agent.dll` / `frida-helper-*.exe` are in several vendors' signature sets) | Have the user whitelist the program folder, then click "Install key-mapping dependencies". **Do not** tell them to disable Defender |
| `Attach failed: TransportError: the connection is closed` over and over (dozens to hundreds of times) while voice works perfectly | **Antivirus / EDR is blocking Frida's injection** — **by far the most common case on a corporate PC** (a real log had all 145 failures as exactly this error) | Have the user ask **IT to whitelist the program folder** (we never change system settings, disable Defender, or install drivers). **Never** suggest disabling antivirus, and never try to bypass corporate policy. Until it is allowed: **voice works, key mapping does not**. Ask them to click "Settings → Diagnostics → Key-mapping self-check" so the evidence lands in the log |
| The log keeps printing `GATT Protocol Error: Insufficient Authentication` (or repeated `missing ATVV characteristic`) | The remote's link was never properly **paired/encrypted** (a BLE peripheral insists on an encrypted link) — unrelated to antivirus or drivers | Have the user **Remove device** under Settings → Bluetooth & devices, hold 【**Back + Home**】 to pair again, then click Restart. **Do not** touch anything beyond the pairing record |
| No sound right after boot | Audio service / sound card became ready late | **Normal, wait 5–10 seconds** (the program retries every 5 seconds) |
| Installing the virtual audio cable reports `Unknown Error 1072` | The service is stuck in the "marked for deletion" state (an earlier leftover cleanup deleted the service without stopping the driver first). Its "run as administrator" hint is **misleading** | **Reboot once**, then install again; if it still fails, right-click `vbcable\VBCABLE_Setup_x64.exe` → "Run as administrator" → click Install Driver |
| Nothing happens after a double-click | Startup failed | Read `client.log` (the launcher also shows a prompt and opens it) |
**Do not do these**: do not suggest installing drivers, do not modify the IME configuration in the registry, and do not make the program
run as a resident administrator — this project does not need it and doing so causes problems.
## 5. Cleanup / uninstall (when the user asks)
"Settings → Cleanup / Uninstall" in the UI is a **per-item checklist** (nothing is checked by default); the command line works too:
```powershell
.venv\Scripts\python.exe uninstall.py --items=venv,pycache # delete only these two
.venv\Scripts\python.exe uninstall.py --items=all # delete everything (including config)
.venv\Scripts\python.exe uninstall.py --dry-run # list only, change nothing
```
Available ids: `task, run, kill, frida_helper, frida_svc, venv, pycache, pythontxt, logs, config, qwen, cable`.
`task` / `frida_svc` / `cable` require administrator rights.
> **`cable` (uninstall VB-CABLE) is different from every other id** — tell the user before they tick it:
> it is a **system-wide audio driver** that OBS / voice changers may also be using; there is **no silent
> uninstall** (the program only launches the official uninstaller, and the user must click "Remove Driver"
> in its window); and VB-Audio requires a **reboot** afterwards. It is unticked by default and not part of
> the recommended set, and the program deliberately does not verify the result (it would report a false failure).
**Completely uninstall the whole program**: (1) run the "recommended cleanup" above once (disable autostart + remove Frida services + delete dependencies),
(2) then delete the entire program directory. The program is a purely portable deployment, so nothing else is left on the system.
The bundled `runtime\` is part of the program itself and is not in the cleanup list.
## 6. Environment
Windows 10/11 x64 · Python 3.10 or newer · voice requires the VB-CABLE virtual audio cable
(its own installer needs administrator rights once; unrelated to this program).
**How to install VB-CABLE**: both zips ship the official installer **and its driver files**, so have
the user click the **one-click install** button on the home-page checklist row (or on the
"Settings → Virtual audio cable" card). Two things to tell them up front:
- one UAC prompt appears (that is the audio driver installer's own requirement, unrelated to this
program), and they still have to click **"Install Driver" in its own window** — the official
installer **offers no silent-install switch** (no `-i` / `-s` / `/S`), so this step cannot be automated;
- the official readme states that **a reboot is required** for the installation to complete.
The program **starts this step for the user automatically on first launch** (it only auto-tries once —
if they decline the UAC it will not ask again). If the dependencies are not installed yet (first run of
the light package), that step does not trigger; use "Install all" in the UI instead (it includes the
virtual audio cable).
+166
View File
@@ -0,0 +1,166 @@
# AGENTS.md —— 给 AI 助手的安装手册
> 这份文件是写给 **AI 助手**看的。用户会把本仓库地址发给你,让你帮忙安装。
> 🌐 [English](AGENTS.en.md)
> 请按这里给的步骤走,**不要把网上的通用教程套进来** —— 这个项目刻意避开了
> 「装驱动 / 开测试签名」那一套,照着做反而会出问题。
## 0. 这是什么
Windows 上的小工具:把 Google TV / Chromecast 蓝牙遥控器变成"按住说话 + 快捷键"设备。
纯用户态,**不装驱动、不写系统目录、不常驻管理员**。⚠ 但**按键映射**(方向键/OK/音量那些)要
**一次**提权:Frida 得往蓝牙 HID 驱动宿主里注入并注册临时服务;点一次「一键优化」后由最高权限
计划任务代劳、之后不再弹。**语音完全不需要管理员** —— 跟用户介绍时别把这两件事混成一句"不需要管理员"。
程序会在 `127.0.0.1:8787` 起一个网页控制台(只监听本机)。
**先跟用户确认三件事**(缺一件都装不成):
1. 系统是 **Windows 10/11 x64**?
2. 遥控器是 **Google TV / Chromecast 蓝牙遥控器**(其他遥控器报文格式不同)?
3. 用哪种包?
- `vibe-mote-offline-v1.zip`(约 61MB,**自带 Python,推荐**)→ 什么都不用装
- `vibe-mote-v1.zip`(约 900KB)→ 需要 **Python 3.10+**,让用户从
[python.org](https://www.python.org/downloads/) 装,**务必勾选 `Add python.exe to PATH`**,
不要用 Microsoft Store 版本
## 1. 安装
**离线包**:解压 → 双击 `启动遥控器.vbs` → 完成(没别的步骤)。
**轻包**(目标机器有 Python 时):
```powershell
python -m venv .venv
.venv\Scripts\python.exe -m pip install --upgrade pip
.venv\Scripts\python.exe -m pip install -r requirements.txt
# 可选:方向键/OK/音量这些要 frida(约 130MB)—— 先问用户要不要
.venv\Scripts\python.exe -m pip install -r requirements-keys.txt
```
装依赖卡住或超时(国内常见)时,按这个顺序换源,第一个成功就停:
```powershell
$mirrors = @(
"https://mirrors.cloud.tencent.com/pypi/simple",
"https://mirrors.ustc.edu.cn/pypi/simple",
"https://mirrors.aliyun.com/pypi/simple",
"https://mirrors.huaweicloud.com/pypi/simple",
"https://pypi.org/simple"
)
foreach ($m in $mirrors) {
.venv\Scripts\python.exe -m pip install -r requirements.txt -i $m --timeout 30 --retries 2
if ($LASTEXITCODE -eq 0) { break }
}
```
> 注意:清华 TUNA 源会返回空索引(`from versions: none`),所以排在最后;
> 网易 163 的 PyPI 镜像已停止同步,**不要用**。
> 界面上的「全部安装」按钮会自动做上面这件事,通常比手敲更省事。
> ⚠ **按键映射不是必需项,先问清楚再装。** 它要 frida,而 frida 会往系统的蓝牙 HID 驱动宿主里注入
> (要一次提权、可能被 AV 误报/隔离、少数带内核反作弊的游戏可能起不来)。**只要语音就别装**。
> 离线包有两个版本:`-voice`(约 14MB,**不含 frida**)和完整版(约 61MB,已带 frida)。
> 用户拿的是 `-voice`、后来又想要按键映射,两条路:
> ① **联网**:点界面「设置 → 诊断 → 安装按键映射依赖」(自动走国内镜像,下载约 47MB);
> ② **不联网**:让他把 `frida-*-win_amd64.whl` 放进程序目录的 `wheels\`,再点同一个按钮
> (装依赖时**优先用包内 wheels**)。两种方式装完,首次挂载都要一次管理员(或先点「一键优化」)。
## 2. 验证装好了
```powershell
# 2.1 启动(双击 启动遥控器.vbs 一样;--silent 表示不开浏览器)
.venv\Scripts\pythonw.exe app.py --silent
# 2.2 等 5 秒,问接口要状态
Invoke-RestMethod http://127.0.0.1:8787/api/state | ConvertTo-Json -Depth 5
```
判读 `/api/state`:
| 字段 | 期望 | 不对时怎么办 |
|---|---|---|
| `running` | `true` | 点 `/api/action {"action":"start"}`,或让用户在界面点「启动」 |
| `deps.ok` | `true` | 依赖没装全,看 `deps.missing` |
| `deps.keys_ok` | 装了 frida 才 `true` | 没装 frida 属正常 |
| `voice.connected` | `true` | 遥控器在休眠 → **让用户按一下遥控器任意键** |
| `keys.ready` | 装了 frida 才 `true` | 见上文 |
界面「设置 → **环境体检**」(或 `POST /api/action {"action":"check"}`)会逐项列出来,最省事。
日志同时写在程序目录的 `client.log`。如果双击后什么都没发生,**先读这个文件** ——
启动失败时启动器会弹提示并自动打开它。
## 3. 装完之后要告诉用户的三件事
1. **启动**:双击 `启动遥控器.vbs`(无黑框),浏览器会自动打开控制台
2. **输入法里把麦克风选成 `CABLE Output (VB-Audio Virtual Cable)`**(否则语音出不了字)。
⚠ **只改输入法这一处**:系统默认录音设备要留给真实麦克风 —— 默认设备设成虚拟声卡的话,
微信语音/录音机也会拿到遥控器这一线,而且「内置麦克风直通」会把声卡输出再灌回声卡输入
(输入法听到人声叠上一份约 115 ms 的延迟副本,识别会明显变差)
3. **千问输入法**:程序首次启动会**自动**处理(打开「允许注入」并重启一次千问进程),
不需要用户手动设置。如果语音键仍无效,让他在「语音」页点一次那个按钮。
想让 UAC 不再出现:让用户点「设置 → **一键优化**」(弹**最后一次** UAC,
之后自启走最高权限计划任务,开机不再弹)。
## 4. 排障
```
第一步永远是:POST /api/action {"action":"check"},看它说哪一层断了
```
| 日志现象 | 真因 | 处理 |
|---|---|---|
| 一直 `连接超时` | 遥控器休眠(它平时不广播,按键后才醒一小会) | 按一下遥控器任意键 —— 程序**一收到遥控器报文就会立刻重试**;连着失败时会退避到最多 45 秒一轮 |
| `缺少 ATVV 特征` + 只枚举到 4 个服务 | 有另一个客户端占着蓝牙通道 | 关掉其它客户端(含旧版),再点「重启」 |
| `挂载失败:PermissionDenied` | Frida 要一次提权被拒 | 让用户点「一键优化」,或重试一次 |
| 按语音键日志无「开始说话」 | 蓝牙没连上 | 看 `voice.connected` |
| `voice.connected` 一直是 false、日志一直刷「连接超时」,**但遥控器按键有用** | BLE 设备只接受一个连接,Windows 的 HID 把那条唯一的连接攥住不放 → 遥控器不再广播(`BleakScanner` 扫不到它,扫别的 BLE 设备正常) | 让用户点「设置 → 诊断 → **重置蓝牙电台**」;或让用户把遥控器**电池取出 10 秒**。**别**去动配对/驱动 |
| 有「开始说话」但输入法没反应 | 输入法不认程序注入的按键 | 千问:开「允许注入」;豆包:不支持,换输入法 |
| 装了 frida、但 `keys.ready` 一直 false / 日志说找不到 frida | **杀软把 frida 静默隔离了**(离线包里 frida 约 47MB,模块名 `frida-agent.dll`/`frida-helper-*.exe` 在多家 AV 特征库里) | 让用户把程序目录加进杀软白名单,再点「安装按键映射依赖」。**别**让他关 Defender |
| `挂载失败:TransportError: the connection is closed` 反复出现(几十~上百次),而语音一切正常 | **杀软 / EDR 拦了 Frida 的注入** —— **公司电脑上最常见**(真机日志里 145 次全是这个错误) | 让用户请 **IT 把程序目录加进白名单**(我们不改系统设置、不关 Defender、不需要驱动)。**别**建议关杀软,更别想绕过公司策略。放行之前:**语音可用、按键映射不可用**。让他点一次「设置 → 诊断 → 按键映射自检」,把这行证据写进日志再发给你 |
| 日志刷 `GATT Protocol Error: Insufficient Authentication`(或反复「缺少 ATVV 特征」) | 遥控器的链路**配对/加密没完成**(BLE 外设要求已加密的连接)—— 跟杀软、驱动都无关 | 让用户「设置 → 蓝牙和其他设备」**删除设备** → 按住遥控器【**返回 + 主页**】重新配对 → 回程序点「重启」。**别**动配对记录以外的任何东西 |
| 刚开机没声音 | 音频服务/声卡晚就绪 | **正常,等 5~10 秒**(程序每 5 秒重试) |
| 装虚拟声卡报 `Unknown Error 1072` | 服务卡在「待删除」状态(先前的清残留删了服务、没先停驱动)。它提示「请用管理员运行」是**误导** | **重启一次**再装;还不行就右键 `vbcable\VBCABLE_Setup_x64.exe` →「以管理员身份运行」→ 点 Install Driver |
| 双击后什么都没发生 | 启动失败 | 读 `client.log`(启动器也会弹提示并打开它) |
| 识别不连贯、像有回声 / 一直有底噪 | 系统默认录音设备被设成了 `CABLE Output`,而「内置麦克风直通」会跟着把声卡输出转回去 | 让用户把系统默认录音设备改回真实麦克风;虚拟声卡只出现在**输入法自己的**麦克风设置里 |
**别做这些**:不要建议用户装驱动、不要改注册表里的输入法配置、不要让程序常驻管理员 ——
这个项目不需要,做了反而出问题。
## 5. 清理 / 卸载(用户要求时)
界面「设置 → 清理 / 卸载」是**逐项勾选**的(默认什么都不勾),也可以命令行:
```powershell
.venv\Scripts\python.exe uninstall.py --items=venv,pycache # 只删这两项
.venv\Scripts\python.exe uninstall.py --items=all # 全删(含配置)
.venv\Scripts\python.exe uninstall.py --dry-run # 只列清单,什么都不做
```
可选 id:`task, run, kill, frida_helper, frida_svc, venv, pycache, pythontxt, logs, config, qwen, cable`。
`task` / `frida_svc` / `cable` 需要管理员。
> **`cable`(卸载虚拟声卡)跟别的 id 都不是一类**,用户要勾之前先跟他讲清楚:
> 它是**系统级音频驱动**,OBS / 变声器可能也在用;官方**没有静默卸载**(程序只能把官方卸载器
> 拉起来,用户还得在它窗口里点「Remove Driver」);按 VB-Audio 官方要求**卸完必须重启**。
> 它默认不勾、也不在推荐清理里;程序**故意不核实**卸载结果(核了就是误报"没删掉")。
**彻底卸载整个程序**:① 上面勾「推荐清理」跑一次(断自启 + 清 frida 服务 + 删依赖),
② 然后直接删掉整个程序目录。程序是纯绿色部署,系统里不会留别的东西。
内置的 `runtime\` 属于程序本体,不在清理清单里。
## 6. 环境
Windows 10/11 x64 · Python 3.10 或更高 · 语音需要 VB-CABLE 虚拟声卡
(它自己的安装器需要一次管理员权限,与本程序无关)。
**VB-CABLE 怎么装**:两个 zip 里都带了官方安装器**和驱动文件**,让用户点界面首页清单
「虚拟声卡」那一行的**一键安装**(或「设置 → 虚拟声卡」那张卡)即可。有两件事要提前告诉他:
- 会弹一次 UAC(那是声卡安装器自己要求的,与本程序无关),**还要在他自己的窗口里点一下
「Install Driver」** —— 官方安装器**不提供静默安装参数**(没有 `-i`/`-s`/`/S` 这类开关),
所以这一步不可能全自动;
- 官方 readme 写明**装完必须重启一次**系统才算装完。
程序**首次启动会自动帮他拉起这一步**(只自动试一次,用户拒了 UAC 就不再自动弹);
如果机器上依赖还没装(轻包首启),那一步不会触发,走界面上的「全部安装」即可(它含虚拟声卡)。
+108
View File
@@ -0,0 +1,108 @@
# 更新日志
## v1.1.0
这一版几乎全是**"用得久一点才会遇到"的问题**:重启后失灵、进程莫名消失、按键没反应却
查不出原因、蓝牙被禁用后设备全消失…… 逐个修掉,并且把"出事时该怎么看、怎么救"做进了程序。
### 新增
- **自动解卡**:日志连续几次出现「没扫到广播(多半被系统 HID 拿着连接)」时 —— 也就是
**按键能用、只有语音键不行**那种状态 —— 程序会自己用 Windows 的电台接口把蓝牙
**关开一次**(和「设置 → 蓝牙」那个开关同一机制,**不需要管理员**),并且**每次都读回状态
确认确实开回来了**;10 分钟最多一次,可用配置 `"voice": {"auto_unstick": false}` 关掉。
- **开机自启看门狗**:计划任务除了「登录时启动」,还带**每 5 分钟自检** —— 进程万一崩溃/被强杀,
最多 5 分钟自己回来;已经在跑时新进程秒退(不弹窗、不刷日志)。
- **两个自检按钮**(设置 → 诊断):
- **按键映射自检**:宿主 PID、该宿主上「遥控器读」调用次数、报文长度分布、**这台遥控器实际
发过哪些按键**(含程序不认识的那些),外加一次**注入能力测试**(往普通进程注入一下,
立刻分清"杀软拦注入"和"我们自己的问题")。
- **麦克风自检**:读一秒系统默认输入设备并报峰值,同时说明 VB-CABLE 的 in/out 到底哪个是哪个。
- **"上次是不是正常退出"诊断**:启动时若发现上次是异常结束(崩溃/被强杀)会在日志里写清楚;
被系统重启结束的则明确标为**正常**(不再吓人)。
- **Claude Code 预设**:上/下/左/右=普通方向键、OK=`Enter`、返回=`Backspace`、主页=`Esc`、
静音=`Enter`、输入源=`Shift+Tab`、音量±=按住连续滚,三个"打字"键 =
YouTube `/clear`、NETFLIX `commit + push`、电源 `/exit`(**打完字自己按 OK 提交**)。
- **语音版离线包** `vibe-mote-offline-voice-v1.zip`(约 14MB,**不含 frida**):只要语音、
怕杀软或公司电脑选它;后续想加按键映射,联网点一下或把 frida 的 whl 丢进 `wheels\` 即可。
### 修复
- **重启后方向键失灵**:蓝牙 HID 驱动宿主(WUDFHost)每次重启都会换进程,而钩子还挂在旧进程上
(界面照样显示"就绪",实际一个报文都收不到)。现在检测到**宿主换了或没了就自动重挂**。
- **改遥控器地址要重启客户端才生效** → 现在保存即热更新并立刻按新地址重连。
- **配置偶发读取失败就用默认值**(Windows 共享冲突:一边写一边读)→ 现在重试 6 次,
真失败才退回默认值并写清楚(避免"我的设置莫名其妙变回默认了")。
- **"重置蓝牙电台"在部分 Intel 电台上会把蓝牙留在禁用状态**(Windows 对那片电台报
`Disable-PnpDevice : 不支持`)→ 现在:先**启用**任何非正常状态的电台;本来就是禁用/异常时
**启用成功就收手,绝不再"关开一次"**;"关开"首选 `pnputil /restart-device`(只重启,不存在
"禁用了没启用回来"的窗口);最后一定**验证**,没恢复就明确告诉你去设备管理器「启用设备」
或**完全关机**。
- **蓝牙电台被禁用后会"设备全没了"**(设置里连蓝牙开关都消失)→ 环境体检现在直接报出来并给救法。
- **非原厂 VID/PID 的兼容款遥控器**找不到 HID 宿主 → 现在会退一步认「任意 BLE HID 设备」;
设备身份(`VID&xxxx_PID&xxxx`)也写进日志,便于分辨原厂/兼容款。
- **预设选错输入法 = 语音键永远不出字**(千问只认 `RALT`)→ 启动时若发现"装了千问但注入的不是
RALT"会在日志里直接点名。
- **杀软拦注入**(公司电脑常见,日志反复 `TransportError` 而杀软界面没有任何记录)→ 现在日志给出
人话解释 + 缓解步骤(请 IT 给程序目录放行;放行前语音照常可用)。
- **配对没配好**(`GATT Protocol Error: Insufficient Authentication`)→ 日志直接给"删除设备 +
按住【返回 + 主页】重新配对"的步骤。
- **音频出口日志不写设备名** → 现在写「写入「CABLE Input…」;输入法那边请选「CABLE Output…」」。
- **开发副本会顶掉用户那份的开机启动**(在开发目录里点了「一键优化」,任务就被改成指向开发目录)
→ 现在开发目录**拒绝安装/改写自启任务**。
### 文档
- README / 说明 新增:**支持的遥控器(原装 G9N9N、兼容款批次差异、"没有语音服务"怎么办)**、
**VB-CABLE 的 in/out 为什么看着是反的**、**"所有麦克风电平都是 0"怎么查**、
**"蓝牙设备全没了 / 设置里没有蓝牙开关"怎么救** 等排障条目。
## v1.0.0
把 **Google TV / Chromecast 蓝牙遥控器**变成 Windows 上的「按住说话 + 快捷键面板」。
纯用户态:不装驱动、不写系统目录、不弹黑框;语音不需要管理员,按键映射要**一次**提权
(Frida 注入蓝牙 HID 驱动宿主,点一次「一键优化」后不再弹)。限制与风险见 README 第五节第 5 小节。
### 功能
- **按住遥控器语音键说话** → 遥控器麦克风 → 虚拟声卡(VB-CABLE)→ 输入法出字
(纯 Python 实现 IMA ADPCM 解码 + 重采样 + AGC,不需要 numpy)
- **14 个按键可映射**:上/下=**鼠标滚轮(按住不放连续滚,轻点滚一格)**、左/右=切换会话、
YouTube/NETFLIX=审批的「同意/拒绝」(`Enter`/`Esc`),其余在网页控制台里改。
映射类型有 `单键 / 组合键 / 按住不放 / 按住组合键 / 鼠标 / 按住鼠标 / 文本`;
「按键捕获」除了抓键盘键,**滚一下滚轮或点一下鼠标键也能抓**(并自动选好类型)
- **预设方案**:选「目标应用(WorkBuddy / Codex / Claude Code / 通用)+ 输入法(千问 / 搜狗 /
微信 / Windows 自带)」→ 一键配好整套键位
- **首页「首次准备」清单**:「运行依赖 / 虚拟声卡 / 千问注入 / 开机自启 / 一键优化」
五项的状态与按钮都在首页,每项标了必需还是可选,照着从上到下点一遍就能用
- **缺虚拟声卡时**顶部横幅会提示并给出「一键安装虚拟声卡」按钮;
**首次启动会自动帮你把官方安装器拉起来**(只自动试一次)。
官方安装器**没有静默参数**,所以那一下「Install Driver」和装完的**重启**省不掉
- **网页控制台**(`127.0.0.1:8787`,只监听本机):状态 · 按键映射(遥控器实物图)·
语音 · 设置 · 日志。单文件、零外部资源、完全离线可用
- **装依赖自动轮询国内镜像**(腾讯云 → 中科大 → 阿里云 → 华为云 → 官方),国内无 VPN 可用
- **中英双语**:右上角切换(首次按浏览器语言自动选,检测不出来用中文),
界面文案、后端主流程消息与全部文档都是双语的;
底层诊断流水(音频帧、ATVV、frida 挂载细节)保持中文
- **启动失败有痕迹**:日志落盘 `client.log`,启动器会确认程序是否真的起来,
起不来就弹提示并打开日志
- **清理 / 卸载**:逐项勾选、默认不勾;勾了却删不掉的项会如实回报。
另有「**清除虚拟声卡残留(注册表 / 驱动包)**」:装/卸卡住时,把官方卸载器不清的那几处
(两个服务键、`HKLM\SOFTWARE\VB-Audio\Cable`、「应用和功能」里那条、DriverStore 驱动包、
`C:\Program Files\VB\CABLE`)清掉 —— **同属 VB-Audio 的 Voicemeeter 的键和目录一律不碰**。
彻底卸载=点一次「推荐清理」→ 删掉整个程序目录
### 两种分发包
| 包 | 大小 | 说明 |
|---|---|---|
| `vibe-mote-offline-v1.zip` | 约 61MB | 自带 Python 运行时 + 预装依赖,**没装 Python、没网也能用** |
| `vibe-mote-v1.zip` | 约 900KB | 目标机器已装 Python 3.10+,首次运行联网装依赖 |
两个包都随包附带 VB-CABLE 的**官方安装器 + 驱动文件**(约 2.1MB)。
VB-CABLE 是 donationware(<https://vb-cable.com/>),本程序只原样带上官方文件、
不修改也不静默安装,细节见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
### 环境
Windows 10/11 x64 · Python 3.10 或更高 · 语音需要 VB-CABLE 虚拟声卡。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 VibeMote contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+426
View File
@@ -0,0 +1,426 @@
# VibeMote · Google TV Remote → Windows Input Device
🌐 **English** | [中文](README.md)
Turn a **Google Chromecast / Google TV Bluetooth remote** into a "push-to-talk + hotkey panel"
device for Windows: **hold the remote's voice button and talk → remote microphone → virtual audio
cable → your IME types the text**. 14 buttons are freely remappable —
**Up/Down = wheel, Left/Right = switch conversation, YouTube / NETFLIX = approval Approve / Deny**.
**Download → unzip → double-click `启动遥控器.vbs`.** There is no fourth step (the offline package
bundles Python, so the target machine needs nothing installed).
> ⬇️ **[Download the latest release](releases/latest)**
> · `vibe-mote-offline-v1.zip` (~61MB, **bundles Python + everything** ← recommended)
> · `vibe-mote-offline-voice-v1.zip` (~14MB, **voice only, no frida** ← pick this for AV-wary or work PCs)
> · `vibe-mote-v1.zip` (~900KB, for machines that already have Python 3.10+)
> Changelog → [`CHANGELOG.md`](CHANGELOG.md) ·
> Want an AI to install it for you → give it the repo URL together with [`AGENTS.en.md`](AGENTS.en.md)
> ⚠️ **Read this before downloading (the 30-second version)**
> **Voice** (hold a button, get text) needs no administrator rights and injects nothing — every package above does it.
> **Key mapping** (D-pad/OK/volume) works by **injecting a module into the system's Bluetooth HID driver host**: it needs
> **one** administrator approval (never prompts again after "One-click optimization"), it may be **flagged or silently
> quarantined by antivirus**, a few games with **kernel anti-cheat** may refuse to start, and a **work/school PC** may
> block it outright.
> → Voice only: take the `-voice` package (no frida, smaller) and **never** click "Install key-mapping dependencies".
> → Want key mapping: the full package already ships frida — just click "Install key-mapping dependencies". Adding frida
> to the `-voice` package later has two routes — **① online**: click the same button (Chinese mirrors, ~47MB download);
> **② offline**: drop the `frida-*-win_amd64.whl` into the program folder's `wheels\` directory and click it (bundled
> wheels are preferred). Either way the first attach needs **one** administrator approval (or run "One-click
> optimization" first).
> Full list in [section 5.5](#55-known-limits-and-risks-better-said-up-front-than-hidden-in-a-corner).
The home page is a **"First-time setup" checklist** — what each of the five items is, whether it is
required or optional, its current state, and the button to fix it, all on one screen. Work down the
list once and you are done.
| Remote button | Default action |
|---|---|
| **Voice button** (the one with the mic) | **Push-to-talk** — remote microphone → virtual audio cable → input method editor types the text |
| Up / Down | Mouse wheel up / down (**hold to keep scrolling**; a tap scrolls one notch) |
| Left / Right | Switch to previous / next conversation (preset, changeable) |
| OK / Back / Home / Power / Input source / Mute / YouTube / NETFLIX / Volume ± | Freely remappable in the web UI |
| YouTube / NETFLIX | Approval **Approve / Deny** (`Enter` / `Esc`) |
Web console (`127.0.0.1:8787`): status · key mapping (with a photo of the actual remote) · voice · settings · logs.
Supports **presets**: pick a "target application (WorkBuddy / Codex / Claude Code / generic) + input method editor (Qianwen / Sogou / WeChat / built-in Windows)", and the whole keymap is configured in one click.
Remapping happens on this page: click any button on the remote picture on the left, change what it
sends on the right. Presets configure a whole keymap in one click, "restore recommended" undoes it,
and mappings can be exported / imported.
---
> 🌐 **Bilingual**: switch languages in the top-right corner of the UI (it auto-detects from your
> browser language on first open, and falls back to Chinese). Chinese docs:
> [`README.md`](README.md), [`说明.md`](说明.md) and [`AGENTS.md`](AGENTS.md).
> The messages the backend produces for the UI (dependency notes, presets, cleanup list,
> environment check) are bilingual too; low-level diagnostic logs stay in Chinese.
> Looking for the long illustrated guide? → [`说明.en.md`](说明.en.md)
## 1. What makes this build clean
- ✅ **No drivers installed** (no virtual HID, no kernel filter driver, doesn't touch HidHide)
- ✅ **The voice part needs no administrator privileges.** Key mapping (optional) needs **one** elevation — Frida has to inject into the Bluetooth HID driver host and register a temporary helper service; after clicking "**One-click optimization**" a highest-privilege scheduled task does it for you, **and it never prompts again**. The other prompt is installing the virtual audio cable (that is its own installer's requirement). Details in section 5.5 below
- ✅ **No console windows flash**: single process, launched with the window hidden throughout, and nothing flashes on autostart either
- ✅ **No test mode, no disabling Secure Boot, no system file changes**
- ✅ **The UI only listens on `127.0.0.1`**, so it never triggers a firewall prompt
- ✅ **Very little code**: 9 source files, **only 3 third-party dependencies** (not even numpy is needed)
---
## 2. Dependency list (what to install, which versions)
### Runtime environment
| Item | Requirement | Notes |
|---|---|---|
| OS | **Windows 10 / 11 x64** | Uses the WinRT Bluetooth API and SendInput |
| Python | **3.10 or newer** | Installer from python.org; **be sure to check `Add python.exe to PATH`** during setup |
| Virtual audio cable | **VB-CABLE** (VB-Audio Virtual Cable) | Prerequisite for voice. Installing it needs administrator rights once; **a reboot afterwards is recommended** |
> With the **offline full bundle**, neither Python nor any of the packages below are your concern — everything is already installed inside.
### Python packages (`requirements.txt`)
| Package | Version | Purpose |
|---|---|---|
| `bleak` | **3.0.2** | Bluetooth GATT: voice-button state channel + remote microphone audio |
| `sounddevice` | **0.5.6** | Audio output/input (PortAudio binding); routes the remote microphone into the virtual audio cable |
| `cffi` | ≥1.16 | A `sounddevice` dependency, pulled in automatically |
### Key-mapping dependencies (`requirements-keys.txt`, **optional**)
| Package | Version | Purpose |
|---|---|---|
| `frida` | ≥17.18 <18 | **Required for key mapping** (~130MB): injects into the Bluetooth driver host to read HID reports. Installed into the bundled `.venv`, exits as soon as it is done |
> **Everything works fine without frida** — the voice button uses the ATVV channel and does not depend on it. Only the D-pad/OK mappings are unavailable.
> Installing frida triggers one UAC prompt on first attach (Frida installs a helper for itself); after running this project's "one-click optimization",
> autostart switches to a highest-privilege scheduled task, and **it never prompts again**.
### What is explicitly **not** needed (don't be misled by older guides)
❌ WinUHid / virtual HID driver ❌ Test signing / disabling Secure Boot ❌ HidHide ❌ Interception driver ❌ A resident administrator process
---
## 3. Installation
### Route 0: the easiest way (two steps for a brand-new user)
```
1) Double-click "启动遥控器.vbs" → the UI opens by itself (no console window)
2) Click "Install all" in the top banner → then "Restart client" → done
```
**No command line at all**, and installing dependencies needs no administrator either (Frida's **first attach** prompts once — see section 5.5). The program automatically picks a Chinese mirror to download from
(Tencent Cloud → USTC → Aliyun → Huawei Cloud → official), installing into a `.venv` inside the program directory,
without polluting the system Python.
> Only Python has to be installed by you first ([python.org](https://www.python.org/downloads/),
> **be sure to check `Add python.exe to PATH`** during setup).
>
> **Don't want to install Python at all?** (Or need to copy it to someone else's machine on a USB stick?) → use "Route D: offline full bundle" below,
> which bundles Python and runs on a machine with nothing installed by double-clicking.
On first opening the UI, the banner tells you exactly what is missing and which button to click; the dependency installation **progress automatically jumps to "Settings → Diagnostics" and scrolls into view**.
### Route A: manual (3 steps)
```bat
:: 1) Enter the program directory, create a virtual environment (doesn't pollute system Python, no admin needed)
python -m venv .venv
.venv\Scripts\python -m pip install -r requirements.txt
:: 2) Only needed for key mapping (~130MB, optional)
.venv\Scripts\python -m pip install -r requirements-keys.txt
:: 3) Launch
double-click 启动遥控器.vbs
```
Clicking "Install base dependencies / Install key-mapping dependencies" in the UI does the same thing — **the program polls the Chinese mirrors automatically**.
### Route B: let an AI install it for you
Send **this repo's URL** together with the text below to your usual AI (ChatGPT / Claude / Doubao / WorkBuddy all work):
> Please read `AGENTS.en.md` in `<repo URL>` and follow its steps to install this remote-control tool on my Windows machine.
> My system is Windows 11. If anything is unclear, ask me first; do not install drivers or change system settings on your own.
`AGENTS.en.md` is an execution manual for an AI: precise steps, verification commands, failure-troubleshooting table, and forbidden actions.
Following it requires no Python knowledge from the user.
### Route C: offline / USB-stick deployment (no network, or don't want one)
On a machine **with internet access**, download the dependencies as wheels, put them in `wheels\`, and copy the whole thing over:
```bat
python -m pip download -r requirements.txt -d wheels
python -m pip download -r requirements-keys.txt -d wheels :: optional, ~130MB
```
The program **uses the `wheels\` directory whenever it detects one** (`--no-index --find-links`), with no network access at all.
The package itself is under 1MB.
> This route still requires **Python on the target machine** (wheels are only packages, not an interpreter).
### Route D: offline full bundle (works without Python / for copying to someone else on a USB stick)
`vibe-mote-offline-v1.zip` (~61MB) **bundles a Python runtime + pre-installed dependencies**:
```
On the other machine: unzip → double-click "启动遥控器.vbs" → use it
(no Python install, no network; key mapping still needs that one elevation — see section 5.5)
```
What is inside (and why it is so large):
| Component | Size | Notes |
|---|---|---|
| Python 3.13 official **embeddable** package | 10.4MB | The official distribution meant for "embedding into an application and running directly" |
| bleak / sounddevice / cffi / winrt-* | ~5MB | Required for voice |
| frida | 47.3MB | Required for key mapping (this is the bulk of the bundle) |
> The two "Install dependencies" buttons in the UI tell you directly on the offline bundle that "dependencies are bundled, nothing to install".
> 📌 **The bundled `runtime\` is part of the program itself; the cleanup tool will not delete it.**
> That is because "Install all" in the UI installs pip packages and **cannot reinstall** a Python distribution —
> once deleted, you can never get back to the "no-install" state. So the cleanup list simply does not offer that option.
---
## 4. Three things to do the first time
**The top of the home page has a “First-time setup” checklist** that shows the state and a button for each of
these five items, so you can just work down it (no digging through the "Settings" page):
| Row | Required? | In one line |
|---|---|---|
| Runtime dependencies | **required** | bleak / sounddevice (voice) + frida (key mapping). Click "Install all" |
| Virtual audio cable | **required** | the physical prerequisite for voice. Click "Install" |
| Qianwen "allow injection" | optional | only needed if you use the Qianwen IME; the program turns it on automatically on first launch |
| Launch at startup | optional | one-click toggle, no administrator needed (to remove Frida's UAC too, click "One-click optimization" as well — that one needs administrator) |
| One-click optimization (no more UAC) | optional | after this, Frida stops prompting for elevation |
The manual steps it corresponds to:
1. **Pair the remote**: Windows Settings → Bluetooth & devices → Add device → hold the remote's **Back + Home** buttons together to enter pairing mode
2. **Install VB-CABLE**: click the **install** button on the home-page checklist or on the **Virtual audio cable** card in "Settings" — it runs the bundled official installer if there is one, otherwise it downloads it from the official site first (~1.3MB).
- ⚠ **One UAC prompt appears** (that is the audio driver installer's own requirement, unrelated to this program), then **click “Install Driver” in its own window**, and finally **reboot once** as VB-Audio requires.
- **On first launch the program starts this step for you automatically** (it only auto-tries once — if you decline the UAC it will not nag you again). **The copy started by autostart never prompts** (the autostart entry passes `--boot`): at boot you are not at the machine, so the prompt would just sit there — and it would appear on every single boot.
- The official installer **offers no silent-install switch** (it has no `-i` / `-s` / `/S`), so "automatic" only saves you from hunting for the button; the click and the reboot cannot be skipped.
3. **Select the microphone in your input method editor**: choose **`CABLE Output (VB-Audio Virtual Cable)`**
(change it only there; keep your real microphone as the system default recording device)
> **Qianwen IME users need to do nothing**: Qianwen discards injected keystrokes by default, so on **first launch the program
> automatically turns on** the "allow injection" switch (writes a user environment variable and restarts the Qianwen process once; no administrator needed).
> That switch can be toggled manually on the "Voice" page at any time — if you deleted it under "Cleanup", the program remembers
> and will not add it back automatically; click that button to restore it.
---
## 5. What it changes on your computer (the complete list)
This is the most common question, so here is everything — **the program itself is a purely portable
deployment**: no drivers, nothing written to system directories, no resident administrator
(**key mapping needs one elevation, and its limits/risks have their own section 5.5**).
This is the *complete* list of what it touches:
### 1. Files inside its own program folder (delete the folder and they are gone)
`config.json` (config) · `client.log` (log) · `uninstall.log` · `qwen_optout` (the marker that you deleted
Qianwen's switch) · `cable_autotried` (the marker that it auto-tried the cable install) · `_ready.txt`
· `_deferred_result.txt` · `_uninstall_result.json`
### 2. System settings — **every one of them is something you clicked, and every one can be undone**
| What | When it happens | How to undo |
|---|---|---|
| `HKCU\...\CurrentVersion\Run\VibeMote` | **only** when you turn on "Launch at startup" | turn it off in the UI, or tick that item in cleanup |
| The highest-privilege scheduled task `VibeMote` | **only** when you click "One-click optimization" | tick "Disable autostart (scheduled task)" in cleanup |
| `HKCU\Environment\QIANWEN_IME_UTILITY_VOICE_HOOK_ALLOW_INJECTED=1` | written automatically when the Qianwen IME is installed (you can opt out) | the UI button, or tick that item in cleanup |
| Frida's temporary helper services `frida-<pid>-x86*` | registered by **Frida itself** while attaching, not by us | tick that item in cleanup; after "One-click optimization" they are not needed |
**What it never does**: install drivers, touch `System32` / `Program Files`, change your IME settings in
the registry, enable test mode, touch Secure Boot, disable Defender, run as a resident administrator.
It only uses the network in two places: when you click "Install dependencies", and when you click
"Install the virtual audio cable" while no installer is bundled (it downloads from the VB-Audio site).
### 3. The only two things that reach deep into the system are **performed by third-party installers — the program only launches them**
| What | Who installs it | What it needs |
|---|---|---|
| **VB-CABLE** virtual audio cable (a kernel audio driver) | **VB-Audio's own installer** | one UAC prompt + a reboot |
| **Frida**'s helper process | Frida's own mechanism | one UAC prompt on the first attach |
> ⚠ **One thing you should know up front: VB-CABLE's official uninstaller does not fully uninstall.**
> After clicking "Remove Driver" and rebooting, the driver **may still be there** (Device Manager shows it
> fine and all audio endpoints reappear). The reason is that it leaves the driver package in the
> DriverStore, `C:\Windows\inf\oem634.inf`, and a few service keys — and Windows reinstalls them from
> those on boot.
> To remove it for real, run one more command as administrator:
> ```
> pnputil /delete-driver oem634.inf /uninstall /force
> ```
> (`oem634.inf` differs per machine — find it with `pnputil /enum-drivers`, looking for the entry whose
> `Original Name` is `vbmmecable64_win10.inf`.)
> That is VB-Audio's uninstaller's behaviour, not this program's — but the UI states it plainly instead
> of pretending the removal succeeded.
### 4. Can it break someone else's computer?
No. There is no kernel driver, no resident service of its own (Frida's attach registers a temporary
**helper service**; after "One-click optimization" even that is not needed) and nothing written to system
directories; the worst case is "one feature does not work, and the log says why". The two things worth care
are listed above — VB-CABLE's install/uninstall needs UAC and a reboot (its own requirement) — and the
cleanup ticks **nothing by default**: only what you tick gets removed.
> Also note: **only one client can use this remote at a time** (the remote only gives the first client its
> full service table). If another tool of the same kind is running on your machine, this program will
> report "ATVV characteristic missing". Close the other client first.
### 5.5 Known limits and risks (better said up front than hidden in a corner)
**Key mapping (D-pad/OK/volume) and voice are two different things, with different costs:**
| | Voice (talk into the remote → text) | Key mapping (14 buttons → keyboard/mouse) |
|---|---|---|
| Needs administrator? | **Not at all** | **One elevation**: Frida injects into the Bluetooth HID driver host and registers a temporary service |
| How it works | an ordinary Bluetooth GATT connection | hooks that driver host **in memory** (gone after a reboot) and erases the native action of the **mapped keys only** |
| Without frida installed | works as usual | this feature is unavailable; everything else is fine |
- **Blast radius**: it injects into the **Bluetooth HID driver host**, which also serves your other Bluetooth
keyboards/mice. We only rewrite the reports of the mapped keys, but if that host ever crashed your Bluetooth
input would drop with it (never seen in practice; if it happens, restart the client or re-plug the adapter).
- **Undocumented interfaces**: key mapping relies on an **undocumented IOCTL** of the Windows Bluetooth HID
driver and one registry path, and pins `frida >=17.18,<18`. A Windows or frida update can silently break it —
it degrades gracefully: voice keeps working, key mapping is disabled and the log says so.
- **Antivirus false positives**: frida's module names (`frida-agent.dll`, `frida-helper-*.exe`) are in several
vendors' signature sets. The offline bundle is ~61MB, ~47MB of which is frida — if your AV silently quarantines
it, key mapping will simply be broken after extraction (and it looks like our bug). Symptom: `keys.ready`
stays false after "Install key-mapping dependencies", or the log says frida is missing. Fix: whitelist the
program folder, or reinstall the dependency. We will **never** ask you to disable Defender or turn on test mode.
- **Protected games**: injecting into a SYSTEM process looks a lot like a cheat loader to anti-cheat. A few games
with kernel anti-cheat may refuse to start — the easy answer is to **quit the client before gaming** (or at
least don't use key mapping then).
- **Licence**: frida is wxWindows 3.1 (LGPL-2.1+ exception); bundling it is compliant, see
[`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
---
## 6. Download sources (installable in China without a VPN)
The program has built-in **multi-source automatic failover**: when one fails it moves to the next.
```
bundled wheels (fully offline) → Tencent Cloud (腾讯云) → USTC (中科大) → Aliyun (阿里云) → Huawei Cloud (华为云) → TUNA (清华) → official PyPI
```
This order is tried automatically; in the normal case the first source finishes the job and you never
have to think about it.
> Two notes:
> · **TUNA (清华) returns an empty index** (`from versions: none`), so it is last — don't pick it manually;
> · **NetEase 163 is not included**: its PyPI mirror stopped syncing long ago, so using it would install an outdated version.
> To change sources: edit the `PIP_MIRRORS` table at the top of `app.py`.
---
## 7. Troubleshooting
"Settings → **Environment check**" in the UI tells you item by item which layer is broken (dependencies / sound card / Bluetooth / permissions / autostart / Qianwen switch).
The "Logs" page is a live stream; when reporting a bug, just paste that section.
| Symptom | Cause | Fix |
|---|---|---|
| Logs are flooded with `connection timeout` | The remote is asleep | **Press any button on the remote**; it reconnects within 3 seconds |
| Logs are flooded with `missing ATVV characteristic` | Another client is holding the Bluetooth channel | Close the other client, then click "Restart" |
| **Every microphone level reads 0 / no audio gets in** (both the laptop mic array and the remote) | **The system default recording device was set to `CABLE Output`** and nothing feeds CABLE Input, so every program that uses the default microphone receives pure silence | Set the default input back to the **real microphone** under Settings → System → Sound → Input (the virtual cable should only appear in the **IME tool own** microphone setting). Click Settings → Diagnostics → **Microphone self-check**: it prints the default device and the measured peak, which shows immediately whether this is the cause |
| Pressing the voice button does nothing | Qianwen's "allow injection" is off | Click that button on the "Voice" page |
| **D-pad / OK / volume do nothing**, and the log keeps printing `Attach failed: TransportError: the connection is closed` | **Antivirus / EDR is blocking the injection** (**by far the most common case on a corporate PC**: the security software cuts Frida's injection channel) | Ask **IT to whitelist the program folder** (we never change system settings or disable Defender). Until then **voice keeps working** — only key mapping is unavailable. Click "Settings → Diagnostics → Key-mapping self-check" and it writes the evidence into the log |
| The log keeps printing `GATT Protocol Error: Insufficient Authentication` (or `missing ATVV characteristic`) | The remote's link was never properly **paired/encrypted** — this BLE peripheral insists on an encrypted link | Settings → Bluetooth & devices → **Remove device**, then hold 【**Back + Home**】 on the remote to pair it again, then click Restart. No drivers, no reinstall |
| No sound right after boot | The audio service / sound card became ready later than the program | **Wait 5–10 seconds** (the program retries every 5 seconds and reconnects on its own) |
| The log keeps printing `missing ATVV characteristic` and the UI shows an old config | Most likely **two instances** are alive, fighting over Bluetooth | Click "Restart the client" — it now **stops every old instance from this folder** (not just whichever holds the port) and keeps only the new one |
| Installing the virtual audio cable reports `Unknown Error 1072` | That service is stuck in the "marked for deletion" state (an earlier leftover cleanup deleted the service without stopping the driver first) — its "run as administrator" hint is misleading, you already are | **Reboot once**, then click "Install the virtual audio cable" again; if that still fails, right-click `vbcable\VBCABLE_Setup_x64.exe` → "Run as administrator" and click Install Driver |
The program writes its startup log to `client.log` in the same directory; if startup fails, the launcher shows a prompt and opens it automatically.
---
## 8. Uninstalling
Two steps, in this order:
1. In the UI, go to "Settings → Cleanup / Uninstall" and click "Run 'recommended cleanup'" → cleanup starts (disables autostart + removes Frida leftovers/services + deletes dependencies);
2. Then **just delete the entire program directory**.
The program is a purely portable deployment (no installer, nothing written to system directories), so after step 2 nothing remains on the system.
**The virtual audio cable is not covered by those two steps — it needs a note of its own.** The cleanup list has an
"Uninstall the virtual audio cable (VB-CABLE)" item, but it is **unticked by default and not part of "recommended cleanup"** —
because it is a **system-wide audio driver** that OBS, voice changers and streaming tools may also be using. If you do want it gone:
- tick it → cleanup prompts for UAC once and **launches the official uninstaller**, in whose window you still have to click **"Remove Driver"**;
- as VB-Audio requires, you **must reboot** afterwards to finalise it.
- It is therefore the only item in the list that still needs a human click *and* a reboot; the log says so explicitly.
**If installing/uninstalling gets stuck** (the registry says it is installed but no device is enumerated, and
"Install Driver" does nothing), the cleanup list also has **"Clear virtual-audio-cable leftovers (registry / driver
store)"**, unticked by default too. It only removes VB-CABLE's own entries (the two service keys,
`HKLM\SOFTWARE\VB-Audio\Cable`, the "Apps & features" entry, the driver package in the DriverStore, and
`C:\Program Files\VB\CABLE`) and **never touches Voicemeeter's keys or folders**, even though Voicemeeter shares the
VB-Audio vendor key. Reboot once afterwards, then click "Install the virtual audio cable" again.
(When the yellow "the virtual audio cable is not usable right now" banner shows up on the home page, that button is
right next to it.)
---
## 9. License
**MIT**, see [`LICENSE`](LICENSE). Personal use, study, modification and redistribution are all fine.
> For **commercial use** (selling it, integrating it into a commercial product, or offering a commercial service with it), please contact the author for authorization first.
> This is a request from the author, not an additional clause of MIT — the terms of the MIT license remain unchanged.
Licenses of the third-party components in the packaged distribution are in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
---
## If it works for you
Everything here — from the first line of code to the packaging — was written by one person. There are
**no ads, no telemetry, and no data collection of any kind**. If it saved you some time, a ⭐ is the
most concrete way to say thanks — and it helps the next person stuck with the same remote and the same
window-switching find it.
If something went wrong during setup, or your remote / IME behaves differently, please open an issue
(just paste the lines from the Logs page — that is what troubleshooting runs on).
---
## 9. Choosing a remote (genuine / compatible / clone)
This tool needs **two capabilities** from the remote, and they do **not** always come together:
| Capability | Used for | How to check |
|---|---|---|
| **ATVV voice service** (`ab5e0001`, audio `ab5e0003`) | hold to talk → text | the log does not say "missing ATVV characteristic" ✓ |
| **Standard HID key reports** (3-byte Consumer Control) | D-pad / OK / volume mapping | the log line "key mapping: attached to driver host … ← device `…_Dev_VID&0218d1_PID&9450_…`" |
**Measured on 2026-09-15 with two units both advertised as "G9N9N / GA01919"**:
- one works: it reports **VID `18D1` / PID `9450` / REV `011b`** (identical to the genuine one) — voice and keys both work ✓
- the other fails: GATT returns `Insufficient Authentication` and the service table has **only the four generic services** (no `ab5e…`) → voice can never work ✗; **replacing it with the same model fixed everything** ✓
So: **compatible units from the same listing and the same "model number" can differ a lot between batches**. When buying:
1. ask the seller "does it support **voice (ATVV)**?" and "is it a genuine G9N9N / GA01919?";
2. when it arrives, look at the **device identity** line in the log (`VID&0218d1_PID&9450` matches the genuine one ✓);
3. if the log itself says "this remote has **no voice service**" → do not suspect the software, **return it**.
**Known good**: the genuine **G9N9N** (Chromecast with Google TV voice remote) and the
**Google TV Streamer (2024) remote** (Google says it works with 2020+ Chromecasts, but this project has
**not tested it**). **Risky**: the ~¥15 "for Google" compatible units — many work, but batches vary; use
the three steps above.
+432
View File
@@ -0,0 +1,432 @@
# VibeMote · Google TV 遥控器 → Windows 输入设备
> 🌐 **中文** | [English](README.en.md)
把 **Google Chromecast / Google TV 蓝牙遥控器**变成 Windows 上的"说话 + 快捷键"设备:
**按住遥控器说话 → 遥控器麦克风 → 虚拟声卡 → 输入法出字**;14 个按键随便映射,
**上下=滚轮、左右=切会话、YouTube / NETFLIX = 审批的「同意 / 拒绝」**。
**下载 → 解压 → 双击 `启动遥控器.vbs`**,没有第四步(离线包自带 Python,目标机器不需要装任何东西)。
> ⬇️ **[下载最新版](releases/latest)**
> · `vibe-mote-offline-v1.zip`(约 61MB,**自带 Python + 完整功能**,解压就能跑 ← 推荐)
> · `vibe-mote-offline-voice-v1.zip`(约 14MB,**只语音、不含 frida** ← 怕杀软 / 公司电脑选它)
> · `vibe-mote-v1.zip`(约 900KB,目标机器已装 Python 3.10+ 时用)
> 更新日志 → [`CHANGELOG.md`](CHANGELOG.md) ·
> 让 AI 帮你装 → 把仓库地址和 [`AGENTS.md`](AGENTS.md) 一起丢给它
> ⚠️ **下载前先说清楚(30 秒版)**
> **语音功能**(按住说话出字)不需要管理员、不注入任何东西,前面那几个包都能用。
> **按键映射**(方向键/OK/音量)是靠**往系统的蓝牙 HID 驱动宿主里注入一个模块**实现的:要**一次**管理员权限
> (点过「一键优化」后不再弹),**可能被杀软误报/静默隔离**,少数带**内核反作弊的游戏**可能因此起不来,
> **公司/学校电脑**可能直接被策略拦掉。
> → 只要语音:下 `-voice` 包(不含 frida,包体也小),**不用点**任何「安装按键映射依赖」。
> → 要按键映射:完整包已经带了 frida,点一下界面的「安装按键映射依赖」即可;`-voice` 包之后再补 frida 有两条路 ——
> **① 联网**:点同一个按钮(自动走国内镜像,下载约 47MB);**② 不联网**:把 frida 的
> `frida-*-win_amd64.whl` 丢进程序目录的 `wheels\` 文件夹,再点那个按钮(会优先用包内文件)。
> 两种方式装完首次挂载都会要**一次**管理员(或先点「一键优化」)。
> 完整清单见 [第五节第 5 小节](#5-已知的限制与风险宁可提前说不藏在角落里)。
首页就是一张「**首次准备**」清单 —— 五项该做什么、必需还是可选、当前什么状态、按钮在哪,
全摆在一屏里;从上往下点一遍就能用。
| 遥控器按键 | 默认效果 |
|---|---|
| **语音键**(带话筒那个) | **按住说话** —— 遥控器麦克风 → 虚拟声卡 → 输入法出字 |
| 上 / 下 | 鼠标滚轮上 / 下(**按住不放连续滚**,轻点滚一格) |
| 左 / 右 | 切换上一个 / 下一个会话(预设可改) |
| OK / 返回 / 主页 / 电源 / 输入源 / 静音 / YouTube / NETFLIX / 音量± | 网页界面里随便改 |
| YouTube / NETFLIX | 审批的 **同意 / 拒绝**(`Enter` / `Esc`) |
网页控制台(`127.0.0.1:8787`):状态 · 按键映射(遥控器实物图)· 语音 · 设置 · 日志。
支持**预设方案**:选「目标应用(WorkBuddy / Codex / Claude Code / 通用)+ 输入法(千问 / 搜狗 / 微信 / Windows 自带)」,一键配好整套键位。
改键就在这一页:左边点遥控器上的任意键,右边改它发什么;预设方案一键配好一整套,
不满意可以「恢复推荐配置」,也能导出 / 导入。
---
> 🌐 **中英双语**:界面右上角可切换(首次打开按浏览器语言自动选,检测不出来就用中文),
> 英文文档见 [`README.en.md`](README.en.md)、[`说明.en.md`](说明.en.md) 与 [`AGENTS.en.md`](AGENTS.en.md)。
> 后端产生的主流程消息(依赖说明、预设方案、清理清单、环境体检)也是双语的;
> 底层诊断流水(音频帧、ATVV、frida 挂载细节)保持中文,那些是排障用的技术流水。
## 一、这个版本干净在哪
- ✅ **不装驱动**(没有虚拟 HID、没有内核过滤驱动、不碰 HidHide)
- ✅ **语音部分不需要管理员权限**。按键映射(可选)要**一次**提权 —— Frida 得往蓝牙 HID 驱动宿主里注入、
并注册一个临时助手服务;点一次「**一键优化**」之后由最高权限计划任务代劳,**之后不再弹**。
另外就是装虚拟声卡时那一次 UAC(那是声卡安装器自己的要求)。细节见第五节第 5 小节
- ✅ **不弹黑框**:单进程 + 全程隐藏窗口启动,开机自启也一个窗口都不闪
- ✅ **不开测试模式、不关 Secure Boot、不改系统文件**
- ✅ **界面只监听 `127.0.0.1`**,不触发防火墙弹窗
- ✅ **代码极少**:9 个源文件、**第三方依赖只有 3 个**(连 numpy 都不需要)
---
## 二、依赖清单(要装什么、什么版本)
### 运行环境
| 项 | 要求 | 说明 |
|---|---|---|
| 操作系统 | **Windows 10 / 11 x64** | 用到了 WinRT 蓝牙 API 与 SendInput |
| Python | **3.10 或更高** | 官网安装包,安装时**务必勾选 `Add python.exe to PATH`** |
| 虚拟声卡 | **VB-CABLE**(VB-Audio Virtual Cable) | 语音必备前置。装它需要一次管理员权限,**装完建议重启一次** |
> 用**离线全量包**的话,Python 和下面这些包都不需要你管,包里已经装好了。
### Python 包(`requirements.txt`)
| 包 | 版本 | 用途 |
|---|---|---|
| `bleak` | **3.0.2** | 蓝牙 GATT:语音键状态通道 + 遥控器麦克风音频 |
| `sounddevice` | **0.5.6** | 音频输出/输入(PortAudio 绑定),把遥控器麦克风送进虚拟声卡 |
| `cffi` | ≥1.16 | `sounddevice` 的依赖,自动带上 |
### 按键映射依赖(`requirements-keys.txt`,**可选**)
| 包 | 版本 | 用途 |
|---|---|---|
| `frida` | ≥17.18 <18 | **按键映射必需**(约 130MB):注入蓝牙驱动宿主读 HID 报文。装在包内 `.venv`,用完即走 |
> **不装 frida 也完全能用** —— 语音键走 ATVV 通道,不依赖它。只是方向键/OK 那些映射不了。
> 装 frida 时首次挂载会要一次 UAC(Frida 给自己装助手);跑一次本项目的「一键优化」后,
> 自启改成最高权限计划任务,**之后一次都不会再弹**。
### 明确**不需要**的东西(别被老教程带偏)
❌ WinUHid / 虚拟 HID 驱动 ❌ 测试签名 / 关闭 Secure Boot ❌ HidHide ❌ Interception 驱动 ❌ 管理员常驻权限
---
## 三、安装
### 路线 0:最省事(全新用户就两步)
```
① 双击「启动遥控器.vbs」 → 界面自己打开(没有黑框)
② 顶部横幅点「全部安装」 → 装完点「重启客户端」→ 完事
```
**全程不用敲一行命令**,装依赖也不用管理员权限(Frida 的**首次挂载**要一次提权,见第五节第 5 小节)。
程序会自动挑国内镜像下载
(腾讯云 → 中科大 → 阿里云 → 华为云 → 官方),装到程序目录里的 `.venv`,
不污染系统 Python。
> 只有 Python 需要你先自己装好([python.org](https://www.python.org/downloads/),
> 安装时**务必勾选 `Add python.exe to PATH`**)。
>
> **完全不想装 Python?**(或者要拷 U 盘给别人的机器用)→ 用下面的「路线 D 离线全量包」,
> 它自带 Python,什么都没装的电脑双击就能跑。
第一次打开界面时,横幅会直接告诉你缺什么、点哪个按钮,装依赖的**进度会自己跳到「设置 → 诊断」并滚到眼前**。
### 路线 A:手动(3 步)
```bat
:: 1) 进程序目录,建虚拟环境(不污染系统 Python,也不需要管理员)
python -m venv .venv
.venv\Scripts\python -m pip install -r requirements.txt
:: 2) 按键映射才需要(约 130MB,可选)
.venv\Scripts\python -m pip install -r requirements-keys.txt
:: 3) 启动
双击 启动遥控器.vbs
```
界面打开后点「安装基础依赖 / 安装按键映射依赖」也一样 —— **程序会自动轮询国内镜像**。
### 路线 B:让 AI 帮你装
把**这个仓库的地址**和下面这段话一起发给你常用的 AI(ChatGPT / Claude / 豆包 / WorkBuddy 都行):
> 请读 `<仓库地址>` 里的 `AGENTS.md`,按它的步骤在我的 Windows 电脑上装好这个遥控器工具。
> 我的系统是 Windows 11。遇到不确定的地方先问我,不要自己装驱动或改系统设置。
`AGENTS.md` 是给 AI 看的执行手册:精确步骤、验证命令、失败排查表、禁止事项。
它照着做就行,不需要用户懂 Python。
### 路线 C:离线 / U 盘部署(没网或不想联网)
在一台**能上网**的机器上把依赖下成 wheel 包,塞进 `wheels\` 再整个拷走:
```bat
python -m pip download -r requirements.txt -d wheels
python -m pip download -r requirements-keys.txt -d wheels :: 可选,约 130MB
```
程序**检测到 `wheels\` 目录就会优先用它**(`--no-index --find-links`),全程不联网。
整个包本体不到 1MB。
> 这条路仍然要求目标机器**装了 Python**(wheels 只是包,不是解释器)。
### 路线 D:离线全量包(没装 Python 也能用 / 拷 U 盘给别人)
`vibe-mote-offline-v1.zip`(约 61MB)**自带 Python 运行时 + 已装好的依赖**:
```
对方机器上:解压 → 双击「启动遥控器.vbs」→ 直接用
(不需要装 Python、不需要联网;按键映射仍要那一次提权,见第五节第 5 小节)
```
包里装着什么(以及为什么这么大):
| 组成 | 大小 | 说明 |
|---|---|---|
| Python 3.13 官方 **embeddable** 包 | 10.4MB | 官方专门为"塞进应用里直接跑"提供的发行版 |
| bleak / sounddevice / cffi / winrt-* | 约 5MB | 语音必需 |
| frida | 47.3MB | 按键映射必需(这是包体的大头) |
> 界面上那两个「安装依赖」按钮在离线包上会直接告诉你"依赖已内置、不用装"。
> 📌 **内置的 `runtime\` 属于程序本体,清理工具不会删它。**
> 因为界面上的「全部安装」装的是 pip 包,**装不回**一个 Python 发行版 ——
> 删了就再也回不到"免安装"状态了。所以清理清单里干脆不提供这个选项。
---
## 四、第一次使用要做的 3 件事
**界面首页顶部有一张「首次准备」清单**,把这 5 项的状态和按钮都摆出来了,
照着从上到下点一遍就行(不用去「设置」页里翻):
| 清单项 | 必需? | 一行说明 |
|---|---|---|
| 运行依赖 | **必需** | bleak / sounddevice(语音)+ frida(按键映射)。点「全部安装」 |
| 虚拟声卡 | **必需** | 语音的物理前提。点「一键安装」 |
| 千问「允许注入」 | 可选 | 用千问输入法才需要;程序**首次启动会自动打开** |
| 开机自启 | 可选 | 一键开关,不需要管理员(想连 Frida 的 UAC 也一起去掉,就再点「一键优化」,那一下要管理员) |
| 一键优化(免 UAC) | 可选 | 做了之后 Frida 不再弹提权框 |
下面是手动的对应步骤:
1. **配对遥控器**:Windows 设置 → 蓝牙和其他设备 → 添加设备 → 同时按住遥控器 **返回 + 主页** 进配对模式
2. **装 VB-CABLE**:界面首页清单或「设置 → **虚拟声卡**」那张卡上点**一键安装** ——
包内带了官方安装器就直接装,没带会先从官网下载(约 1.3MB)。
- ⚠ **会弹一次 UAC**(那是声卡安装器自己的要求,与本程序无关),
然后**在它自己的窗口里点一下「Install Driver」**,最后按 VB-Audio 的官方要求**重启一次系统**。
- **首次启动时程序会自动帮你把这一步拉起来**(只自动试一次,你拒了 UAC 就不会再烦你)。
**开机自启拉起的那一份不会弹**(自启命令带 `--boot`):开机时你不在电脑前,弹了也没人点,
而且会变成每次开机都弹一次。
- 官方安装器**不提供静默安装参数**(没有 `-i`/`-s`/`/S` 这类开关),所以"自动"能省掉的只是
"自己去找那个按钮",那一下确认和重启省不掉。
3. **输入法里选麦克风**:选 **`CABLE Output (VB-Audio Virtual Cable)`**
(只改输入法这一处,系统默认录音设备保持真实麦克风)
> ❓ **"虚拟声卡的 in / out 看起来是反的"——不是装反了,是命名角度问题。**
> VB-CABLE 的两个端点是从**线缆自己**的角度命名的:
>
> | 你在 Windows 哪里看到它 | 名字 | 它是给谁用的 |
> |---|---|---|
> | **播放设备**(扬声器那一栏) | `CABLE Input` | **程序往里写** —— 本程序把遥控器的声音写进这里 |
> | **录制设备**(麦克风那一栏) | `CABLE Output` | **程序从它读** —— **输入法要选的就是这一个** |
>
> 所以:**输入法的麦克风列表里只出现 `Output`,是完全正确的** ✓(输入法在"录");
> 而"程序往 `Input` 写"那一端你在麦克风列表里永远看不到 ✓。
> 链路是:`遥控器麦克风 → 本程序 → CABLE Input(播放)→ 虚拟线 → CABLE Output(录制)→ 输入法`。
> 没声音时按这个顺序查:① 本程序日志那行「音频出口已开启 → 写入「CABLE Input…」」在不在;
> ② 输入法选的是不是 `CABLE Output`;③ **`CABLE Input` 和 `CABLE Output` 两边的音量和静音**
> (重装声卡后常有一侧被静音或音量 0)。
> 想立刻验证:`设置 → 诊断 → 麦克风自检` 会把这两个名字和实测峰值一起打出来。
> **千问输入法用户不用做任何事**:千问默认会丢弃程序注入的按键,所以程序在**首次启动时
> 会自动帮你打开**「允许注入」开关(写用户环境变量、重启一次千问进程,不需要管理员)。
> 这个开关随时能在「语音」页手动开关 —— 你在「清理」里删过它的话,程序会记住、
> 不再自动加回来,想恢复点那个按钮就行。
---
## 五、它到底会动你电脑的什么(完整清单)
这是问得最多的一个问题,所以把话说全 —— **程序本体是纯绿色部署**:不装驱动、不写系统目录、
不需要管理员常驻(**按键映射要一次提权,以及它带来的限制/风险,单独写在第 5 小节**)。
下面是它**全部**会碰的东西,一项不漏:
### 1. 写在程序自己的目录里(删掉整个目录就没了)
`config.json`(配置)· `client.log`(日志)· `uninstall.log` · `qwen_optout`(你删过千问开关的标记)
· `cable_autotried`(自动装过声卡的标记)· `_ready.txt` · `_deferred_result.txt` · `_uninstall_result.json`
### 2. 系统里的设置改动 —— **每一项都是你自己点出来的,且都能一键撤销**
| 动什么 | 什么时候会动 | 怎么撤 |
|---|---|---|
| `HKCU\...\CurrentVersion\Run\VibeMote` | **只有**你打开「开机自启」 | 界面关掉,或清理清单里勾那一项 |
| 最高权限计划任务 `VibeMote` | **只有**你点「一键优化」 | 清理清单里勾「取消开机自启(计划任务)」 |
| `HKCU\Environment\QIANWEN_IME_UTILITY_VOICE_HOOK_ALLOW_INJECTED=1` | 装了千问输入法时自动写(也可以不写) | 界面按钮,或清理清单里勾那一项 |
| Frida 的临时助手服务 `frida-<pid>-x86*` | **Frida 自己**在挂载时注册的 | 清理清单里那一项;点过「一键优化」之后不再需要 |
**不做的事**(写下来是为了让你放心):不装驱动、不碰 `System32` / `Program Files`、
不改输入法在注册表里的配置、不开测试模式、不动 Secure Boot、不关 Defender、不常驻管理员。
联网只有两处:你自己点「安装依赖」,或点「安装虚拟声卡」而包内没带安装器时(去 VB-Audio 官网下)。
### 3. 唯一会动到系统深处的两件事:**都由第三方安装器完成,程序只是替你启动它**
| 东西 | 谁装的 | 需要什么 |
|---|---|---|
| **VB-CABLE** 虚拟声卡(内核音频驱动) | **VB-Audio 自己的安装器** | 一次 UAC + 重启 |
| **Frida** 的助手进程 | Frida 自己的机制 | 首次挂载一次 UAC |
> ⚠ **必须提前知道的一件事:VB-CABLE 官方的卸载器卸不干净。**
> 点「Remove Driver」并重启之后,驱动可能**仍然存在**(设备管理器里状态正常、音频端点全部重新出现)。
> 原因是它留下了 DriverStore 里的驱动包、`C:\Windows\inf\oem634.inf` 和几个服务键,
> Windows 重启时照着又装了回去。
> 想**真正**删掉,得再跑一次管理员命令:
> ```
> pnputil /delete-driver oem634.inf /uninstall /force
> ```
> (`oem634.inf` 这个编号每台机器可能不同,用 `pnputil /enum-drivers` 找
> `Original Name: vbmmecable64_win10.inf` 那一条。)
> 这是 VB-Audio 卸载器自己的行为,与本程序无关 —— 但界面会照实说明,不会假装删干净了。
### 4. 会不会把别人的电脑弄坏?
不会。它没有内核驱动、不注册自己的常驻服务(Frida 挂载时会临时注册一个**助手服务**,点过「一键优化」
之后连这个都不需要)、不写系统目录,最坏情况只是"某个功能不工作 + 一条日志说明为什么"。
真正需要小心的两点已经在上面写明了:VB-CABLE 的安装/卸载要 UAC + 重启(那是它的要求),
以及清理时**默认什么都不勾** —— 只有你自己勾了才会删东西。
> 另外提醒:**同一时间只能有一个客户端连这个遥控器**(遥控器只给第一个客户端完整的服务表)。
> 如果你机器上还跑着别的同类工具,本程序会报「缺少 ATVV 特征」。先关掉其它客户端再试。
### 5. 已知的限制与风险(宁可提前说,不藏在角落里)
**按键映射(方向键/OK/音量那些)和语音是两套东西,代价也不一样:**
| | 语音(遥控器说话 → 出字) | 按键映射(14 个键 → 键盘/鼠标) |
|---|---|---|
| 需要管理员吗 | **完全不需要** | **需要一次提权**:Frida 要往蓝牙 HID 驱动宿主里注入,并注册一个临时服务 |
| 靠什么实现 | 普通蓝牙 GATT 连接 | 在**内存里**钩住那个驱动宿主(重启就没了),并把**被映射那几个键**的原生动作抹掉 |
| 不装 frida 会怎样 | 照常工作 | 这个功能不可用,其余一切正常 |
- **爆炸半径**:注入的是**蓝牙 HID 驱动宿主**,它同时服务你其他的蓝牙键鼠。我们只改被映射那几个键的
报文,但万一这个宿主崩了,**蓝牙键鼠会一起掉线**(实测没遇到过;真发生就重启客户端或拔插一下适配器)。
- **依赖未公开接口**:按键映射用的是 Windows 蓝牙 HID 驱动的一个**未公开 IOCTL** 和一个注册表路径,
并锁定 `frida >=17.18,<18`。Windows 或 frida 更新后它**可能静默失效** —— 失效时程序会优雅降级:
语音照常,按键映射停用并在日志里说明。
- **杀软可能误报/误隔离**:frida 的模块名(`frida-agent.dll`、`frida-helper-*.exe`)在多家 AV 的特征库里。
离线包约 61MB,其中 frida 约 47MB —— 被静默隔离的话,解压出来"按键映射"就是坏的(用户会以为是我们 bug)。
症状:界面「安装按键映射依赖」后 `keys.ready` 一直 false、或日志说找不到 frida。
处理:把程序目录加进 AV 白名单,或重新装一次依赖。我们**不会**让你关 Defender、开测试模式。
- **受保护的游戏**:往 SYSTEM 进程里注入这件事,行为特征和作弊器加载器同类。少数带内核反作弊的游戏
可能因此起不来或报错 —— **玩游戏前把客户端退出**(或至少别用按键映射)是最省事的做法。
- **许可**:frida 是 wxWindows 3.1(LGPL-2.1+ 例外),随包分发合规,细节见
[`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
---
## 六、下载源(国内没 VPN 也能装)
程序内置**多源自动轮询**,一个失败就换下一个,顺序是:
```
包内 wheels(纯离线)→ 腾讯云 → 中科大 USTC → 阿里云 → 华为云 → 清华 TUNA → 官方 PyPI
```
正常情况下第一个源就能装完,不需要你操心。
> 两点提示:
> · **清华 TUNA 会返回空索引**(`from versions: none`),所以排在最后,别手动指定它;
> · **没有收录网易 163**:它的 PyPI 镜像早已停止同步,用它会装出老版本。
> 想换源:改 `app.py` 顶部的 `PIP_MIRRORS` 表。
---
## 七、出问题
界面「设置 → **环境体检**」会逐项告诉你哪一层断了(依赖 / 声卡 / 蓝牙 / 权限 / 自启 / 千问开关)。
「日志」页是实时流,报 bug 时把那段贴出来就够了。
| 现象 | 原因 | 解决 |
|---|---|---|
| 日志刷 `连接超时` | 遥控器在休眠 | **按一下遥控器任意键**,3 秒内自动连上 |
| 日志刷 `缺少 ATVV 特征` | 有另一个客户端占着蓝牙通道 | 关掉其它客户端再点「重启」 |
| **所有麦克风电平都是 0、声音进不去**(电脑麦克风阵列和遥控器都一样) | **系统默认录音设备被设成了 `CABLE Output`** —— 而没人往 CABLE Input 灌声音,于是所有「用默认麦克风」的程序收到的都是纯静音 | 「设置 → 系统 → 声音 → 输入」把默认设备改回**真实麦克风**(虚拟声卡只该出现在**输入法自己的**麦克风设置里)。点一次「设置 → 诊断 → **麦克风自检**」,它会报出默认设备和实测峰值,一眼看出是不是这个问题 |
| 按语音键没反应 | 千问的「允许注入」没开 | 「语音」页点一下那个按钮 |
| **方向键 / OK / 音量没反应**,日志反复写 `挂载失败:TransportError: the connection is closed` | **杀软 / EDR 拦了注入**(**公司电脑上最常见**:安全软件掐断 Frida 的注入通道) | 请 **IT 把程序目录加进杀软白名单**(我们不改系统设置、不关 Defender)。放行之前**语音照常可用**,只有按键映射不行。点一次「设置 → 诊断 → 按键映射自检」,它会把这行证据写进日志 |
| 日志刷 `GATT Protocol Error: Insufficient Authentication`(或一直「缺少 ATVV 特征」) | 遥控器的链路没完成**配对/加密** —— 蓝牙外设坚持要一条已加密的连接 | 「设置 → 蓝牙和其他设备」把遥控器**删除设备** → 按住遥控器【**返回 + 主页**】重新配对 → 回程序点「重启」。别动驱动、别重装程序 |
| **用着用着发现客户端没了 / 开机没自动起** | 计划任务只在**登录那一刻**跑一次,进程异常退出后没人拉它回来 | 点一次「设置 → **一键优化**」重建任务:新版任务带**每 5 分钟自检**,进程万一挂了最多 5 分钟自动回来(已经在跑时新进程秒退,不弹窗不刷日志) |
| **按键都能用、只有语音键不行**(日志刷「没扫到广播(多半被系统 HID 拿着连接)」) | BLE 遥控器只允许**一条**连接,系统 HID 正攥着它,程序建不起自己的语音通道 | **程序会自动解卡**:连续几次出现这行日志,它就自己把蓝牙**关开一次**(不需要管理员、每次都读回状态确认、10 分钟最多一次)。手动备选:按一下遥控器任意键叫醒 → 设置里关开一次蓝牙(**记得开回来**)→ 遥控器**拔电池 10 秒** |
| **蓝牙设备全没了 / 设置里连"蓝牙"开关都不见了** | 蓝牙**电台被禁用了**(Windows 的蓝牙开关本质就是禁用那个 USB 电台;关掉后忘记开回来、或设备管理器里禁用过) | 一、`Win+X → 设备管理器 → 蓝牙` → 右键 `Intel(R) Wireless Bluetooth(R)` → 「**启用设备**」;二、不行就**完全关机**(开始菜单「关机」,不是重启)再开机;三、新版客户端里点「设置 → 诊断 → **重置蓝牙电台**」也会先帮你把电台启用回来(只启用,不会再关开)。**不是硬件坏了,设备都在** |
| 刚开机没声音 | 音频服务/声卡比程序晚就绪 | **等 5~10 秒**(程序每 5 秒重试,会自己接上) |
| 日志刷 `缺少 ATVV 特征`、界面显示的还是旧配置 | 多半有**两个实例**同时活着抢蓝牙 | 点「重启整个客户端」即可 —— 现在它会**把本目录里所有旧实例一起结束掉**(不再只看谁占着端口),只保留新起来的那一个 |
| 装虚拟声卡时报 `Unknown Error 1072` | 那个服务停在「待删除」状态(之前清残留只删了服务、没先停驱动)—— 它提示"请用管理员运行"是误导,你本来就是管理员 | **重启一次**,再点「一键安装虚拟声卡」;还不行就把 `vbcable\VBCABLE_Setup_x64.exe` 右键「以管理员身份运行」点 Install Driver |
程序会把启动日志写到同目录的 `client.log`;启动失败时启动器会弹提示并自动打开它。
---
## 八、卸载
两步,顺序别反:
1. 界面「设置 → 清理 / 卸载」点「选『推荐清理』」→ 开始清理(断开机自启 + 清 frida 残留服务 + 删依赖);
2. 然后**直接删掉整个程序目录**。
程序是纯绿色部署(没有安装器、不写系统目录),第 ② 步之后系统里不会有任何残留。
**虚拟声卡不在这两步里,要单独说一句**:清理清单里有一项
「卸载虚拟声卡(VB-CABLE)」,但**默认不勾、也不属于「推荐清理」** —— 理由是
它是**系统级音频驱动**,OBS / 变声器 / 直播工具可能也在用它。真要卸的话:
- 勾上它 → 清理时会弹一次 UAC 并**启动官方的卸载器**,
你还要在它窗口里点一下「**Remove Driver**」;
- 按 VB-Audio 的官方要求,**卸完必须重启一次**系统才算卸完。
- 所以它是清单里唯一一个"点完还要人动手 + 重启"的项,日志里都写明了。
**装/卸卡住了怎么办**(注册表里显示装了、设备却枚举不出来,点 Install Driver 没反应):
清理清单里还有一项「**清除虚拟声卡残留(注册表 / 驱动包)**」,默认也不勾。
它只删 VB-CABLE 自己的那几处(两个服务键、`HKLM\SOFTWARE\VB-Audio\Cable`、
「应用和功能」里那条、DriverStore 里的驱动包、`C:\Program Files\VB\CABLE`),
**同属 VB-Audio 的 Voicemeeter 的键和目录一个都不会碰**。清完**重启一次**,再点「一键安装虚拟声卡」。
(首页出现「虚拟声卡现在用不了」那个黄色横幅时,旁边的按钮就是它。)
---
## 九、开源协议
**MIT**,见 [`LICENSE`](LICENSE)。个人使用、学习、修改、二次分发都没问题。
> **商用**(对外销售、集成进商业产品、以它提供商业服务)请先联系作者获得授权。
> 这是作者的一个请求,不是 MIT 的附加条款 —— MIT 的授权条件保持不变。
打包分发的第三方组件许可见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
---
## 如果它好用
这个项目从第一行代码到打包都是一个人写的,**没有广告、没有埋点、不收集任何数据**。
如果它帮你省了时间,给个 ⭐ 就是最实在的支持 —— 也能让更多拿着同一个遥控器、
正在忍受"来回切窗口"的人搜到它。
装的过程中卡住了、或者你的遥控器/输入法有不一样的表现,欢迎开 issue 说一声
(把日志页那几行贴上就行,排障全靠它)。
---
## 九、遥控器怎么挑(原装 / 兼容款 / 山寨)
这个工具依赖遥控器的**两个能力**,而它们**不一定同时存在**:
| 能力 | 用在哪 | 怎么判断 |
|---|---|---|
| **ATVV 语音服务**(`ab5e0001`,音频 `ab5e0003`) | 按住说话 → 输入法出字 | 日志里没有「缺少 ATVV 特征」就是有 ✓ |
| **标准 HID 按键报文**(3 字节 Consumer Control) | 方向键 / OK / 音量等映射 | 日志「按键映射:挂上蓝牙驱动宿主 … ← 设备 `…_Dev_VID&0218d1_PID&9450_…`」 |
**实测(2026-09-15,两支都标着「G9N9N / GA01919」的兼容款)**:
- 一支正常:设备报告 **VID `18D1` / PID `9450` / REV `011b`**(跟原装一致),语音 + 按键都能用 ✓
- 另一支失败:GATT 直接报 `Insufficient Authentication`,服务表里**只有 4 个通用服务**(没有 `ab5e…`)
→ 语音不可能工作 ✗;**换一支同款就正常了** ✓
结论:**同一链接、同一型号的兼容款,不同批次能差很多**。挑的时候:
1. 先问卖家「**支持语音(ATVV)吗**」「是不是原装 G9N9N / GA01919」;
2. 到手就看日志那行**设备身份**(`VID&0218d1_PID&9450` 是与原装一致的表现 ✓);
3. 如果日志直接说「这台遥控器**没有语音服务**」→ 别怀疑软件,**退换货**。
**已知可用**:原装 **G9N9N**(Chromecast with Google TV 语音遥控器)、
**Google TV Streamer(2024)遥控器**(Google 说它兼容 2020+ 的 Chromecast,但本项目**未实测**)。
**风险款**:十几块钱的「适用谷歌」兼容款 —— 能用的不少,但批次差异大,按上面三步挑。
+61
View File
@@ -0,0 +1,61 @@
# 第三方组件声明
本项目的**自有代码**采用 MIT 许可(见 [`LICENSE`](LICENSE))。
下列第三方组件由本项目**打包分发**(主要在 `vibe-mote-offline-v1.zip` 里),
它们各自的许可**独立于**本项目,且优先于本项目条款适用于这些组件本身。
| 组件 | 版本 | 许可 | 用途 |
|---|---|---|---|
| Python 运行时 | 3.13 | PSF License Agreement | 离线包内置的解释器 |
| [frida](https://frida.re/) | 17.18.0 | wxWindows Library Licence 3.1(LGPL-2.1 加例外) | 读取蓝牙 HID 报文,实现按键映射 |
| [bleak](https://github.com/hbldh/bleak) | 3.0.2 | MIT | 蓝牙 GATT:语音键状态 + 麦克风音频 |
| [sounddevice](https://github.com/spatialaudio/python-sounddevice) | 0.5.6 | MIT | 音频输入输出(PortAudio 绑定) |
| [cffi](https://cffi.readthedocs.io/) | 2.1.1 | MIT | sounddevice 的依赖 |
| [pycparser](https://github.com/eliben/pycparser) | 3.0 | BSD-3-Clause | cffi 的依赖 |
| [winrt-runtime](https://pypi.org/project/winrt-runtime/) | 3.2.1 | MIT | bleak 在 Windows 上的依赖 |
| [typing_extensions](https://pypi.org/project/typing-extensions/) | 4.16.0 | PSF-2.0 | 类型标注兼容层 |
| [VB-CABLE](https://vb-audio.com/Cable/)(安装器 + 驱动) | Driver Pack 45 | VB-Audio 自有许可(donationware,见下) | 虚拟声卡:把遥控器麦克风送进输入法 |
## 许可原文在哪
- 离线包:上述 Python 组件的各自 `LICENSE` / `METADATA` 随包分发,可在
`runtime\Lib\site-packages\<组件>-<版本>.dist-info\` 下找到;
内置 Python 的许可原文在 `runtime\LICENSE.txt`。
- 轻包:依赖由 `pip` 从 PyPI 安装,许可文件在对应包目录里。
- **VB-CABLE**:许可原文随包分发(`vbcable\readme.txt`,官方原文件),
也在 VB-Audio 官网 <https://vb-audio.com/Cable/> 与其安装器的许可页上。
## frida 的特别说明
frida 采用 wxWindows Library Licence 3.1(LGPL-2.1 附「静态链接例外」)。
本项目以**独立文件**形式分发它(未静态链接进自有程序),
使用者可以自行替换 `runtime\Lib\site-packages\frida\` 下的文件。
## VB-CABLE 的特别说明
**VB-CABLE**(VB-Audio Virtual Cable)是本项目语音功能所需的系统级虚拟声卡,
作者是 **Vincent Burel(VB-Audio Software)**,版权与许可归其所有,
本项目**不主张任何权利**。
- **两个 zip 分发包里都带了它的原版文件**(`vbcable\` 下的
`VBCABLE_Setup_x64.exe`、`VBCABLE_ControlPanel.exe`、`vbMmeCable64_win10.inf`、
`vbaudio_cable64_win10.sys`、`vbaudio_cable64_win10.cat`、`readme.txt`),
全部是官网原文件、**一个字节都没改**,这样用户没网也能一键装好虚拟声卡。
仓库 **git 里不含**这些文件。
- **官方许可(`vbcable\readme.txt` 原文要点)**:
- VB-CABLE 是 **donationware**:允许**原样(AS IS)复制与分发**整个包,
但**不允许在未经作者同意的情况下把 VB-CABLE 包集成进另一个软件的安装流程**;
分发时应当注明来源 <https://www.vb-cable.com>,并说明它是 donationware。
- 本项目只做两件事:把**原版文件原样**放进包里,以及**替你点一下它自己的安装器**
(用的是它自己的 GUI,用户仍要自己点「Install Driver」并同意它的许可)。
我们**没有**修改、重打包或静默安装它。若作者认为这仍属"集成",
请联系我们,我们会把 `vbcable\` 从包里去掉 —— 界面上的「安装虚拟声卡」
会自动改为去官网下载(程序本身一直是这个兜底逻辑)。
- 用着顺手的话,建议去 <https://vb-cable.com/> 给作者捐一份。
- 安装需要一次管理员权限,那是**它自己的安装器**要求的,与本项目无关。
- **官方要求:装完必须重启一次系统才算装完**(官方 readme 原文
"To finalize installation, you must reboot your computer.")。
卸载同理 —— 本程序的清理清单里那一项也只是把它的卸载器拉起来,
用户仍需点它窗口里的「Remove Driver」,然后重启。
- 如果你要**再分发**本项目的 zip,请先自行确认 VB-Audio 的条款;
不合规时请从包里删掉 `vbcable\` 目录。
+3359
View File
File diff suppressed because it is too large. Load diff
+435
View File
@@ -0,0 +1,435 @@
# -*- coding: utf-8 -*-
"""一次性优化(需要管理员,由主程序用 UAC 拉起 —— **只会弹这一次**)。
干三件事:
1. **清掉 Frida 的历史残留**。Frida 每次挂载不属于自己的进程时,都会尝试注册
一个临时服务 `frida-<pid>-x86/x86_64` 来注入;注册服务需要管理员,于是每
次尝试都会弹一次 UAC;没批准或中途中断就留下一条死服务。残留会随重启不断累积,
攒到几十条服务 + 一堆 frida-helper 进程都很常见。
2. **装一个「最高权限 + 登录时触发」的计划任务来做开机自启**。
为什么不用更简单的 HKCU Run 项:普通权限启动时,Frida 装它的助手要提权,
于是**每次开机都要弹一次 UAC**。改成最高权限的计划任务后,开机由任务计划
程序以管理员身份静默启动 —— Frida 要的权限都是现成的,**再也不会弹**。
3. 删掉 HKCU Run 里的普通权限自启项,避免两份同时启动抢遥控器的蓝牙通道。
过程写进同目录的 elevate_setup.log,非提权的主程序会读它并显示给你看。
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
import winreg
import xml.sax.saxutils as sx
HERE = os.path.dirname(os.path.abspath(__file__))
LOG = os.path.join(HERE, "elevate_setup.log")
XML = os.path.join(HERE, "_task.xml")
TASK_NAME = "VibeMote"
VBS = os.path.join(HERE, "启动遥控器.vbs")
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
CREATE_NO_WINDOW = 0x08000000
def log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
def is_admin():
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def _dec(b: bytes) -> str:
"""子进程输出编码不能写死:schtasks/sc 吐 GBK,python 子进程吐 UTF-8。
写死一个就会把中文变成「涓嶅彲鐢」那种乱码。"""
for enc in ("utf-8", "gbk", "mbcs"):
try:
return b.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return b.decode("utf-8", "replace")
def run(cmd, timeout=60):
try:
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
creationflags=CREATE_NO_WINDOW)
return p.returncode, _dec(p.stdout or b"") + _dec(p.stderr or b"")
except Exception as e:
return -1, repr(e)
# ---------------------------------------------------------------- 1. 清残留
def cleanup_frida():
total_killed = 0
for exe in ("frida-helper-x86.exe", "frida-helper-x86_64.exe"):
rc, out = run(["taskkill", "/IM", exe, "/F"])
killed = out.lower().count("success") + out.count("成功")
total_killed += killed
if total_killed:
log(f"已结束 {total_killed} 个 frida-helper 进程")
else:
log("没有 frida-helper 进程在跑")
names = []
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Services") as k:
i = 0
while True:
try:
names.append(winreg.EnumKey(k, i))
except OSError:
break
i += 1
except OSError as e:
log(f"读服务列表失败:{e}")
return
targets = [n for n in names if n.lower().startswith("frida-")]
log(f"发现 {len(targets)} 条 frida-* 残留服务,开始删除…")
ok = 0
for n in targets:
rc, _ = run(["sc.exe", "delete", n])
if rc == 0:
ok += 1
log(f"已删除 {ok}/{len(targets)} 条")
# ---------------------------------------------------------------- 2. 装任务
def task_xml(run_level="HighestAvailable"):
user = os.environ.get("USERDOMAIN", "") + "\\" + os.environ.get("USERNAME", "")
# ★ 必须带 `--boot`:它标记"这一份是开机自启拉起来的",
# 程序靠它关掉"首次启动自动拉起虚拟声卡安装器"那条路(见 app.py 里 auto_ok 的注释)。
# 漏了的话,开机时如果声卡不在了就会在开机那一刻弹安装器 + UAC —— 那正是
# 用户最不想要的"开机弹窗"。
# ★ 另外给登录触发器加了**每 5 分钟重复一次**(见下面 XML):这台机器上真出现过
# "客户端不知什么时候没了、开机自启也没把它拉回来"(任务只在登录那一刻跑一次)。
# 有了重复,进程若异常退出,最多 5 分钟就会自己回来;已经在跑时新进程看到端口在听
# 会立刻退出,等于什么都没做(不弹窗、不刷日志)。
args = sx.escape(f'"{VBS}" --silent --boot')
return f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>VibeMote - Google TV remote bridge (voice key + key mapping)</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<Delay>PT8S</Delay>
<Repetition>
<Interval>PT5M</Interval>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
</LogonTrigger>
<BootTrigger>
<Enabled>false</Enabled>
</BootTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{sx.escape(user)}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>{run_level}</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RestartOnFailure>
<Interval>PT1M</Interval>
<Count>3</Count>
</RestartOnFailure>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>wscript.exe</Command>
<Arguments>{args}</Arguments>
</Exec>
</Actions>
</Task>
"""
def looks_like_dev_copy():
"""这个目录是不是**开发副本**(源码仓库)而不是用户装好的那一份。
为什么需要(真机踩过,很坑):开发副本被跑起来、并且在**它的**界面上点了「一键优化」,
于是**计划任务被改成指向开发目录** —— 从此开机启动的是开发副本(配置还不一样),
用户看到的是"程序怎么又不对了 / 收音又有问题"。仓库里有这些开发脚本、用户那边没有,
拿它们当指纹最省事。
"""
for name in ("_package.py", "_build_offline.py", "_verify_zips_match_head.py"):
if os.path.exists(os.path.join(HERE, name)):
return True
return False
def install_task(run_level="HighestAvailable", name=TASK_NAME):
# ★ 守卫:开发目录不许改写/安装自启任务(否则会把用户那份的开机启动顶掉)。
# 确实需要在本机开发目录测试时,设环境变量 VIBEMOTE_ALLOW_DEV_TASK=1 绕过。
if looks_like_dev_copy() and os.environ.get("VIBEMOTE_ALLOW_DEV_TASK") != "1":
log("✗ 这个目录看起来是**开发副本**(里面有 _package.py 等开发脚本)——")
log(" 因此不动计划任务,免得把用户那份的开机启动顶掉。")
log(" 真要在开发目录里装任务:设环境变量 VIBEMOTE_ALLOW_DEV_TASK=1 再跑。")
return False
# 任务定义用 UTF-16 写,schtasks 读 XML 时按声明解码,中文路径不会乱
with open(XML, "w", encoding="utf-16") as f:
f.write(task_xml(run_level))
rc, out = run(["schtasks", "/create", "/tn", name, "/xml", XML, "/f"])
head = (out or "").strip().splitlines()
log(f"计划任务:返回码 {rc} {' / '.join(head[:2]) if head else ''}")
try:
os.remove(XML)
except OSError:
pass
return rc == 0
# ---------------------------------------------------------------- 3. 清 Run 项
def remove_run_entry():
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0,
winreg.KEY_SET_VALUE) as k:
try:
winreg.DeleteValue(k, TASK_NAME)
log("已删除 HKCU Run 里的自启项(改用计划任务,避免两份同时启动)")
except FileNotFoundError:
log("HKCU Run 里本来就没有自启项")
except OSError as e:
log(f"处理 Run 项失败(不影响计划任务):{e}")
# ------------------------------------------------------- 4. 用新代码重启客户端
UI_PORT = 8787
def port_owners(port):
"""谁正在监听这个端口(解析 netstat,不依赖任何第三方库)。"""
rc, out = run(["netstat", "-ano"])
pids = []
for line in out.splitlines():
parts = line.split()
if len(parts) >= 5 and parts[1].endswith(f":{port}") \
and parts[3].upper() == "LISTENING":
try:
pids.append(int(parts[4]))
except ValueError:
pass
return sorted(set(pids))
def restart_client():
"""结束旧客户端,再用计划任务拉起当前目录里的新代码。
为什么必须由管理员进程来做:客户端自己是最高权限跑的,**普通权限杀不掉它**
(Stop-Process / taskkill 在普通权限下都是 Access is denied)。所以「换了代码怎么
生效」这件事,正好借这一次提权一起办掉;之后想重启,用界面「状态 → 重启」
即可 —— 应用内的 restart-app 动作会以自身权限拉起新实例并接管,不再需要 UAC。
"""
old = port_owners(UI_PORT)
if not old:
log("没有正在运行的客户端(跳过重启)")
for pid in old:
log(f"结束旧客户端 PID {pid}")
run(["taskkill", "/PID", str(pid), "/T", "/F"])
for _ in range(40):
if not port_owners(UI_PORT):
break
time.sleep(0.25)
rc, out = run(["schtasks", "/run", "/tn", TASK_NAME])
log(f"用计划任务重新启动客户端:返回码 {rc}")
for _ in range(40):
if port_owners(UI_PORT):
log(f"客户端已起来(PID {port_owners(UI_PORT)}),界面 http://127.0.0.1:{UI_PORT}/")
return True
time.sleep(0.5)
log("客户端没在 20 秒内起来,可双击「启动遥控器.vbs」手动拉起")
return False
def _ps(cmd, timeout=120):
rc, out = run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmd],
timeout=timeout)
for line in (out or "").splitlines():
if line.strip():
log(" " + line.strip())
return rc, out or ""
def reset_bluetooth():
"""禁用再启用蓝牙电台 —— 拆掉"被系统 HID 攥住"的那条 BLE 连接。
BLE 外设只接受**一个**连接。Windows 的 HID 栈有时会把那条唯一的连接攥住不放,
遥控器因此不再广播,而程序要自己建一条 GATT 连接才能收语音 → "永远连不上"
(表现:日志一直刷连接超时,但按键还有用)。拔电池/关开蓝牙开关能解开,
这个动作等于"用代码关开一次蓝牙电台"。
⚠⚠ 这里踩过一个**很严重**的坑:原先禁用+启用写在同一条 PowerShell 里、都带
`-ErrorAction Stop` —— 只要"启用"那一步抛错(或者提权进程在中途被杀),
电台就**留在禁用状态**,用户看到的是「**我的蓝牙设备全没了**」。
所以现在:① 禁用、启用分成两次独立调用,各自成败互不影响;
② 启用**重试 3 次**;③ 最后**验证**电台状态,没回来就明确告诉他去哪儿手动启用。
"""
# 蓝牙电台在 USB 上(如 Intel USB\VID_8087&PID_0033);BTHLE\ 那些是设备,不能停。
# 注意:禁用后 Status 会变成 Error/Unknown,所以"启用"这一步不能再用 Status -eq 'OK' 过滤。
pick = ("$d = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'USB*' });"
"if ($d.Count -eq 0) { Write-Output 'NO-RADIO'; exit 3 };"
"$d | ForEach-Object { Write-Output ('RADIO ' + $_.InstanceId + ' ' + $_.FriendlyName) }")
rc, out = _ps(pick)
if rc != 0 or "NO-RADIO" in out:
log("✗ 没找到 USB 上的蓝牙电台(可能是别的方式接的,或驱动异常)—— 什么都没动。")
return False
if "RADIO " not in out:
log("✗ 没能列出蓝牙电台 —— 什么都没动。")
return False
insts = []
for line in out.splitlines():
parts = line.strip().split(None, 2)
if len(parts) >= 2 and parts[0] == "RADIO":
insts.append(parts[1])
# ★ 第 0 步:**先把被禁用的电台启用回来**。
# 真机上发生过:用户按提示"手动关开一次蓝牙开关"只关了没开回来(或者设备管理器里
# 被禁用),结果 Windows 设置里的蓝牙开关**整个消失**、所有蓝牙设备都不见了 ——
# 用户以为设备坏了。这一步就是那个状态的对症解药(而且是幂等的:正常时不做事)。
en0 = ("Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'USB*' -and $_.Status -ne 'OK' } | ForEach-Object {"
" Write-Output ('发现非正常状态的电台:' + $_.Status + ' / ' + $_.FriendlyName);"
" try { Enable-PnpDevice -InstanceId $_.InstanceId -Confirm:$false -ErrorAction Stop;"
" Write-Output ' Enable-PnpDevice 成功' }"
" catch { Write-Output (' Enable-PnpDevice 失败: ' + $_.Exception.Message) } }")
_ps(en0)
for inst in insts:
# pnputil 的 enable 更兼容(真机上 Enable-PnpDevice 也会"不支持")
_ps(f'pnputil /enable-device "{inst}"')
time.sleep(2)
# ★★ 安全闸门:如果一开始就是"非正常状态"(被禁用/Error),那么**只要启用成功就够了**,
# 绝不再去"关开一次" —— 真机上这一步曾经把电台留在禁用状态,用户以为设备坏了。
# 确认变好就收手,不做任何多余动作。
rc_ok, chk0 = _ps("$d = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'USB*' -and $_.Status -eq 'OK' });"
"if ($d.Count -gt 0) { Write-Output 'RADIO-OK' } else { Write-Output 'RADIO-DOWN' }")
if "RADIO-DOWN" in out or "Error" in out or "Unknown" in out or "Disabled" in out:
if "RADIO-OK" in chk0:
log("✓ 电台已被启用并正常工作(原来是禁用/异常状态,所以只做了启用,没有再关开)")
return "enabled"
log("… 启用后状态仍不对,继续尝试重启设备")
# ★ 第 1 步:用 `pnputil /restart-device` 做"关开一次"。
# 它是"重启设备",**不存在"禁用后没启用回来"的窗口**,而且比
# Disable/Enable-PnpDevice 兼容(真机上 Disable-PnpDevice 直接报"不支持"
# HRESULT 0x8004100c,那条路根本走不通)。重启失败再退回禁用+启用。
for inst in insts:
log(f"用 pnputil 重启设备:{inst}")
rc3, o3 = _ps(f'pnputil /restart-device "{inst}"')
if rc3 == 0:
log("✓ 设备已重启(这条路径不会把电台留在禁用状态)")
return "restarted"
log(" pnputil 这条路没成功,改用『禁用 + 启用』")
dis = ("Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'USB*' } | ForEach-Object {"
" Write-Output ('禁用 ' + $_.FriendlyName);"
" try { Disable-PnpDevice -InstanceId $_.InstanceId -Confirm:$false -ErrorAction Stop }"
" catch { Write-Output ('禁用失败(继续): ' + $_.Exception.Message) } }")
_ps(dis)
time.sleep(3)
# ★ 启用是"必须成功"的那一步:独立调用 + 重试
for attempt in range(1, 4):
en = ("Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'USB*' } | ForEach-Object {"
" Write-Output ('启用 ' + $_.FriendlyName);"
" try { Enable-PnpDevice -InstanceId $_.InstanceId -Confirm:$false -ErrorAction Stop }"
" catch { Write-Output ('启用失败: ' + $_.Exception.Message) } }")
_ps(en)
time.sleep(3)
rc2, chk = _ps("$d = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'USB*' -and $_.Status -eq 'OK' });"
"if ($d.Count -gt 0) { Write-Output 'RADIO-OK' } else { Write-Output 'RADIO-DOWN' }")
if "RADIO-OK" in chk:
log(f"✓ 蓝牙电台已恢复(第 {attempt} 次确认)")
return True
log(f"… 电台还没回来(第 {attempt} 次),再试一次启用")
log("✗ 蓝牙电台没能自己回来。**别慌,设备还在**,手动两步就能救回:")
log(" ① 设备管理器(Win+X → 设备管理器)→ 蓝牙 → 右键那个适配器 → **启用设备**")
log(" ② 还不行就**完全关机**(开始菜单点「关机」,不是「重启」)再开机 —— ")
log(" 快速启动有时会让蓝牙设备开机后不枚举,完整关机一次必回来。")
return False
def main():
# 只重置蓝牙电台(不动自启/任务):给界面上那个「重置蓝牙电台」按钮用
if "--reset-bt" in sys.argv:
try:
open(LOG, "w", encoding="utf-8").close()
except Exception:
pass
log("=== 重置蓝牙电台 ===")
if not is_admin():
log("✗ 当前不是管理员,什么都没做。请从界面点按钮并在 UAC 里点「是」。")
log("DONE")
return 1
log("管理员权限 ✓(期间蓝牙设备会短暂断开,几秒后自动恢复)")
ok = reset_bluetooth()
if ok == "restarted":
log("✓ 蓝牙电台已重启,等待遥控器自动重连…")
elif ok == "enabled":
log("⚠ 这台机器**不支持**用软件重启蓝牙电台(Windows 报「不支持 / 需要重启系统才能完成上一次操作」)——")
log(" 我已经确保它是**启用**状态;要真正「关开一次」请二选一:")
log(" ① 把遥控器**电池取出 10 秒**再装回(推荐,最快)")
log(" ② **重启电脑**(或完全关机再开机)—— 系统里还有一次待完成的蓝牙配置操作,重启后才干净")
else:
log("✗ 重置失败 —— 手动两步:① 设备管理器 → 蓝牙 → 右键适配器 → 「启用设备」;② 完全关机再开机")
log("DONE")
return 0 if ok else 1
try:
open(LOG, "w", encoding="utf-8").close()
except Exception:
pass
log("=== 一次性优化开始 ===")
if not is_admin():
log("✗ 当前不是管理员,什么都没做。请从界面点「一键优化」并在 UAC 里点「是」。")
log("DONE")
return 1
log("管理员权限 ✓")
cleanup_frida()
if install_task():
remove_run_entry()
log("✓ 开机自启已改为「最高权限计划任务」:以后开机静默以管理员身份启动,")
log(" Frida 需要的权限现成,**不会再弹任何 UAC**。")
else:
log("✗ 计划任务没建成。可以只做第一步(清残留)也不影响使用;")
log(" 自启仍走 HKCU Run(那种方式每次开机 Frida 可能要你授权一次)。")
restart_client()
log("DONE")
return 0
if __name__ == "__main__":
sys.exit(main())
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""中英双语 —— 后端只负责「界面要显示的消息」。
分工:
· 界面自身的文字(按钮/标题/提示)由前端 `web/index.html` 里的 I18N 字典负责;
· 后端产生的、**会显示在界面上的**文字(依赖说明、预设方案名与备注、清理清单、
角色状态备注、体检输出、动作回复)由这里负责;
· **内部诊断日志保持中文**(角色日志、ATLL/音频细节、frida 细节)—— 那些是排障用的
技术流水,双语的收益远低于维护成本,这一点在 README 里写明了。
语言从哪来:`config.json` 的 `"lang"`,由界面通过 `set-lang` 动作写入
(界面首次打开会按浏览器语言自动选一次,用户手动切过就记住)。
取不到就一律用中文 —— 与界面「检测不出来就用中文」的约定一致。
为什么不用字典式的 i18n 框架:这些字符串本来就在代码上下文里,抽成 key → 字典
反而离上下文更远、更容易漂移。所以只留一个极简的 `L(zh, en)`;
**数据表**(依赖清单、预设方案、清理清单)例外 —— 它们本身就是数据结构,
直接把中英两版写进表里,渲染时按语言取。
"""
from __future__ import annotations
import json
import os
import threading
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "config.json")
_lock = threading.Lock()
_lang = "zh"
# 写 config.json 的**唯一**一把锁。
# ⚠ 为什么放在这里:写 config.json 的有两个地方 —— app.py 的 `save_config()` 和本文件的
# `save_to_config()`。它们在**同一个进程**里,而且界面上「保存设置」和「切语言」
# 完全可能同时发生。原来两边都用 `CONFIG + ".tmp"` 这一个临时文件名:
# A 写 tmp → B 把 tmp 覆盖掉 → A replace 走了 B 的内容 → B replace 时文件已经没了
# (异常被吞)→ 结果要么丢一次修改,要么**在程序目录里留下一个 config.json.tmp**
# (那里面会是用户完整的配置:蓝牙地址 + 整套键位)。
# app.py 已经 import 了 i18n,所以把锁放这里两边都能用。
CONFIG_LOCK = threading.Lock()
def normalize(lang):
"""只认 zh / en,其它(含空、None、检测不出来)一律回中文。"""
s = str(lang or "").strip().lower()
return "en" if s.startswith("en") else "zh"
def current():
with _lock:
return _lang
def set_lang(lang):
global _lang
with _lock:
_lang = normalize(lang)
return _lang
def load_from_config():
"""启动时从 config.json 读一次语言。"""
global _lang
try:
# utf-8-sig:记事本另存为会加 BOM,用它读才不会 JSONDecodeError
with open(CONFIG, encoding="utf-8-sig") as f:
cfg = json.load(f)
with _lock:
_lang = normalize(cfg.get("lang"))
except Exception:
pass
return current()
def save_to_config(lang):
"""界面切语言时写回 config.json —— **只动 lang 这一项**,其它字段原样保留。"""
v = set_lang(lang)
# 跟 app.save_config() 共用一把锁 + 一个唯一的临时文件名(见 CONFIG_LOCK 的注释)
with CONFIG_LOCK:
try:
cfg = {}
if os.path.exists(CONFIG):
with open(CONFIG, encoding="utf-8-sig") as f:
cfg = json.load(f)
cfg["lang"] = v
tmp = f"{CONFIG}.{os.getpid()}.tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
os.replace(tmp, CONFIG)
except OSError:
try:
os.remove(tmp) # 别留垃圾
except OSError:
pass
except Exception:
pass
return v
def L(zh, en):
"""按当前语言取一条文案。"""
return en if current() == "en" else zh
def pair(zh, en):
"""给数据表用:包成 (zh, en),渲染时用 pick() 取。"""
return (zh, en)
def pick(value):
"""从数据表里取当前语言的那一版;不是 (zh, en) 就原样返回。"""
if isinstance(value, (tuple, list)) and len(value) == 2:
return value[1] if current() == "en" else value[0]
return value
+864
View File
@@ -0,0 +1,864 @@
# -*- coding: utf-8 -*-
"""按键映射:Frida 旁路读遥控器 HID 报文 → 合成键盘 / 鼠标动作。
为什么必须走 Frida(定案,别再试别的路子):
谷歌遥控器的按键报告被 Windows 的 UMDF 蓝牙驱动(WUDFHost.exe)消费掉了 ——
Raw Input 收不到、HID 设备直接 ReadFile 也是空的。唯一可行出口是钩住
WUDFHost 里的 ntdll!NtDeviceIoControlFile,抄 IOCTL 0x80018483 的输出缓冲区。
报文格式:
3 字节 Consumer Control: [0x02] [usage_lo] [usage_hi]
usage 非 0 = 按下这个键
02 00 00 = 空闲帧(收到它就代表全部松开)
注意:同一个 WUDFHost 还服务别的蓝牙键鼠,它们吐 9 字节键盘报告 —— 按长度过滤。
清位(消灭「原生动作 + 映射动作」双发):
在钩子里把**已被映射**的 usage 原地写 0,驱动就以为没按键,原生动作不再发生,
只剩我们合成的那一次。所以音量键不会顺带调系统音量、YouTube 不会打开应用。
本文件只用标准库 + frida(缺 frida 时整体降级,不影响语音)。
"""
from __future__ import annotations
import ctypes
import os
import threading
import time
import winreg
from ctypes import wintypes
import i18n
HERE = os.path.dirname(os.path.abspath(__file__))
TAP_JS = os.path.join(HERE, "tap.js")
# ============================================================ 常量表
# 键名 -> 虚拟键码。界面里能选到的名字就是这张表的键。
VK = {
# 修饰键(左右分开:右 Alt / 右 Ctrl 是输入法语音快捷键的常客)
"CTRL": 0x11, "SHIFT": 0x10, "ALT": 0x12,
"LCTRL": 0xA2, "RCTRL": 0xA3, "LSHIFT": 0xA0, "RSHIFT": 0xA1,
"LALT": 0xA4, "RALT": 0xA5, "LWIN": 0x5B, "RWIN": 0x5C, "APPS": 0x5D,
# 编辑与导航
"BACKSPACE": 0x08, "TAB": 0x09, "ENTER": 0x0D, "ESC": 0x1B, "SPACE": 0x20,
"PAGEUP": 0x21, "PAGEDOWN": 0x22, "END": 0x23, "HOME": 0x24,
"LEFT": 0x25, "UP": 0x26, "RIGHT": 0x27, "DOWN": 0x28,
"INSERT": 0x2D, "DELETE": 0x2E, "CAPSLOCK": 0x14,
"PRINTSCREEN": 0x2C, "NUMLOCK": 0x90, "SCROLLLOCK": 0x91, "PAUSE": 0x13,
# 符号
"SEMICOLON": 0xBA, "EQUAL": 0xBB, "COMMA": 0xBC, "MINUS": 0xBD,
"PERIOD": 0xBE, "SLASH": 0xBF, "BACKQUOTE": 0xC0, "LBRACKET": 0xDB,
"BACKSLASH": 0xDC, "RBRACKET": 0xDD, "QUOTE": 0xDE,
# 小键盘
"NUMPAD0": 0x60, "NUMPAD1": 0x61, "NUMPAD2": 0x62, "NUMPAD3": 0x63,
"NUMPAD4": 0x64, "NUMPAD5": 0x65, "NUMPAD6": 0x66, "NUMPAD7": 0x67,
"NUMPAD8": 0x68, "NUMPAD9": 0x69, "MULTIPLY": 0x6A, "ADD": 0x6B,
"SUBTRACT": 0x6D, "DECIMAL": 0x6E, "DIVIDE": 0x6F,
# 媒体
"MEDIA_NEXT": 0xB0, "MEDIA_PREV": 0xB1, "MEDIA_STOP": 0xB2,
"MEDIA_PLAY_PAUSE": 0xB3, "VOLUME_MUTE": 0xAD,
"VOLUME_DOWN": 0xAE, "VOLUME_UP": 0xAF,
# 浏览器 / 系统
"BROWSER_BACK": 0xA6, "BROWSER_FORWARD": 0xA7, "BROWSER_REFRESH": 0xA8,
"BROWSER_STOP": 0xA9, "BROWSER_SEARCH": 0xAA, "BROWSER_FAVORITES": 0xAB,
"BROWSER_HOME": 0xAC,
}
for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
VK[_c] = ord(_c)
for _d in range(10):
VK[f"DIGIT{_d}"] = 0x30 + _d
for _i in range(1, 25):
VK[f"F{_i}"] = 0x6F + _i
# 必须带 KEYEVENTF_EXTENDEDKEY 的键。少了这个标志,Windows 会把
# 「右 Alt」当成左 Alt、把方向键当成小键盘数字键 —— 输入法就收不到语音快捷键。
EXT_VK = {
0xA3, 0xA5, # 右 Ctrl / 右 Alt
0x5B, 0x5C, 0x5D, # Win / RWin / 菜单键
0x2D, 0x2E, # Insert / Delete
0x24, 0x23, 0x21, 0x22, # Home / End / PgUp / PgDn
0x26, 0x28, 0x25, 0x27, # 方向键
0x90, 0x2C, 0x6F, # NumLock / PrintScreen / 小键盘除号
}
# 常用键扫描码(make code)。带上扫描码兼容性最好:有些输入法/游戏只认扫描码。
SCAN = {
0x1B: 0x01, 0x31: 0x02, 0x32: 0x03, 0x33: 0x04, 0x34: 0x05, 0x35: 0x06,
0x36: 0x07, 0x37: 0x08, 0x38: 0x09, 0x39: 0x0A, 0x30: 0x0B, 0xBD: 0x0C,
0xBB: 0x0D, 0x08: 0x0E, 0x09: 0x0F, 0x51: 0x10, 0x57: 0x11, 0x45: 0x12,
0x52: 0x13, 0x54: 0x14, 0x59: 0x15, 0x55: 0x16, 0x49: 0x17, 0x4F: 0x18,
0x50: 0x19, 0xDB: 0x1A, 0xDD: 0x1B, 0x0D: 0x1C,
0x41: 0x1E, 0x53: 0x1F, 0x44: 0x20, 0x46: 0x21, 0x47: 0x22, 0x48: 0x23,
0x4A: 0x24, 0x4B: 0x25, 0x4C: 0x26, 0xBA: 0x27, 0xDE: 0x28, 0xC0: 0x29,
0x5A: 0x2C, 0x58: 0x2D, 0x43: 0x2E, 0x56: 0x2F,
0x42: 0x30, 0x4E: 0x31, 0x4D: 0x32, 0xBC: 0x33, 0xBE: 0x34, 0xBF: 0x35,
0x20: 0x39, 0xA4: 0x38, 0xA5: 0x38, # 左右 Alt 共用 0x38,靠扩展标志区分
0x26: 0x48, 0x28: 0x50, 0x25: 0x4B, 0x27: 0x4D, # 方向键与小键盘共用
0x24: 0x47, 0x23: 0x4F, 0x21: 0x49, 0x22: 0x51,
0x2D: 0x52, 0x2E: 0x53,
}
# 遥控器 Consumer Control usage -> 按键 id(受控采集,14/14 全命中)
USAGE_TO_KEY = {
0x019E: "power", 0x0189: "input",
0x0042: "up", 0x0043: "down", 0x0044: "left", 0x0045: "right",
0x0041: "ok", 0x0224: "back", 0x0223: "home",
0x00E9: "volup", 0x00EA: "voldown", 0x00E2: "mute",
0x0077: "youtube", 0x0078: "netflix",
}
KEY_TO_USAGE = {v: k for k, v in USAGE_TO_KEY.items()}
# ============================================================ SendInput
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_EXTENDEDKEY = 0x0001
KEYEVENTF_UNICODE = 0x0004
INPUT_KEYBOARD, INPUT_MOUSE = 1, 0
WHEEL_DELTA = 120
user32 = ctypes.WinDLL("user32", use_last_error=True)
class KEYBDINPUT(ctypes.Structure):
_fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong))]
class MOUSEINPUT(ctypes.Structure):
_fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong))]
class _INPUTunion(ctypes.Union):
# ⚠ 64 位下这个 union 必须是 32 字节(MOUSEINPUT 最大)。
# 写成 24 字节 → SendInput 直接返回 err=87,一个键都发不出去,而且不报原因。
_fields_ = [("ki", KEYBDINPUT), ("mi", MOUSEINPUT), ("pad", ctypes.c_byte * 32)]
class INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("u", _INPUTunion)]
def _kb(vk, up=False, scan=0, flags=0):
inp = INPUT()
inp.type = INPUT_KEYBOARD
inp.u.ki = KEYBDINPUT(wVk=vk, wScan=scan,
dwFlags=flags | (KEYEVENTF_KEYUP if up else 0),
time=0, dwExtraInfo=None)
return inp
def _mouse(flags, data=0):
inp = INPUT()
inp.type = INPUT_MOUSE
inp.u.mi = MOUSEINPUT(dx=0, dy=0, mouseData=data & 0xFFFFFFFF,
dwFlags=flags, time=0, dwExtraInfo=None)
return inp
def _send(items):
if not items:
return False
arr = (INPUT * len(items))(*items)
return user32.SendInput(len(items), arr, ctypes.sizeof(INPUT)) == len(items)
def vk_of(name):
return VK.get(str(name or "").strip().upper())
def key_down(vk):
return _send([_kb(vk, scan=SCAN.get(vk, 0),
flags=KEYEVENTF_EXTENDEDKEY if vk in EXT_VK else 0)])
def key_up(vk):
return _send([_kb(vk, up=True, scan=SCAN.get(vk, 0),
flags=KEYEVENTF_EXTENDEDKEY if vk in EXT_VK else 0)])
def mods_down(mods):
return _send([_kb(vk_of(m), scan=SCAN.get(vk_of(m), 0),
flags=KEYEVENTF_EXTENDEDKEY if vk_of(m) in EXT_VK else 0)
for m in mods if vk_of(m)])
def mods_up(mods):
return _send([_kb(vk_of(m), up=True, scan=SCAN.get(vk_of(m), 0),
flags=KEYEVENTF_EXTENDEDKEY if vk_of(m) in EXT_VK else 0)
for m in reversed(mods) if vk_of(m)])
def send_text(text):
"""逐 UTF-16 code unit 用 UNICODE 事件发文本(中文靠这条才能上屏)。
单次 SendInput 有长度限制,分批发(每批 64 个 code unit)。
"""
units = []
for ch in text:
raw = ch.encode("utf-16-le")
for i in range(0, len(raw), 2):
units.append(int.from_bytes(raw[i:i + 2], "little"))
for i in range(0, len(units), 64):
batch = []
for u in units[i:i + 64]:
batch.append(_kb(0, scan=u, flags=KEYEVENTF_UNICODE))
batch.append(_kb(0, up=True, scan=u, flags=KEYEVENTF_UNICODE))
_send(batch)
return True
MOUSE_ACTIONS = {
"wheel_up": 0x0800, "wheel_down": 0x0800,
"left": 0x0002, "left_up": 0x0004,
"right": 0x0008, "right_up": 0x0010,
"middle": 0x0020, "middle_up": 0x0040,
}
# 「按住鼠标」松开时要发的配对动作
MOUSE_RELEASE = {"left": "left_up", "right": "right_up", "middle": "middle_up"}
# 按住期间要**连续重复**的鼠标动作:滚轮按住只动一格等于没用
MOUSE_REPEAT = ("wheel_up", "wheel_down")
REPEAT_INITIAL = 0.30 # 按住多久后开始连发(秒)
REPEAT_INTERVAL = 0.07 # 连发间隔(秒),约 14 次/秒
def mouse_action(name):
name = str(name or "").lower()
flags = MOUSE_ACTIONS.get(name)
if not flags:
return False
if name == "wheel_up":
return _send([_mouse(flags, WHEEL_DELTA)])
if name == "wheel_down":
return _send([_mouse(flags, -WHEEL_DELTA & 0xFFFFFFFF)])
return _send([_mouse(flags)])
def key_is_down(vk):
return bool(user32.GetAsyncKeyState(vk) & 0x8000)
# ============================================================ 映射执行
class Player:
"""把一条映射作用到「按下 / 松开」两个动作上。
key / combo —— 点一下(按下立刻抬起)
hold / holdcombo —— 遥控器键按住期间保持按下,松开遥控器才抬起
holdmouse —— 按住鼠标:滚轮按住**连续滚**,左右中键按住**不放**(拖拽用)
mouse / text —— 只在按下那一下触发
"""
def __init__(self):
self.held = None # 当前 hold 住的键盘键:(mods, vk) 或 None
self.held_mouse = None # 当前按住的鼠标键名(left/right/middle)或 None
self._rep_stop = None # 滚轮连发的停止信号
def _tap(self, mods, vk):
if mods:
mods_down(mods)
key_down(vk)
key_up(vk)
if mods:
mods_up(mods)
def _hold_start(self, mods, vk):
self._hold_stop()
if mods:
mods_down(mods)
key_down(vk)
self.held = (list(mods), vk)
def _hold_stop(self):
if not self.held:
return
mods, vk = self.held
key_up(vk)
if mods:
mods_up(mods)
self.held = None
# ---------------------------------------------------------- 按住鼠标
def _mouse_repeat_stop(self):
if self._rep_stop is not None:
self._rep_stop.set()
self._rep_stop = None
def _holdmouse_start(self, value):
self._holdmouse_stop() # 同时只按住一个鼠标动作
if value in MOUSE_REPEAT:
mouse_action(value) # 先来一格(按下去就有反馈)
stop = threading.Event()
self._rep_stop = stop
def loop():
# 先等一小会儿再开始连发:轻点一下只滚一格,按住不放才连续滚
if stop.wait(REPEAT_INITIAL):
return
while not stop.wait(REPEAT_INTERVAL):
mouse_action(value)
threading.Thread(target=loop, daemon=True, name="holdmouse").start()
elif value in MOUSE_RELEASE:
mouse_action(value)
self.held_mouse = value
def _holdmouse_stop(self):
self._mouse_repeat_stop()
if self.held_mouse:
rel = MOUSE_RELEASE.get(self.held_mouse)
if rel:
mouse_action(rel)
self.held_mouse = None
def down(self, m):
t = m.get("type")
value = str(m.get("value") or "")
mods = [str(x) for x in (m.get("mods") or [])]
if t == "mouse":
mouse_action(value)
return
if t == "holdmouse":
self._holdmouse_start(value)
return
if t == "text":
send_text(str(m.get("text") or ""))
return
vk = vk_of(value)
if not vk:
return
if t in ("hold", "holdcombo"):
self._hold_start(mods if t == "holdcombo" else [], vk)
else: # key / combo
self._tap(mods if t == "combo" else [], vk)
def up(self, m):
t = m.get("type")
if t in ("hold", "holdcombo"):
self._hold_stop()
elif t == "holdmouse":
self._holdmouse_stop()
def release_all(self):
self._hold_stop()
self._holdmouse_stop()
# ============================================================ Frida 旁路
BTHLE_ENUM = r"SYSTEM\CurrentControlSet\Enum\BTHLEDevice"
HID_SVC_PREFIX = "{00001812-0000-1000-8000-00805f9b34fb}"
WUDF_DIAG = r"Device Parameters\WUDFDiagnosticInfo"
READ_IOCTL = 0x80018483
def hid_devices(vid="18D1", pid="9450", any_hid=False):
"""[(HostPid, 服务节点名)] —— BLE HID(0x1812)设备所在的驱动宿主。
节点名里带着**这台设备是谁**:`..._Dev_VID&0218d1_PID&9450_REV&011b_<MAC>`,
所以顺手把它返回出去,日志里就能写清"挂上的是哪台设备" —— 判断遥控器是原厂还是
兼容款(真机见过:同一批山寨遥控器有的有语音服务、有的没有)全靠这一行。
`any_hid=True`:不挑 VID/PID,任何 BLE HID 设备都要(**兼容款兜底** —— 它们可能用
别的 VID/PID,甚至根本没写厂商信息,但按键照样走 HID,映射还能用)。
"""
out = []
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, BTHLE_ENUM) as root:
i = 0
while True:
try:
svc = winreg.EnumKey(root, i)
except OSError:
break
i += 1
low = svc.casefold()
if not low.startswith(HID_SVC_PREFIX.casefold()):
continue
if not any_hid and (vid.lower() not in low or pid.lower() not in low):
continue
with winreg.OpenKey(root, svc) as sk:
j = 0
while True:
try:
inst = winreg.EnumKey(sk, j)
except OSError:
break
j += 1
try:
with winreg.OpenKey(root, f"{svc}\\{inst}\\{WUDF_DIAG}") as dk:
out.append((int(winreg.QueryValueEx(dk, "HostPid")[0]), svc))
except (OSError, TypeError, ValueError):
continue
except OSError:
pass
return out
def find_wudfhost_pid(vid="18D1", pid="9450", any_hid=False):
"""遥控器 HID 服务(0x1812) 所在 WUDFHost 进程的 PID(找不到返回 None)。"""
devs = hid_devices(vid, pid, any_hid)
return devs[0][0] if devs else None
def probe_attach():
"""能不能往一个**普通进程**(记事本)里注入?—— 把"环境阻止注入"和"我们自己的问题"分开。
真机背景:公司电脑上日志反复 `TransportError: the connection is closed`,而**杀软界面里
没有任何记录** —— 这不奇怪:EDR / Defender ASR / WDAC / AppLocker 这类策略是**静默拒绝**,
记录只在企业控制台或事件日志里,用户界面上看不到。
往记事本注入一下就能定性:连记事本都注入不进去 → 是这台机器在阻止注入,
跟遥控器、蓝牙、我们的代码都无关;反过来则说明问题只在"注入蓝牙 HID 宿主"这一步。
"""
try:
import frida
except Exception as e:
return i18n.L(f"注入能力测试:本机没有 frida({e!r})",
f"Injection test: frida is not available here ({e!r})")
import subprocess
import sys
# ⚠ 探测目标必须挑"一定能注的":记事本在 Win11 上是打包应用(AppContainer),
# 连本机 frida 正常的时候也注不进去 —— 拿它当探针会得出完全相反的结论(实测踩过)。
# 用我们**自己那个解释器**跑一个空转进程:同权限、非打包,能注 = frida 这条路是通的。
p = None
try:
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"],
creationflags=0x08000000)
time.sleep(2.0)
s = frida.attach(p.pid)
s.detach()
return i18n.L("注入能力测试:往一个普通进程注入**成功** → frida 本身没问题,"
"问题只出在注入蓝牙 HID 宿主那一步(把那行自检发给开发者)。",
"Injection test: attaching to an ordinary process **succeeded** → frida itself "
"is fine, so the problem is specific to the Bluetooth HID host (send that "
"self-check line to the developer).")
except Exception as e:
return i18n.L(
f"注入能力测试:往一个普通进程注入**失败**({type(e).__name__}: {str(e)[:80]})→ "
f"这台机器在阻止注入(公司电脑很常见,而且**杀软界面可能一条记录都没有**,"
f"记录只在企业控制台/事件日志里)。请让 IT 给程序目录放行;放行之前语音照常可用。",
f"Injection test: attaching to an ordinary process **failed** "
f"({type(e).__name__}: {str(e)[:80]}) → this machine is blocking injection (common on "
f"corporate PCs, and the antivirus UI may show **no record at all** — those records live "
f"in the enterprise console or the event log). Ask IT to allow the program folder; voice "
f"keeps working until then.")
finally:
try:
if p:
p.kill()
except Exception:
pass
def wudfhost_pids():
"""列出机器上所有 WUDFHost.exe 的 PID。
为什么要它:一台机器上常有**好几个** WUDFHost(蓝牙、指纹、摄像头各一个),
而"遥控器的按键读调用"只出现在**服务它的那个**里面。真机上出现过:注册表查到的
PID 换了(蓝牙重置/驱动重载),我们还挂在旧进程上 —— 界面显示"就绪",
实际一个报文都收不到。有了这张表,就能在收不到数据时换一个宿主再试。
"""
import subprocess
pids = []
try:
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq WUDFHost.exe",
"/NH", "/FO", "CSV"],
capture_output=True, timeout=15,
creationflags=0x08000000).stdout or b""
except Exception:
return pids
for enc in ("utf-8", "gbk", "mbcs"):
try:
text = out.decode(enc)
break
except (UnicodeDecodeError, LookupError):
continue
else:
text = out.decode("utf-8", "replace")
for line in text.splitlines():
parts = [p.strip().strip('"') for p in line.split(",")]
if len(parts) >= 2 and parts[0].lower().startswith("wudfhost") and parts[1].isdigit():
pids.append(int(parts[1]))
return pids
class KeyTap(threading.Thread):
"""Frida 会话线程:读报文 → 解 usage → 施加映射。
on_event(kind, payload) 由 app 传入,用来推给前端:
("key", {"id": "ok", "down": True}) 遥控器按键事件(界面高亮用)
("log", "文本")
("status", {"ready": bool, "note": str})
"""
def __init__(self, on_event, vidpid=("18D1", "9450")):
super().__init__(daemon=True, name="KeyTap")
self.on_event = on_event
# 收到遥控器报文时的回调。语音角色用它来"立刻重连" —— 遥控器只在按键后
# 醒一小会,这时候去连成功率最高;等退避睡满再连就错过了窗口。
self.on_activity = None
self.vidpid = vidpid
self.player = Player()
self.mapping = {}
self.blocked_usages = []
self.stop_flag = threading.Event()
self.ready = False
self.note = i18n.L("未启动", "Not started")
self.last_seen = 0.0
# 诊断/自愈用:钩子挂上了却收不到按键报文时,靠这几项判断"是不是宿主换了"
self._hb = {}
self._attached_pid = None
self._attached_at = 0.0
self._av_hinted = False
self.reports = 0
self._held = set() # 当前按住的 usage(过滤长按连发)
self._script = None
self._session = None
self._lock = threading.Lock()
# ---------- 对外 ----------
def set_mapping(self, mapping):
with self._lock:
self.mapping = dict(mapping or {})
self._push_block()
def _push_block(self):
"""把「已被映射的键」的 usage 下发给钩子做清位。"""
with self._lock:
usages = sorted({KEY_TO_USAGE[k] for k in self.mapping
if k in KEY_TO_USAGE})
self.blocked_usages = usages
if self._script:
try:
self._script.post({"type": "block", "usages": usages})
except Exception:
pass
def _emit(self, kind, payload=None):
try:
self.on_event(kind, payload)
except Exception:
pass
def _log(self, msg):
self._emit("log", msg)
# ---------- 主循环 ----------
def run(self):
try:
import frida
except ImportError:
self.note = i18n.L("缺 frida(按键映射不可用,语音不受影响)",
"frida missing (key mapping unavailable; voice is unaffected)")
self._emit("status", {"ready": False, "note": self.note})
self._log("按键映射:未安装 frida,跳过(语音键仍可用)")
return
with open(TAP_JS, encoding="utf-8") as f:
source = f.read()
# ★ 重试节奏很重要:Frida 第一次挂载会要求**一次提权**(frida-helper)。
# 如果失败就每 2 秒猛重试,就会把那个授权框反复弹出来 —— 用户看到的是
# 「怎么一直弹」。所以这里改成指数退避,并且对「权限类失败」特别狠:
# 连撞 3 次就退到 5 分钟一次,只提示一次,绝不刷屏。
delay = 2.0
perm_fails = 0
hinted = False
while not self.stop_flag.is_set():
try:
pid, dev = self._next_host_pid()
if not pid:
self.note = i18n.L(
"没找到遥控器的 HID 驱动宿主(遥控器配对了吗?)",
"The remote's HID driver host was not found (is the remote paired?)")
self._emit("status", {"ready": False, "note": self.note})
if self.stop_flag.wait(min(delay, 10.0)):
break
continue
if self._session is None:
self._log(f"按键映射:挂上蓝牙驱动宿主 WUDFHost (PID {pid})"
+ (f" ← {dev}" if dev else "")
+ ("(非原厂 VID/PID,按「任意 BLE HID 设备」兜底找到的)"
if getattr(self, "_compat_remote", False) else ""))
self._session = frida.attach(pid)
self._script = self._session.create_script(source)
self._script.on("message", self._on_message)
self._script.load()
self._push_block()
self.last_seen = time.time()
self._attached_pid = pid
self._attached_at = time.time()
self._hb = {}
self._av_hinted = False
delay = 2.0 # 成功后恢复灵敏重试
perm_fails = 0
else:
# ★ 宿主没了/换了就得重新挂:蓝牙重置、驱动重载都会让 WUDFHost 换 PID,
# 而我们还挂在那个已经死掉的进程上 —— 界面照样显示"就绪",
# 实际一个按键报文都收不到(真机上就是这么"看着正常却没用"的)。
now = (find_wudfhost_pid(*self.vidpid)
or find_wudfhost_pid(*self.vidpid, any_hid=True)
or self._attached_pid)
if self._attached_pid and (now != self._attached_pid
or not self._pid_alive(self._attached_pid)):
self._log(f"按键映射:宿主换了或没了(PID {self._attached_pid} → {now}),"
f"重新挂上去")
self._teardown_session()
continue
time.sleep(1.0)
except Exception as e:
self._session = None
self._script = None
self.ready = False
name, text = type(e).__name__, str(e)
perm = ("PermissionDenied" in name or "unable to access" in text
or "AccessDenied" in name)
if perm:
perm_fails += 1
if perm_fails >= 3:
delay = 300.0 # 别再招惹授权框了
self.note = i18n.L(
"按键映射需要一次管理员授权(已暂停自动重试,"
"5 分钟后再试一次)",
"Key mapping needs a one-time administrator approval "
"(auto-retry paused; it will try again in 5 minutes)")
if not hinted:
hinted = True
self._log("按键映射:挂载需要提权,但授权没给 —— "
"已停下不再反复弹框。想用按键映射就在界面"
"「设置」里点『以管理员身份重启』(只需授权一次)。")
else:
self.note = i18n.L(
f"挂载失败({perm_fails}/3):需要一次提权授权",
f"Attach failed ({perm_fails}/3): one-time elevation required")
delay = min(delay * 2, 30.0)
else:
self.note = i18n.L(f"挂载失败:{name}: {text}",
f"Attach failed: {name}: {text}")
# ⚠ 最常见的一种:TransportError: the connection is closed ——
# 那是"注入通道被掐断",在装了杀软/EDR 的机器(尤其公司电脑)上
# 几乎一定是安全软件拦的。这句术语对外行毫无意义,直接给一句人话。
if ("TransportError" in name or "connection is closed" in text) and not self._av_hinted:
self._av_hinted = True
self._log(i18n.L(
"按键映射:注入被中断了(TransportError)—— 在装了杀软/EDR 的机器上"
"(公司电脑尤其常见)几乎都是安全软件拦的。正规解法:请 IT 把"
"**程序目录**加进杀软白名单(我们不改系统设置、不关 Defender,"
"只需要放行注入)。放行之前语音功能不受影响,"
"只是方向键/OK/音量这些用不了。",
"Key mapping: the injection was cut off (TransportError) — on machines "
"with antivirus/EDR (very common on corporate PCs) this is almost always "
"the security software blocking it. The proper fix is to ask IT to "
"whitelist the program folder (we never change system settings or disable "
"Defender; we only need the injection allowed). Until then voice still "
"works — only D-pad/OK/volume are unavailable."))
delay = min(delay * 2, 60.0)
self._emit("status", {"ready": False, "note": self.note})
self._log(f"按键映射:{self.note}({delay:.0f} 秒后重试)")
if self.stop_flag.wait(delay):
break
# ---------- 宿主选择 / 自愈 / 诊断 ----------
def _next_host_pid(self):
"""该挂哪个 WUDFHost,以及这台设备是谁(节点名里带 VID/PID)。
先按配置的 VID/PID 找原厂那款;找不到就**退一步认任意 BLE HID 设备** ——
兼容款遥控器可能用别的 VID/PID(甚至不写厂商),但按键照样走 HID 服务,
映射还能用;没有这一步,那些遥控器会直接报"没找到宿主"。
"""
devs = hid_devices(*self.vidpid)
if not devs:
devs = hid_devices(*self.vidpid, any_hid=True)
# ⚠ "走了兜底"**不等于**"兼容款":蓝牙重置那一瞬间注册表节点可能刚好不在,
# 兜底找到的仍然可能是原厂那台。只有节点名里确实没有我们的 VID/PID 才算兼容款,
# 否则日志会写出"非原厂 VID/PID"这种**假结论**(真机上出现过)。
if devs:
low = devs[0][1].casefold()
v, p = (self.vidpid or ("", ""))
self._compat_remote = not (str(v).lower() in low and str(p).lower() in low)
if not devs:
return None, ""
return devs[0][0], devs[0][1]
def _teardown_session(self):
"""断掉当前钩子但不停线程(宿主换了、或要换一个宿主时用)。"""
try:
if self._script:
self._script.unload()
except Exception:
pass
try:
if self._session:
self._session.detach()
except Exception:
pass
self._script = None
self._session = None
self._attached_pid = None
self.ready = False
def selfcheck(self):
"""按需输出"钩子到底看到了什么"(界面「设置 → 诊断 → 按键映射自检」调它)。
为什么要有它:真机上出现过"钩子挂上了、界面显示就绪、按键却完全没反应",
而日志里只有成功/失败 —— 分不清是"挂到了不服务这支遥控器的宿主",
还是"这支遥控器的报文格式跟预期不同"。这几个原始计数能一眼分辨:
· 读调用 0 次 + 收到 0 条 → 宿主不对
· 读调用 N 次、长度里从没出现 3 字节 → 报文格式不同(另一款遥控器)
· 长度里有 3 字节 / 收到过报文 → HID 这条路是通的
⚠ 必须**按需**触发:遥控器空闲时本来就不发报文(实测:健康机器上 90 秒里
只有别的蓝牙键鼠的 9 字节输出),所以定时判断"没有 3 字节 = 坏了"一定是误报。
"""
if not self._attached_pid:
return i18n.L("按键映射没有在运行(先看「运行依赖」里 frida 是否齐全)",
"Key mapping is not running (first check that frida is installed)")
hb = self._hb or {}
lens = hb.get("lens") or {}
dist = "、".join(f"{k} 字节×{v}" for k, v in sorted(lens.items())) or "无"
sent = int(hb.get("sent") or 0)
ioctl = int(hb.get("ioctl") or 0)
host_n = len(wudfhost_pids())
tip = i18n.L("HID 这条路是通的。",
"The HID path is working.") if (sent or lens.get("3")) else i18n.L(
"如果你刚刚连按了几个方向键、而这行仍是 0 条,把这行发给开发者即可定位"
"(是宿主不对还是这支遥控器的报文格式不同)。",
"If you just pressed a few D-pad keys and this still says 0, send this line to the "
"developer — it tells whether the host is wrong or this remote uses a different format.")
# 没有任何报文时,顺手做一次"注入能力测试":把"这台机器不让注入"这条单独验掉,
# 省得用户在杀软界面里翻记录(那里本来就看不到静默拦截)。
extra = ""
if not sent and not lens.get("3"):
try:
extra = " " + probe_attach()
except Exception:
pass
# 这台遥控器**实际发过哪些键**(含没被映射、我们表里没有的)—— 换遥控器/兼容款
# 时靠这一行认人:语音键在某些款上是 HID usage(而不是 ATVV),一眼就能看出来。
seen = hb.get("seen") or {}
if seen:
items = []
for u, n in sorted(seen.items(), key=lambda kv: -int(kv[1]))[:14]:
try:
code = int(u)
except (TypeError, ValueError):
continue
name = USAGE_TO_KEY.get(code)
items.append(f"{name or '未知'}(0x{code:04X})×{n}")
if items:
extra += " 已见按键:" + "、".join(items)
return i18n.L(
f"按键映射自检:挂在宿主 PID {self._attached_pid}(本机共 {host_n} 个 WUDFHost),"
f"该宿主上「遥控器读」{ioctl} 次,输出长度:{dist};已收到 {sent} 条按键报文。{tip}",
f"Key-mapping self-check: attached to host PID {self._attached_pid} "
f"({host_n} WUDFHost processes on this machine), {ioctl} “remote read” calls in that "
f"host, output lengths: {dist}; {sent} key reports received. {tip}") + extra
def _pid_alive(self, pid):
"""进程还在不在(ctypes 直接问系统,比每次起 tasklist 便宜)。
⚠ 两个坑都踩过:① 不声明 restype,64 位下 HANDLE 会被当 int32 截断,
活着的进程被看成"没了"(于是每秒重挂一次);② OpenProcess 失败不一定是
"进程不存在"——权限不足也会失败,那种必须当"活着",否则会疯狂重挂。
"""
if not pid:
return False
try:
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.OpenProcess.restype = ctypes.c_void_p
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
k32.CloseHandle.argtypes = [ctypes.c_void_p]
h = k32.OpenProcess(0x1000, False, int(pid)) # QUERY_LIMITED_INFORMATION
if h:
k32.CloseHandle(h)
return True
return ctypes.get_last_error() != 87 # 87 = 进程不存在
except Exception:
return True # 问不出来就当它活着,别乱重挂
def _on_message(self, message, data):
if message.get("type") == "error":
self._log(f"按键映射脚本错误:{str(message.get('description'))[:200]}")
return
payload = message.get("payload") or {}
kind = payload.get("kind")
if kind == "ready":
self.ready = True
self.note = i18n.L("运行中", "Running")
self._emit("status", {"ready": True, "note": self.note})
self._log("按键映射:就绪")
elif kind == "report":
self._on_report(payload.get("raw") or "")
elif kind == "block_ack":
self._log(f"按键映射:已屏蔽 {payload.get('count', 0)} 个键的原生动作")
elif kind == "hb":
self._hb = payload # 诊断用:看这个宿主到底读到了什么
elif kind == "error":
self._log(f"按键映射:{payload.get('message')}")
def _on_report(self, raw):
"""raw 形如 '02 42 00'。"""
self.last_seen = time.time()
if self.on_activity:
try:
self.on_activity()
except Exception:
pass
parts = raw.split()
if len(parts) != 3:
return # 遥控器固定 3 字节;9 字节是别的键鼠
try:
b = [int(x, 16) for x in parts]
except ValueError:
return
usage = b[1] | (b[2] << 8)
self.reports += 1
if usage == 0: # 空闲帧 = 全部松开
for u in list(self._held):
self._release(u)
return
if usage in self._held:
return # 长按连发:只认第一下
self._held.add(usage)
kid = USAGE_TO_KEY.get(usage)
if not kid:
return
self._emit("key", {"id": kid, "down": True})
m = self.mapping.get(kid)
if m:
try:
self.player.down(m)
except Exception as e:
self._log(f"按键映射:执行 {kid} 失败 {type(e).__name__}: {e}")
def _release(self, usage):
self._held.discard(usage)
kid = USAGE_TO_KEY.get(usage)
if not kid:
return
self._emit("key", {"id": kid, "down": False})
m = self.mapping.get(kid)
if m:
try:
self.player.up(m)
except Exception:
pass
def stop(self):
self.stop_flag.set()
self.player.release_all()
try:
if self._script:
self._script.unload()
except Exception:
pass
try:
if self._session:
self._session.detach()
except Exception:
pass
self._script = None
self._session = None
self.ready = False
self.note = i18n.L("已停止", "Stopped")
+244
View File
@@ -0,0 +1,244 @@
# -*- coding: utf-8 -*-
"""预设方案:目标应用 × 输入法 → 一整套映射。
数据来源(都写清楚了,别凭印象改):
· WorkBuddy:**它自己的设置页 → 快捷键**(以该页为准)。原样对应:
Ctrl+, 打开设置 / Ctrl+D 语音录制开关 /
Ctrl+F 对话内搜索 / Enter 发送 / Shift+Enter 换行 / Ctrl+N 新建对话 /
Esc 停止生成 / Ctrl+[ 上一个任务 / Ctrl+] 下一个任务 / Ctrl+B 左侧栏 /
Ctrl+Shift+B 右侧产物面板 / F11 全屏 / Shift+Alt+W 唤起隐藏主窗口。
· Codex = ChatGPT 桌面客户端:**官方 Commands 参考页**(Windows 一节)。
最关键的几条:核准请求 = Enter、拒绝请求 = Esc;上一个/下一个聊天 =
Ctrl+Shift+[ / ];下一个需要关注的聊天 = Ctrl+Alt+A;新建聊天 = Ctrl+N;
切换侧边栏 = Ctrl+B;切换底部面板 = Ctrl+J;切换终端 = Ctrl+`;
搜索文件 = Ctrl+P;字号 = Ctrl+= / Ctrl+-;全屏 = F11。
· 输入法语音键:开源参考实现 ZSTDJan/windows-remote-mic-app 的
voice_hotkey_sync_windows.py(DEFAULT_PROVIDER_HOTKEYS)——
搜狗 rctrl、微信输入法 lctrl+lwin、Windows 语音输入 win+h。
千问 = 右 Alt,依据它自己的 voice_settings.json(windows.vk.165)。
"""
# 中英双语:界面上的名字与备注按当前语言取(机制见 i18n.py)。
import i18n
# 每个映射:type 取 key/combo/hold/holdcombo/mouse/text
def _k(v):
return {"type": "key", "value": v, "mods": [], "text": ""}
def _c(v, *mods):
return {"type": "combo", "value": v, "mods": list(mods), "text": ""}
def _wheel(direction):
"""上/下 = 滚轮,而且是**按住不放连续滚**(轻点滚一格,按住一直滚)。
跟出厂默认保持一致(app.py 的 RECOMMENDED 也是 holdmouse):
不然"一键套预设"会把默认那个更好用的行为换回"按一下只滚一格"。
"""
return {"type": "holdmouse", "value": direction, "mods": [], "text": ""}
# 「审批」这两个动作:ChatGPT/Codex 官方就是 Enter 批准、Esc 拒绝。
# 抽成函数,将来要换成 y/n 或 1/2 只改这一处。
def _approve_yes():
return _k("ENTER")
def _approve_no():
return _k("ESC")
def _text(s):
"""文本动作:按下就把这段字**打进去**。
⚠ 故意**不补回车**(按 OK 才是回车)—— 2026-09-15 跟需求方确认过:
`/clear`、`/exit`、`commit + push` 这些打完字,由用户自己按 OK 提交,
这样也能先看一眼、不满意就删掉,比自动回车安全。
"""
return {"type": "text", "value": "", "mods": [], "text": s}
APPS = {
"workbuddy": {
"name": i18n.pair("WorkBuddy", "WorkBuddy"),
"note": i18n.pair("按 WorkBuddy 自己的快捷键页做的(最准)",
"Built from WorkBuddy's own shortcuts page (most accurate)"),
"keys": {
"ok": _k("ENTER"), # 发送消息
"back": _k("ESC"), # 停止生成
"up": _wheel("wheel_up"),
"down": _wheel("wheel_down"),
"left": _c("LBRACKET", "CTRL"), # 上一个任务
"right": _c("RBRACKET", "CTRL"), # 下一个任务
"home": _c("N", "CTRL"), # 新建对话
"power": _c("W", "SHIFT", "ALT"), # 唤起/隐藏主窗口
"input": _c("F", "CTRL"), # 对话内搜索
"mute": _c("B", "CTRL"), # 切换左侧栏
"volup": _c("B", "CTRL", "SHIFT"), # 切换右侧产物面板
"voldown": _k("F11"), # 全屏
"youtube": _approve_yes(), # 审批:同意
"netflix": _approve_no(), # 审批:拒绝
},
},
"codex": {
"name": i18n.pair("Codex / ChatGPT 客户端", "Codex / ChatGPT desktop app"),
"note": i18n.pair("按 OpenAI 官方 Commands 参考页(Windows)做的",
"Built from OpenAI's official Commands reference (Windows)"),
"keys": {
"ok": _k("ENTER"), # 发送 / 批准请求
"back": _k("ESC"), # 停止 / 拒绝请求
"up": _wheel("wheel_up"),
"down": _wheel("wheel_down"),
"left": _c("LBRACKET", "CTRL", "SHIFT"), # 上一个聊天
"right": _c("RBRACKET", "CTRL", "SHIFT"), # 下一个聊天
"home": _c("N", "CTRL"), # 新建聊天
"power": _k("F11"), # 全屏
"input": _c("A", "CTRL", "ALT"), # 下一个需要关注的聊天
"mute": _c("B", "CTRL"), # 切换侧边栏
"volup": _c("EQUAL", "CTRL"), # 增大字号
"voldown": _c("MINUS", "CTRL"), # 减小字号
"youtube": _approve_yes(),
"netflix": _approve_no(),
},
},
"claude": {
"name": i18n.pair("Claude Code", "Claude Code"),
"note": i18n.pair("按 Claude Code 命令行的习惯做的:方向键导航,另三个键直接打斜杠命令/指令",
"Built around Claude Code's CLI habits: arrow-key navigation plus three "
"keys that type slash commands / a prompt"),
"keys": {
"ok": _k("ENTER"), # 提交输入
"back": _k("BACKSPACE"), # 删一个字符
"up": _k("UP"), # ↓ 普通方向键(不用滚轮:菜单/历史更直观)
"down": _k("DOWN"),
"left": _k("LEFT"),
"right": _k("RIGHT"),
"home": _k("ESC"), # 取消当前输入 / 关掉补全
"power": _text("/exit"), # 打字:退出
"input": _c("TAB", "SHIFT"), # 反向切换(权限模式 / 补全候选)
"mute": _k("ENTER"), # 和 OK 一样是回车(需求方确认:保持截图那套)
"volup": _wheel("wheel_up"), # 侧边:按住不放连续滚
"voldown": _wheel("wheel_down"),
"youtube": _text("/clear"), # 打字:清空上下文
"netflix": _text("commit + push"), # 打字:让它提交并推送
},
},
"generic": {
"name": i18n.pair("通用(浏览器 / 任意程序)", "Generic (browser / any app)"),
"note": i18n.pair("只用各程序都常见的键,不依赖具体 App",
"Only keys that are common across apps; not tied to one app"),
"keys": {
"ok": _k("ENTER"),
"back": _k("ESC"),
"up": _wheel("wheel_up"),
"down": _wheel("wheel_down"),
"left": _c("PAGEUP", "CTRL"), # 上一个标签页/会话
"right": _c("PAGEDOWN", "CTRL"), # 下一个标签页/会话
"home": _c("T", "CTRL"), # 新标签页
"power": _k("F11"), # 全屏
"input": _c("L", "CTRL"), # 地址栏 / 行号
"mute": _c("B", "CTRL"), # 侧边栏
"volup": _c("EQUAL", "CTRL"),
"voldown": _c("MINUS", "CTRL"),
"youtube": _approve_yes(),
"netflix": _approve_no(),
},
},
}
# 输入法 → 语音键。status: ok=已确认 / try=照参考实现填、未确认 / no=已知不行
IMES = {
"qwen": {
"name": i18n.pair("千问输入法", "Qianwen IME"),
"spec": {"mode": "hold", "key": "RALT", "mods": []},
"status": "ok",
"note": i18n.pair("按住右 Alt。依据它自己的配置(windows.vk.165)。"
"需要在「语音」页开一次「允许注入」,否则它会丢弃程序发的按键。",
"Hold Right Alt. Based on its own config (windows.vk.165). "
"You must turn on “allow injection” once on the “Voice” page, "
"otherwise it discards the keys this program sends."),
},
"sogou": {
"name": i18n.pair("搜狗语音输入", "Sogou voice input"),
"spec": {"mode": "hold", "key": "RCTRL", "mods": []},
"status": "ok",
"note": i18n.pair("按住右 Ctrl。已确认:搜狗语音助手自己的日志里写着 "
"`[KeyboardService] Loaded long press hotkey from store: RightCtrl`,"
"而且 longPressHotkeyEnabled/freespeakEnabled 都是 true。"
"它跟千问的语音键(右 Alt)**不冲突**,可以同时装。"
"(搜狗那个设置界面偶尔会被卡在新手引导里打不开,但语音键照旧是这个。)",
"Hold Right Ctrl. Confirmed: Sogou's own voice-assistant log says "
"`[KeyboardService] Loaded long press hotkey from store: RightCtrl`, "
"and longPressHotkeyEnabled/freespeakEnabled are both true. "
"It does **not conflict** with Qianwen's voice key (Right Alt), "
"so both can be installed at the same time. "
"(Sogou's settings window occasionally gets stuck in its "
"onboarding wizard, but the voice key is still this one.)"),
},
"wechat": {
"name": i18n.pair("微信输入法", "WeChat IME"),
"spec": {"mode": "hold", "key": "LWIN", "mods": ["CTRL", "LWIN"]},
"status": "try",
"note": i18n.pair("按住 Ctrl+Win(微信输入法 2.1.2 迁移后的默认)。"
"它不提供静默改键接口,改完要在它自己的设置里核对一致。",
"Hold Ctrl+Win (the default after the WeChat IME 2.1.2 migration). "
"It offers no silent rebinding API, so check that its own settings "
"agree with this."),
},
"windows": {
"name": i18n.pair("Windows 语音输入(系统自带)", "Windows voice typing (built in)"),
"spec": {"mode": "tap", "key": "H", "mods": ["LWIN"]},
"status": "ok",
"note": i18n.pair("按一下 Win+H。系统固定,且是**开关式**不是按住,所以模式必须是「按一下」。"
"中文识别质量一般。",
"Tap Win+H. Fixed by the OS — and it is a **toggle**, not "
"push-to-talk, so the mode must be “tap”. Recognition quality "
"is mediocre."),
},
"doubao": {
"name": i18n.pair("豆包输入法", "Doubao IME"),
"spec": {"mode": "hold", "key": "RALT", "mods": []},
"status": "no",
"note": i18n.pair("按住右 Alt。⚠ 已知不行:豆包会拦程序注入的按键"
"(要挂进它的进程里抹掉注入标记,本程序不做这种事)。",
"Hold Right Alt. ⚠ Known not to work: Doubao blocks injected "
"keystrokes (getting around it would mean hooking its process "
"to wipe the injection flag — this program does not do that)."),
},
"custom": {
"name": i18n.pair("自定义(不动现有语音设置)", "Custom (leave the voice settings alone)"),
"spec": None,
"status": "ok",
"note": i18n.pair("保持你当前在「语音」页里的设置不变。",
"Keeps whatever you currently have on the “Voice” page."),
},
}
DEFAULT_APP = "workbuddy"
DEFAULT_IME = "qwen"
def catalog():
"""给前端用的完整目录(含每个键的中文名与说明)。"""
return {
"apps": [{"id": k, "name": i18n.pick(v["name"]), "note": i18n.pick(v["note"]),
"keys": v["keys"]} for k, v in APPS.items()],
"imes": [{"id": k, "name": i18n.pick(v["name"]), "spec": v["spec"],
"status": v["status"], "note": i18n.pick(v["note"])}
for k, v in IMES.items()],
"default_app": DEFAULT_APP,
"default_ime": DEFAULT_IME,
}
def build(app_id, ime_id, current=None):
"""返回 (keys, voice)。voice 为 None 表示「保持现有」。"""
app = APPS.get(app_id) or APPS[DEFAULT_APP]
ime = IMES.get(ime_id) or IMES[DEFAULT_IME]
keys = {k: dict(v) for k, v in app["keys"].items()} # 深拷贝一份
voice = None if ime["spec"] is None else dict(ime["spec"])
return keys, voice
+14
View File
@@ -0,0 +1,14 @@
# VibeMote —— 按键映射依赖(**可选**)
#
# 不装这个也能用:语音键走 ATVV 通道,跟 frida 无关。
# 装它才能把方向键 / OK / 音量键这些映射成键盘鼠标。
#
# 为什么是 frida:遥控器的 HID 报告被 Windows 的 UMDF 蓝牙驱动(WUDFHost.exe)
# 消费掉了 —— Raw Input 和直接读 HID 设备都收不到。只能注入 WUDFHost,
# 挂钩 ntdll!NtDeviceIoControlFile 的 IOCTL 0x80018483,抄它的输出缓冲区。
#
# ⚠ 体积:_frida.pyd 一个文件就 127MB,且不可裁剪 —— 这是它唯一的缺点。
# ⚠ 首次挂载会弹一次 UAC(frida 给自己装助手进程);本项目的「一键优化」
# 可以把自启改成最高权限计划任务,之后就不会再弹。
#
frida>=17.18,<18 # 实测 17.18.0
+11
View File
@@ -0,0 +1,11 @@
# VibeMote —— 基础依赖(语音键 + 遥控器麦克风)
#
# 只需要这三个,**不需要 numpy**:音频处理(IMA ADPCM 解码 + 重采样)是纯 Python 写的。
# 版本是实测通过的组合(Python 3.13 / Windows 11)。
#
# 装不上/太慢就换源(实测顺序,详见 AGENTS.md):
# pip install -r requirements.txt -i https://mirrors.cloud.tencent.com/pypi/simple
#
bleak==3.0.2 # 蓝牙 GATT:语音键状态通道(ab5e0004) + 遥控器麦克风音频(ab5e0003)
sounddevice==0.5.6 # 音频输入输出(PortAudio 绑定):把遥控器麦克风送进虚拟声卡
cffi>=1.16 # sounddevice 的依赖,一般自动带上;实测 2.1.1
+128
View File
@@ -0,0 +1,128 @@
/*
* 遥控器 HID 报文旁路(Frida 脚本,挂在 WUDFHost.exe 里)
*
* 原理:BLE HID(HOGP)在 Windows 上是 UMDF 驱动,跑在 WUDFHost.exe 中。
* 驱动通过 ntdll!NtDeviceIoControlFile + IOCTL 0x80018483 把 GATT 特征
* (= HID 输入报告)读上来,报告就在这次调用的**输出缓冲区**里。
* 在那个缓冲区上抄一份,就绕开了整个 HID 映射层。
*
* 只做两件事:
* 1. 把 3 字节的遥控器报告送给 Python(内容有变化才送,避免空闲帧刷屏)
* 2. 把「已被映射的键」的 usage 原地写 0 —— 驱动以为没按键,原生动作
* 不再发生,于是不会「原生 + 映射」双发(音量键就不会顺带调音量)
*
* 为什么只认 3 字节:同一个 WUDFHost 还服务别的蓝牙键鼠,它们吐 9 字节
* 键盘报告,混在一起。遥控器是纯 Consumer Control,固定 3 字节。
*/
const READ_IOCTL = 0x80018483;
let lastRaw = null;
let sent = 0;
let total = 0;
let blockCount = 0;
let blockUsages = {};
/* 诊断计数:真机上出现过"钩子挂上了、界面显示就绪、但一个按键报文都收不到"
* (另一支遥控器 / 挂错了宿主)。只看成功与否分不清是哪种,所以把原始计数报给 Python:
* ioctl = 命中"遥控器那个读 IOCTL"的次数(0 → 多半挂错了宿主)
* lens = 该 IOCTL 的输出长度分布(出现 3 以外的长度 → 报文格式跟预期不同)
* weird = 3 字节但首字节不是 0x02 的样例(最多留 6 条) */
let ioctl = 0;
let lens = {};
let weird = {};
/* 见过的 usage 清单(usage → 次数)。用来"认人":日志里把这台遥控器**实际发过哪些键**
* 列出来,兼容款/山寨款多出来的那些 usage(比如语音键)就能一眼看到 —— 换遥控器时不用猜。 */
let seen = {};
function toHex(ptr, len) {
const b = new Uint8Array(ptr.readByteArray(len));
let s = "";
for (let i = 0; i < b.length; i++) {
s += b[i].toString(16).padStart(2, "0");
if (i + 1 < b.length) s += " ";
}
return s;
}
/* 若这份报告是「已被映射的键」,把 usage 两字节写 0,抹掉原生动作。 */
function nullify(ptr, len) {
if (ptr.isNull() || len !== 3) return;
try {
const b = new Uint8Array(ptr.readByteArray(3));
if (b[0] !== 0x02) return; // 不是遥控器那份报告
const usage = b[1] | (b[2] << 8);
if (!usage || !blockUsages[usage]) return;
ptr.writeByteArray([0x00, 0x00]); // usage_lo / usage_hi = 0
blockCount++;
} catch (e) {
/* 写失败就算了:最坏情况是原生动作也会发生一次 */
}
}
recv(function (msg) {
if (msg && msg.type === "block") {
const next = {};
(msg.usages || []).forEach(function (u) { next[u >>> 0] = true; });
blockUsages = next;
send({ kind: "block_ack", count: Object.keys(next).length });
}
});
const ntdll = Process.findModuleByName("ntdll.dll");
const target = ntdll ? ntdll.findExportByName("NtDeviceIoControlFile") : null;
if (target === null) {
send({ kind: "error", message: "没找到 ntdll!NtDeviceIoControlFile" });
} else {
send({ kind: "ready", pid: Process.id });
Interceptor.attach(target, {
onEnter(args) {
// 0=FileHandle 1=Event 2=ApcRoutine 3=ApcContext 4=IoStatusBlock
// 5=IoControlCode 6=InBuf 7=InLen 8=OutBuf 9=OutLen
total++;
if (args[5].toUInt32() === READ_IOCTL) {
this.cap = true;
this.out = args[8];
this.outLen = args[9].toUInt32();
ioctl++;
}
},
onLeave(retval) {
if (!this.cap) return;
this.cap = false;
const n = this.outLen;
lens[n] = (lens[n] || 0) + 1;
// 3 字节且首字节是 0x02 的,把它的 usage 记进"见过哪些键"(含没被映射的)
if (n === 3 && !this.out.isNull()) {
try {
const b3 = new Uint8Array(this.out.readByteArray(3));
if (b3[0] === 0x02) {
const u = b3[1] | (b3[2] << 8);
if (u) seen[u] = (seen[u] || 0) + 1;
} else if (Object.keys(weird).length < 6) {
weird[toHex(this.out, 3)] = 1;
}
} catch (e) { /* 读不到就算了 */ }
}
// 只认 3 字节 + 成功返回;其它长度是同一宿主里别的蓝牙键鼠
if (retval.toUInt32() !== 0 || this.outLen !== 3 || this.out.isNull()) return;
let raw;
try {
raw = toHex(this.out, 3);
} catch (e) {
return;
}
if (raw !== lastRaw) { // 内容有变化才上报:空闲帧不再刷屏
lastRaw = raw;
sent++;
send({ kind: "report", raw: raw });
}
// 快照已发出,现在才可以安全改写输出缓冲区
nullify(this.out, this.outLen);
}
});
}
// 轻量心跳:只给 Python 做健康判断用(20 秒一次,可忽略不计)
setInterval(function () {
send({ kind: "hb", total: total, sent: sent, blocked: blockCount,
ioctl: ioctl, lens: lens, weird: weird, seen: seen });
}, 20000);
+1591
View File
File diff suppressed because it is too large. Load diff
+1415
View File
File diff suppressed because it is too large. Load diff
+3678
View File
File diff suppressed because it is too large. Load diff
+308
View File
@@ -0,0 +1,308 @@
' ===========================================================================
' VibeMote launcher (PURE ASCII ON PURPOSE - DO NOT ADD CHINESE HERE)
' ---------------------------------------------------------------------------
' Why ASCII only: wscript reads .vbs using the system ANSI codepage (GBK on
' Chinese Windows). If this file contains UTF-8 Chinese comments, the bytes
' get misread and the parser dies with "Unterminated string constant"
' (800A0409). Line endings must be CRLF too.
'
' What it does: find a pythonw.exe and start app.py with a HIDDEN window
' (window style 0), so the user never sees a black console box.
' Double-click -> starts the app, waits until the console answers, opens browser
' --silent -> start only, no browser (used by the autostart entry)
'
' Why it waits and checks (added 2026-09-13): app.py runs under pythonw.exe,
' where stdout is None - so any startup failure is COMPLETELY silent: no
' window, no error, no browser, nothing. The user report was literally
' "I extracted it and double-click does nothing". Now the launcher polls
' http://127.0.0.1:<port>/api/state and, if the app never answers, shows a
' message and opens client.log in Notepad so there is always a clue.
' ===========================================================================
Option Explicit
Dim fso, sh, base, py, cmd, i, silent, boot, check, port, ok, tries, before, now
Set fso = CreateObject("Scripting.FileSystemObject")
Set sh = CreateObject("WScript.Shell")
base = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = base
silent = False
boot = False
check = False
For i = 0 To WScript.Arguments.Count - 1
If LCase(WScript.Arguments(i)) = "--silent" Then silent = True
' --boot = this copy was started by the BOOT AUTOSTART entry, not by a user
' double-click. app.py uses it to suppress the automatic "install the virtual
' audio cable" UAC prompt: at boot nobody is at the machine, so the prompt
' would just sit there - and it would come back on every single boot.
' (Keep this file pure ASCII + CRLF: wscript mis-parses non-ASCII comments.)
If LCase(WScript.Arguments(i)) = "--boot" Then boot = True
' --check = parse the arguments, find an interpreter, print one line, exit.
' Used by the automated test and by the packagers (_package.py / _build_offline.py)
' so a broken launcher can never be shipped again: this whole block runs under
' "Option Explicit", so forgetting to Dim a new variable kills the launcher with
' 800A01F4 at the *assignment* - which is exactly what happened once, and it was
' only noticed after the package had been published.
If LCase(WScript.Arguments(i)) = "--check" Then check = True
Next
If check Then silent = True ' never pop a MsgBox that would block an automated run
py = PickPy()
If check Then
WScript.Echo "VBS-CHECK-OK silent=" & silent & " boot=" & boot & " py=" & py
WScript.Quit 0
End If
If Len(py) = 0 Then
If Not silent Then
MsgBox "No Python found." & vbCrLf & vbCrLf & _
"Install Python 3.10+ from python.org and TICK 'Add python.exe to PATH'." & vbCrLf & _
"Then run this file again." & vbCrLf & vbCrLf & _
"(Or use the offline package, which carries its own Python.)", 16, "VibeMote"
End If
WScript.Quit 1
End If
port = ReadPort()
' Remember the ready marker's timestamp. We must NOT delete it: if a copy of the
' app is ALREADY running, the instance we launch will hit the single-instance
' lock, exit immediately, and never write a new marker - deleting the marker
' would make that case look like a failed start (and re-clicking the icon while
' it runs is the most common double-click scenario of all).
before = MarkerStamp()
' app.py must NOT open the browser itself: we do it only after the console
' really answers, so a failed start can never look like "nothing happened".
cmd = """" & py & """ """ & base & "\app.py"" --silent"
If boot Then cmd = cmd & " --boot"
If port > 0 Then cmd = cmd & " --port " & port
On Error Resume Next
sh.Run cmd, 0, False
If Err.Number <> 0 Then
If Not silent Then
MsgBox "Failed to start:" & vbCrLf & Err.Description & vbCrLf & vbCrLf & _
"Tried interpreter: " & py, 16, "VibeMote"
End If
Err.Clear
WScript.Quit 1
End If
On Error GoTo 0
' Two independent signals, and the cheap one is the hot path:
' * ready marker is NEW -> plain file check (instant), used for a fresh start
' * console answers HTTP -> needed for "another copy is already running",
' because that instance exits immediately and never writes a new marker
' Probing HTTP only ONCE matters: CreateObject("MSXML2.XMLHTTP") + Send goes
' through WinINet (system proxy lookup) and measured ~2s per call here, so
' polling it 30 times made a failed start take 82 seconds. The marker loop is
' 25 * 700ms = ~17s, which is plenty for a cold Bluetooth/audio stack.
ok = False
If ServerUp(port) Then
ok = True
Else
For tries = 1 To 25
now = MarkerStamp()
If Len(now) > 0 And now <> before Then
ok = True
port = ReadyPort(port)
Exit For
End If
WScript.Sleep 700
Next
End If
If ok Then
If Not silent Then sh.Run "http://127.0.0.1:" & port & "/", 1, False
WScript.Quit 0
End If
' Not up. Common causes: another client holds the single-instance lock, or the
' interpreter/deps are broken. Either way client.log now has the reason.
If Not silent Then
MsgBox "VibeMote could not start." & vbCrLf & vbCrLf & _
"No answer from http://127.0.0.1:" & port & "/" & vbCrLf & _
"client.log will be opened - the last lines say why." & vbCrLf & vbCrLf & _
"(Another copy already running is the most common cause.)", 48, "VibeMote"
If fso.FileExists(base & "\client.log") Then
sh.Run "notepad.exe """ & base & "\client.log""", 1, False
End If
End If
WScript.Quit 1
' The port out of the ready marker (its first line). Keeps the old value on failure.
Function ReadyPort(fallback)
Dim ts, s
ReadyPort = fallback
On Error Resume Next
Set ts = fso.OpenTextFile(base & "\_ready.txt", 1)
If Err.Number = 0 Then
If Not ts.AtEndOfStream Then
s = Trim(ts.ReadLine)
If Len(s) > 0 Then
If IsNumeric(s) Then
If CLng(s) > 0 And CLng(s) < 65536 Then ReadyPort = CLng(s)
End If
End If
End If
ts.Close
End If
Err.Clear
On Error GoTo 0
End Function
' Read the UI port from config.json (users can change it). 0 = not found.
Function ReadPort()
Dim p, ts, txt, k, n, s, c
ReadPort = 8787
p = base & "\config.json"
If Not fso.FileExists(p) Then Exit Function
On Error Resume Next
Set ts = fso.OpenTextFile(p, 1)
If Err.Number <> 0 Then
Err.Clear
On Error GoTo 0
Exit Function
End If
txt = ts.ReadAll
ts.Close
On Error GoTo 0
k = InStr(txt, """ui_port""")
If k = 0 Then Exit Function
' take the first run of digits after the key
n = 0
s = ""
For n = k + 9 To Len(txt)
c = Mid(txt, n, 1)
If c >= "0" And c <= "9" Then
s = s & c
ElseIf Len(s) > 0 Then
Exit For
End If
Next
If Len(s) > 0 Then ReadPort = CLng(s)
If ReadPort < 1 Or ReadPort > 65535 Then ReadPort = 8787
End Function
' Timestamp of the ready marker, or "" when it does not exist yet.
Function MarkerStamp()
MarkerStamp = ""
On Error Resume Next
If fso.FileExists(base & "\_ready.txt") Then
MarkerStamp = CStr(CDbl(fso.GetFile(base & "\_ready.txt").DateLastModified))
End If
Err.Clear
On Error GoTo 0
End Function
' Is the console answering yet?
' Prefer ServerXMLHTTP (WinHTTP: no system-proxy auto-detect, supports
' timeouts); fall back to plain XMLHTTP. Returns False if neither is available,
' which just means the marker-file path is used instead.
Function ServerUp(p)
Dim h
ServerUp = False
On Error Resume Next
Set h = CreateObject("MSXML2.ServerXMLHTTP.6.0")
If Err.Number <> 0 Then
Err.Clear
Set h = CreateObject("MSXML2.XMLHTTP")
End If
If Err.Number <> 0 Then
Err.Clear
On Error GoTo 0
Exit Function
End If
h.setTimeouts 800, 800, 800, 1500
h.Open "GET", "http://127.0.0.1:" & p & "/api/state", False
h.Send
If Err.Number = 0 Then
If h.Status = 200 Then ServerUp = True
End If
Err.Clear
On Error GoTo 0
End Function
Function PickPy()
Dim cands, roots, i, s, f, p, ts, line
' 0) explicit override: python.txt next to this script holds ONE line with the
' full path to pythonw.exe. Useful when the machine already has a prepared
' environment (or when the stick carries a private runtime).
p = base & "\python.txt"
If fso.FileExists(p) Then
On Error Resume Next
Set ts = fso.OpenTextFile(p, 1)
If Err.Number = 0 Then
If Not ts.AtEndOfStream Then
line = Trim(ts.ReadLine)
If Len(line) > 0 And fso.FileExists(line) Then
ts.Close
PickPy = line
Exit Function
End If
End If
ts.Close
End If
Err.Clear
On Error GoTo 0
End If
' 1) package-local virtualenv (created by the "install dependencies" button).
' It must be a COMPLETE venv. A half-deleted .venv still has
' Scripts\pythonw.exe lying around but cannot run anything, and picking it
' makes double-click do absolutely nothing (no window, no error, no browser)
' - which is exactly the "uninstalled it and now it will not start" report.
' A real venv always has Scripts\python.exe + pyvenv.cfg, so check both.
p = base & "\.venv\Scripts\pythonw.exe"
If fso.FileExists(p) Then
If fso.FileExists(base & "\.venv\Scripts\python.exe") And _
fso.FileExists(base & "\.venv\pyvenv.cfg") Then
PickPy = p : Exit Function
End If
End If
' 2) a python runtime shipped next to the app (offline / USB layout)
p = base & "\runtime\pythonw.exe"
If fso.FileExists(p) Then PickPy = p : Exit Function
' 3) common per-user and machine-wide installations
cands = Array( _
sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python313\pythonw.exe"), _
sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python312\pythonw.exe"), _
sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python311\pythonw.exe"), _
sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python310\pythonw.exe"), _
"C:\Python313\pythonw.exe", "C:\Python312\pythonw.exe", _
"C:\Python311\pythonw.exe", "C:\Python310\pythonw.exe")
For i = 0 To UBound(cands)
If fso.FileExists(cands(i)) Then PickPy = cands(i) : Exit Function
Next
' 4) scan one level of subdirectories under the usual Python roots
roots = Array( _
sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python"), _
"C:\", "D:\DEV")
For Each s In roots
If fso.FolderExists(s) Then
For Each f In fso.GetFolder(s).SubFolders
If fso.FileExists(f.Path & "\pythonw.exe") Then
PickPy = f.Path & "\pythonw.exe" : Exit Function
End If
If fso.FileExists(f.Path & "\Scripts\pythonw.exe") Then
PickPy = f.Path & "\Scripts\pythonw.exe" : Exit Function
End If
Next
End If
Next
' 5) last resort: whatever is on PATH
PickPy = "pythonw.exe"
End Function
+434
View File
@@ -0,0 +1,434 @@
# VibeMote — Remote Client Guide
> 🌐 [中文](说明.md)
Turn a **Google Chromecast / Google TV Bluetooth remote** into a talking-and-hotkey device for Windows.
| Remote button | Default action |
|---|---|
| **Voice button** (the one with the mic) | **Push-to-talk** — remote microphone → virtual audio cable → input method editor types the text |
| Up / Down | Mouse wheel up / down (**hold to keep scrolling**; a tap scrolls one notch) |
| Left / Right | `Ctrl+Shift+[` / `Ctrl+Shift+]` |
| OK / Back / Home / Power / Input source / Mute / YouTube / NETFLIX / Volume ± | See the "Key mapping" page in the UI — change them however you like |
---
## 1. What makes this build clean
- ✅ **No drivers installed** (no virtual HID, no kernel filter driver)
- ✅ **The voice part needs no administrator privileges.** Key mapping (optional) needs **one** elevation (Frida injects into the Bluetooth HID driver host); after one "One-click optimization" a highest-privilege scheduled task does it and it never prompts again. The other prompt is the virtual audio cable installer. Details in README section 5.5
- ✅ **No console windows flash**: single process + launched with the window hidden throughout, and nothing flashes on autostart either
- ✅ **No test mode, no disabling Secure Boot, no system file changes**
- ✅ **No network access** (except that one moment when you click "Install dependencies" yourself)
Three hard design rules, all of them in service of the points above:
1. **There is only one process.** Voice and keys both live in threads of the same process, with no Popen subprocesses —
so architecturally a console window simply cannot appear. (The only place a subprocess is started is dependency
installation, where the window is explicitly hidden.)
2. **The UI only listens on `127.0.0.1`**. Safe, and it will not trigger a Windows firewall prompt
(listening on `0.0.0.0` is what triggers it — and it triggers it repeatedly).
3. **Configuration follows the backend `config.json`**, and the frontend does not cache or re-derive it.
---
## 2. Quick start (three steps)
> **The top of the home page has a “First-time setup” checklist** that puts the state and a button for each of these
> in one place (runtime dependencies / virtual audio cable / Qianwen injection / autostart / one-click optimization) —
> just work down it, no digging through the "Settings" page. **The virtual audio cable is required**: without it,
> pressing the remote's voice button does absolutely nothing (the remote microphone has nowhere to play, so your
> input method editor never sees it).
| Step | What to do |
|---|---|
| 1 | Install **Python 3.10+** (the official installer from python.org; **be sure to check Add python.exe to PATH**) |
| 2 | Double-click **`启动遥控器.vbs`** — the UI opens by itself in your browser (`http://127.0.0.1:8787/`) |
| 3 | Click **"Install all"** on the home-page checklist (~140MB, one network access) — voice deps + key-mapping deps + the virtual audio cable in one go. If you only want the voice key for now, click "Voice deps only" (~10MB) |
Then:
| What else you want | Where |
|---|---|
| Pair the remote | Windows Settings → Bluetooth & devices → Add device → hold the remote's **Back + Home** buttons together to enter pairing mode |
| Install the virtual audio cable (the prerequisite voice needs) | Click **Install** on the "Virtual audio cable" row of the home-page checklist, or on the "Settings → Virtual audio cable" card. **On first launch the program starts it for you automatically** (it only auto-tries once). ⚠ One UAC prompt, then click "Install Driver" in the installer's window, and **you must reboot once** (VB-Audio's own requirement) |
| Select the microphone in your input method editor | Choose **`CABLE Output (VB-Audio Virtual Cable)`**. ⚠ Change it **only there**: keep your **real microphone as the system default recording device** — if the default is the virtual cable, WeChat voice messages, Voice Recorder and similar apps will pick up that line too |
| Qianwen IME users | Click **Turn on** on that checklist row (the program also turns it on automatically on first launch); Qianwen discards injected keystrokes by default |
| Run automatically at boot | Click **Enable** on the "Launch at startup" row (or use "Settings → Autostart"). **Normal privileges, no UAC prompt, silent background start at boot** |
| **Make UAC never prompt again** | Click **Optimize** on the checklist row (prompts for administrator once) — see section 2.1 below |
| **Want to wipe it completely / reinstall from scratch** | "Settings → Full uninstall / cleanup" — see section 2.2 |
---
## 2.1. Why does Frida trigger UAC? How to make it never prompt again
**Symptom**: while using it, a "Do you want to allow this app to make changes to your device?" prompt appears, with
`frida-helper` visible around the title; if you ignore it or click "No", it pops up again a while later.
**Cause**: key mapping relies on Frida to read the reports in the Bluetooth driver. When Frida wants to inject into a
process that is not its own, it **tries to register a temporary service** (named `frida-<PID>-x86_64`) to do this —
and registering a system service requires administrator rights, hence the UAC prompt. If it is not approved or is
interrupted midway, that service becomes a dead leftover. Retrying over and over = prompts over and over = a pile of
leftover services accumulating.
**You have to do both of these together to fix it at the root**:
1. **On the program side** (already built in): attach failures now use **exponential backoff**; after 3 consecutive
permission failures it **stops for 5 minutes** instead of frantically retrying and spamming prompts.
2. **On the privilege side**: "Settings → **One-click optimization (prompts for administrator once)**". It will:
- clean up all leftover `frida-*` services and helper processes;
- switch autostart from "a Run entry with normal privileges" to "**a scheduled task with highest privileges +
logon trigger**" — at boot the Task Scheduler starts it silently as administrator, so every permission Frida
needs is already in place, and **from then on not a single UAC prompt will appear**;
- delete the original Run entry, so two copies do not start at the same time and fight over the Bluetooth channel.
> If you only want to chat and talk, and do not need the D-pad/OK mappings, **just don't install frida** (don't click
> "Install key-mapping dependencies"), and this prompt will never appear at all — nor will any of the caveats below.
**Four things to know before you use key mapping** (full list in README section 5.5):
- **Antivirus may flag or silently quarantine it**: frida's module names are in several vendors' signature sets, and
it is ~47MB of the offline bundle. Symptom: dependencies "installed" but the D-pad/OK do nothing. Whitelist the
program folder and click "Install key-mapping dependencies" again. You do **not** need to disable Defender or
turn on test mode.
- **It relies on undocumented interfaces**: an undocumented IOCTL of the Windows Bluetooth HID driver plus one
registry path, and it pins `frida >=17.18,<18`. A Windows or frida update can silently break it — voice keeps
working, key mapping is disabled and the log says so.
- **The injected host also serves your other Bluetooth keyboards/mice**: we only rewrite the reports of the mapped
keys (erasing their native action), but if that host ever crashed, Bluetooth input would drop with it. If it
happens, restart the client or re-plug the adapter.
- **Protected games**: the injection looks like a cheat loader to anti-cheat, so a few kernel-anti-cheat games may
refuse to start. Quitting the client before gaming is the easy answer.
---
> **Usable right after boot**: at the moment Windows starts, the audio service / virtual audio cable is often ready
> later than the program. This program **will not give up because of that** — the voice channel retries every 5
> seconds, attaches automatically as soon as the sound card is ready, and rebuilds itself even if the connection drops
> midway. (If it only looked for the sound card once at startup, pressing the voice button after boot would never
> produce sound.)
---
## 2.2. Cleanup / uninstall (tick items one by one; use this when you want to reinstall from scratch)
"Settings → **Cleanup / uninstall (tick items one by one)**". **Nothing is ticked by default — tick an item and it
gets deleted; anything unticked is left completely alone.**
There are 12 tickable items (this is exactly what the UI shows; the ones marked 🔒 prompt for UAC once).
**Each item is also labelled with "does it exist right now / how big is it"** — for example, on the light package
"Delete dependencies (`.venv`)" shows "does not exist", so you are never left ticking a checkbox for something that
isn't there:
| Item | Description | 🔒 |
|---|---|---|
| Disable autostart (scheduled task) | Delete the highest-privilege scheduled task `VibeMote` | ✓ |
| Disable autostart (startup entry) | Delete `VibeMote` from the HKCU Run key | |
| Kill the client process | The voice-bridging + key-mapping process | |
| Kill the frida helper process | `frida-helper-x86 / x86_64` | |
| Clean up leftover frida services | `frida-<pid>-x86*` in HKLM | ✓ |
| Delete dependencies (`.venv`) | The bleak / sounddevice / cffi / frida you installed | |
| Delete `__pycache__` | Python bytecode cache | |
| Delete `python.txt` | The pointer file that specifies an interpreter | |
| Delete logs | `elevate_setup.log` / `client.log` | |
| Delete `config.json` | **Your key mappings will be lost**, back to the default scheme | |
| Delete the Qianwen "allow injection" switch | Once deleted, Qianwen discards injected keystrokes again | |
| Uninstall the virtual audio cable (VB-CABLE) | A **system-wide audio driver** — see the note below; unticked by default | ✓ |
| Clear virtual-audio-cable leftovers (registry / driver store) | A **repair action for when installing/uninstalling gets stuck** — see below; unticked by default | ✓ |
Three helper buttons: **Select none** / **Select "recommended cleanup"** (the 9 items above, keeping the config, the
Qianwen switch and the virtual audio cable) / **Select all**.
> ⚠ **"Uninstall the virtual audio cable" is the only item in the list that still needs a human click *and* a reboot**,
> which is why it is **unticked by default and not part of "recommended cleanup"**:
> - it is a **system-wide audio driver** that OBS, voice changers and streaming tools may also be using — removing it silences them;
> - there is **no silent uninstall**: the `UninstallString` that Windows "Apps & features" holds is the installer
> itself with no arguments, so a human has to click **"Remove Driver"** in its window;
> - per VB-Audio's own readme, you **must reboot** afterwards to finalise it.
>
> After you tick it and start cleanup: the program prompts for UAC once and **launches the official uninstaller**;
> the last two steps (clicking Remove Driver, rebooting) are yours. The log spells this out line by line.
> The program deliberately does **not** verify "did the uninstall succeed" — reporting "not deleted" before you have
> finished clicking would be a false alarm.
>
> **"Clear virtual-audio-cable leftovers"** exists for the case where installing *and* uninstalling are both stuck:
> the registry says it is installed, but no device is enumerated and clicking "Install Driver" does nothing — the
> leftovers are blocking the new install. It removes only VB-CABLE's own entries (the two service keys,
> `HKLM\SOFTWARE\VB-Audio\Cable`, the "Apps & features" entry, the driver package in the DriverStore and
> `C:\Program Files\VB\CABLE`) and **never touches Voicemeeter's keys or folders**, even though Voicemeeter shares
> the VB-Audio vendor key. Reboot once afterwards, then reinstall. When the yellow "the virtual audio cable is not
> usable right now" banner appears on the home page, the button next to it is this action.
> ⚠ **The list deliberately has no "delete the bundled Python runtime (`runtime\`)" item.**
> It is the Python that ships with the offline package (154MB after extraction); it is part of **the program itself**,
> not an "installed dependency". And "Install all" in the UI installs pip packages, which **cannot reinstall** a
> Python distribution — once you delete it, this machine is no longer "install-free", and there is no one-click way
> back. So the cleanup tool always keeps it.
>
> **Want to uninstall the whole program completely? Two steps, and don't reverse the order:**
> 1. Here, click **"Select 'recommended cleanup'"** → start cleanup (clears autostart + leftover frida services + dependencies);
> 2. Then **just delete the entire program directory**.
>
> The program is a purely portable deployment: no installer, nothing written to system directories, so after step 2
> nothing remains anywhere on the system.
**The execution order is fixed** (not the order you ticked things in; the wrong order fails):
```
disable autostart first → then kill processes → then delete services/files → finally terminate itself
```
This matters especially for "kill the process before deleting `.venv`" — on Windows **a running `pythonw.exe` is
locked**, and reversing the order reports "cannot delete". In extreme cases (when the bundled interpreter is the
only one on the machine) the program automatically schedules a background command to continue deleting after this
process has exited (the log will say "scheduled .venv deletion… about 2 seconds"); you don't have to do anything.
**Only want to reinstall dependencies** → just click "Select 'recommended cleanup'", and `config.json` is kept.
**Want to get fully back to "never installed"** → additionally tick `config.json` and the Qianwen switch by hand (or
simply "Select all").
Command-line equivalents:
```bat
python uninstall.py --items=venv,pycache :: delete only these two items
python uninstall.py --items=all :: delete everything (including config.json, the Qianwen switch and the virtual audio cable; but does **not** touch runtime\)
python uninstall.py --items=task,venv --dry-run :: list the plan only, do nothing
python uninstall.py --dry-run :: nothing ticked = nothing done
```
---
## 2.3. Which source do dependency downloads use? (installable in China without a VPN)
"Install dependencies" **automatically polls Chinese mirrors**, moving to the next one whenever one fails, and only
trying the official PyPI last. Each time it prints which source it used, so if something goes wrong you can see it
at a glance.
**This table is ranked by availability** (each one was verified to download, not copied from someone's web
recommendation):
| Order | Source | Result | Notes |
|---|---|---|---|
| 1 | Tencent Cloud | ✓ 1.1s | Fastest |
| 2 | USTC | ✓ 1.0s | The frida index is a bit slow (8.6s) |
| 3 | Aliyun | ✓ 1.4s | |
| 4 | Huawei Cloud | ✓ 1.1s | |
| 5 | TUNA | ✗ returns an empty index | Demoted to last |
| 6 | Official PyPI | ✓ 2.5s | Fallback (most reliable with a proxy / from outside China) |
> **NetEase 163 is not included**: its PyPI mirror stopped syncing long ago (bleak only goes up to 0.5.0), so
> including it would install an outdated version and produce baffling errors. Also, don't use those web configs that
> "list 8 sources at once".
**What to do if they all fail** (the program prints these three lines into the log):
1. Switch networks (phone hotspot) and click again
2. Change sources manually: edit the `PIP_MIRRORS` table at the top of `app.py` (one source per line)
3. **Fully offline**: see the `wheels\` directory in the next section — when the program detects it, **it is used
first, with no network access at all**
---
## 3. How to use the UI
Five pages on the left:
- **Status**: running state, remote address, whether the voice/key channels are ready, which dependencies are missing, start/stop/restart
- **Key mapping**: **first pick a "preset" at the top** (see section 3.1); below that is a diagram laid out like the
physical remote. Click a button → choose the **mapping type** on the right → choose the target key → save
- Mapping types:
| Type | Behaviour |
|---|---|
| `Single key` / `Key combo` | fires once on press (down and up immediately) |
| `Hold down` / `Hold combo` | stays held while the remote button is held, released when you let go (this is what "hold to talk" needs) |
| `Mouse` | sends one mouse action per press |
| **`Hold mouse`** | holds the mouse: the **wheel keeps scrolling while held** (a tap scrolls one notch); left/middle/right stay pressed until you let go (for dragging) |
| `Text` | types a piece of text on press |
- Up and Down default to **`Hold mouse` + wheel** — a tap scrolls one notch, holding keeps scrolling; nothing to configure
- Not sure which physical button corresponds to which item in the UI? Turn on **"Press a remote button to locate
it"**, press a button on the remote, and the UI jumps there automatically
- Can't fill in the target? Click **"Key capture"** and press a key on your physical keyboard — **or scroll the
wheel / click a mouse button**. When it captures a mouse action it picks the type from the current "tap / hold"
mode for you (scrolling while in "Hold down" gives you "hold and keep scrolling" directly)
- Mapped buttons show right under the remote button what they are mapped to; pressing one plays an animation and
lights an indicator in the UI
- **Voice**: voice-button target (hold / tap + modifier + main key), input method editor presets, test key send
- **Settings**: Bluetooth address + scan, audio gain / AGC / built-in microphone passthrough, autostart, environment check, install dependencies
- **Logs**: live logs; when something goes wrong, look here first
Mapping changes **take effect immediately**, no restart needed.
---
## 3.1. Presets: pick a "application" + pick an "input method editor" and the whole set is configured in one click
At the top of the "Key mapping" page there are two dropdowns; pick both and click "Apply this preset" to configure all
14 buttons + the voice button at once. Below, it **previews the whole mapping set first** and then lets you decide —
it never changes anything behind your back.
### Application (decides how the 14 buttons are mapped)
| Preset | Basis |
|---|---|
| **WorkBuddy** | **Its own "Settings → Shortcuts" page** (the most accurate): `Ctrl+[`/`Ctrl+]` previous/next task, `Ctrl+N` new conversation, `Ctrl+B` left sidebar, `Ctrl+Shift+B` right artifact panel, `Ctrl+F` in-conversation search, `Ctrl+,` open settings, `F11` fullscreen, `Shift+Alt+W` bring up the main window |
| **Codex / ChatGPT client** | **OpenAI's official Commands reference page (Windows)**: `Ctrl+Shift+[`/`]` previous/next chat, `Ctrl+Alt+A` next chat needing attention, `Ctrl+N` new chat, `Ctrl+B` sidebar, `Ctrl+J` bottom panel, ``Ctrl+` `` terminal, `Ctrl+P` search files, `Ctrl+=`/`Ctrl+-` font size, `F11` fullscreen |
| **Claude Code** | Built around Claude Code's CLI habits: plain `↑`/`↓`/`←`/`→` arrows, `Enter` to submit, `Backspace`, `Esc`, `Shift+Tab` to cycle backwards (**the mute key is `Enter` as well** — the same as OK, on purpose); three "typing" keys — `/clear`, `commit + push`, `/exit` (**you press OK/Enter yourself to submit**); the side ± keys still scroll continuously while held |
| **Generic** | Only the keys common to every program (`Ctrl+PageUp/PageDown` switch tabs, `Ctrl+T` new tab, `Ctrl+L` address bar, …) |
### ★ Approval Yes / No
The YouTube / NETFLIX buttons are permanently mapped to **approve / deny an approval request**:
- **ChatGPT / Codex official definition: approve request = `Enter`, deny request = `Esc`**
- So these two buttons are `Enter` / `Esc` (the same keys as OK / Back, which amounts to two extra big
"approve/deny on the fly" buttons)
> If the program you use recognises different keys in its approval dialog (say `y` / `n` or `1` / `2`),
> just change the two functions `_approve_yes()` / `_approve_no()` in `profiles.py` — one place, and it takes effect.
### Input method editor (decides what the voice button sends)
| Input method editor | Trigger key | Mode | Status |
|---|---|---|---|
| Qianwen IME | Right Alt | Hold | ✅ Works (its own config says `windows.vk.165`) |
| Sogou voice input | Right Ctrl | Hold | ✅ Works (the Sogou voice assistant log says the long-press key is RightCtrl) |
| WeChat IME | Ctrl + Win | Hold | ⚠️ WeChat provides no silent key-remapping interface, so after changing it you must verify in its settings |
| Windows voice input | Win + H | **Tap** | ✅ Fixed by the system, and it is a toggle rather than a hold |
| Doubao IME | Right Alt | Hold | ❌ Does not work: Doubao intercepts program-injected keystrokes |
| Custom | —— | —— | Keep whatever you currently have set on the "Voice" page |
---
## 4. Switching the voice button to another input method editor
Which key the remote's voice button sends depends on the input method editor you use. **The fastest way is the preset
dropdown in section 3.1 above** (pick the input method editor and it is filled in); you can also set it manually on
the "Voice" page in the UI:
| Input method editor | Its "push-to-talk" shortcut | Status |
|---|---|---|
| **Qianwen IME** | Hold **Right Alt** | ✅ Verified (the "allow injection" switch must be on) |
| **Sogou voice input** | Hold **Right Ctrl** | Fill it in per the preset; if its settings say a different key, what it displays wins |
| **WeChat IME** | Hold **Ctrl + Win** | Already filled in by the preset; WeChat provides no silent key remapping, so you must check its own settings match |
| **Windows voice input** | Tap **Win + H** | Built into the system; **the mode must be set to "Tap"** |
| **Doubao IME** | Hold **Right Alt** | ⚠️ Known not to work: Doubao intercepts program-injected keystrokes |
| Custom | Up to you | The opening left for other voice software |
---
## 5. Copying to another computer / deploying from a USB stick
Just copy the whole `vibe-mote` folder over — **the package is under 1MB** (dependencies are installed on the target
machine).
**Route A: install dependencies online** (the default)
On the target machine, double-click `启动遥控器.vbs` → click "Install base dependencies" in the UI.
**Route B: fully offline** (recommended for machines with no network)
On a machine **with a network and the same OS**, download the dependencies as wheels, drop them into the `wheels\`
directory, then copy the whole thing over:
```bat
python -m pip download -r requirements.txt -d wheels
python -m pip download -r requirements-keys.txt -d wheels :: only needed for key mapping, ~130MB
```
Afterwards, when you click "Install dependencies" on the target machine, the program **automatically detects the
`wheels\` directory and switches to `--no-index --find-links wheels`**, installing fully offline.
**Route C: point at an existing interpreter** (least effort when the machine already has an environment with the
dependencies installed)
Put a `python.txt` in the program directory containing **just one line**: the full path to `pythonw.exe`.
The launcher uses it **first of all**, and if it can't be found it automatically falls through (`.venv` → `runtime` →
system Python), so getting this file wrong cannot break anything.
```
D:\somewhere\vibe-mote\python.txt
→ C:\Users\you\AppData\Local\Programs\Python\Python313\pythonw.exe
```
**Route D: just take the "offline full bundle"** (no Python to install at all, least effort)
`vibe-mote-offline-v1.zip` (~61MB) already bundles a Python runtime and all the dependencies:
```
On the other machine: unzip → double-click "启动遥控器.vbs" → use it
no Python to install, no network (key mapping still needs that one elevation — see README section 5.5)
```
**Route E: supply your own runtime** (when you already have a ready-made Python environment)
Extract the Python embeddable package as `runtime\` in the program directory, and the launch script will use it
first (lookup order: `python.txt` → `.venv\` → `runtime\` → common install locations → PATH).
Note that the embeddable package **has `site` disabled by default** and **has no pip**: for installed
packages to be importable you must enable `import site` in `python313._pth` and add a
`Lib\site-packages` line, with dependencies pre-installed from an external
`pip install --target runtime\Lib\site-packages`. Using the ready-made offline bundle saves you all of this.
> The honest truth about size: a single `frida` file, `_frida.pyd`, is **127MB**, and it cannot be trimmed down —
> key mapping (the D-pad/OK/volume buttons) depends entirely on it. So the "full-featured offline bundle" is about
> 200MB after extraction (61MB compressed). If you only want the voice button, about 15MB is enough, **without
> frida**.
---
## 6. How to diagnose problems
**Always click "Settings → Environment check" first** — it tells you item by item which layer is broken.
| Symptom | Most likely cause | What to do |
|---|---|---|
| **The frida-helper UAC prompt keeps appearing** | Frida needs administrator rights to register a temporary service for injection; repeated retries produce repeated prompts | "Settings → **One-click optimization (prompts for administrator once)**" fixes it in one go; see section 2.1 |
| **The log keeps printing `Insufficient Authentication` (or `missing ATVV characteristic`)** | The remote's link was never properly **paired/encrypted**: this BLE peripheral insists on an encrypted link | **Remove device** under Settings → Bluetooth & devices, hold 【**Back + Home**】 on the remote to pair again, then click Restart. **No** drivers, no reinstall |
| **Every input level reads 0 / no audio gets in** | The system default recording device is `CABLE Output` (nothing feeds CABLE Input, so every app on the default microphone hears pure silence) | Set it back to the real microphone under Settings → System → Sound → Input; click Settings → Diagnostics → **Microphone self-check** to verify instantly (it prints the default device and the measured peak) |
| **No sound when pressing the voice button right after boot** | At boot the audio service / virtual audio cable is not ready yet | **Wait 5–10 seconds** (the program retries every 5 seconds; the log will say "waiting for the virtual audio cable to be ready…" and then attach automatically) |
| The UI won't open | The client isn't running / the port is taken | Double-click `启动遥控器.vbs` and try again; the port is visible on the "Settings" page |
| The log is flooded with `missing ATVV characteristic` | **Another client is holding the remote's Bluetooth channel**, or this process has a leftover session | Close the other clients (including older versions), then click "Restart". The log prints the service table it enumerated |
| The log is flooded with `connection timeout` | The remote is asleep | **Press any button on the remote**; it connects automatically within 3 seconds |
| Pressing the voice button does nothing, but the log shows `▼ start` | The input method editor does not accept injected keystrokes | Qianwen: click "Turn on Qianwen 'allow injection'"; Doubao: not supported |
| Not even `▼ start` appears for the voice button | Bluetooth is not connected / another client is holding it | Look at the "Voice" channel on the status page; click "Restart" first |
| Chinese text appears even when nobody is speaking | The input method editor has the wrong microphone selected | Choose `CABLE Output (VB-Audio Virtual Cable)` |
| Recognition is choppy, sounds echoed, or there is a constant noise floor | The system default recording device is set to `CABLE Output` (the mic passthrough then feeds that line back) | Set the system default recording device back to your real microphone: Settings → System → Sound → Input. The virtual cable only needs to appear in the **input method editor's own** microphone setting |
| The D-pad/OK do nothing | frida not installed / attach failed | Look at the "Keys" line on the status page; click "Install key-mapping dependencies" |
| Pressing the volume buttons also changes the system volume | In theory it shouldn't (mapped keys have their native action erased at the driver layer); if it happens, that key is **not mapped** | Add it to the mapping table and it is blocked automatically |
| It didn't start automatically at boot | The autostart entry was not written successfully | Turn the switch off and on again on the "Settings" page; the log will report the result |
| **The remote's buttons work, but holding the voice button does nothing / the log keeps saying `connection timeout`** | A Bluetooth LE device accepts only **one** connection and Windows' HID stack sometimes never releases that single link, so the remote stops advertising and the program can never connect | "Settings → Diagnostics → **Reset Bluetooth radio**" (one admin prompt; Bluetooth devices drop briefly). If that fails, take the remote's battery out for 10 seconds |
---
## 7. What's in the directory
| File | Purpose |
|---|---|
| `启动遥控器.vbs` | **The only entry point** (pure ASCII + CRLF, deliberately without Chinese comments — see the note at the top of the file) |
| `app.py` | All the logic: UI server, configuration, managing the two roles, autostart, environment check, dependency installation |
| `i18n.py` | Bilingual support: the UI messages the backend produces (dependency notes / presets / cleanup list / environment check / action replies) |
| `voice.py` | Voice: ATVV voice button + remote microphone → virtual audio cable (pure Python audio, no numpy dependency) |
| `keys.py` | Keys: Frida reads the remote's HID reports out of band → synthesises keyboard/mouse |
| `tap.js` | The Frida script (used for key mapping) |
| `web/index.html` | The UI (single file, zero external resources, works offline) |
| `config.json` | Configuration (generated automatically on first run) |
| `profiles.py` | **The preset table**: application (WorkBuddy / Codex / Claude Code) × input method editor → the whole mapping set. Change key assignments here |
| `uninstall.py` | The cleanup / uninstall script (the UI's "Clean up" button calls it; it also runs standalone on the command line) |
| `elevate_setup.py` | One-off optimization (clear frida leftovers + install the highest-privilege autostart + restart the client with the new code) |
| `python.txt` | **Optional**: which `pythonw.exe` to use (one line, a path). Without it, one is found automatically |
| `.venv/` | **Generated automatically**: the in-package virtual environment created by "Install dependencies" (does not pollute the system Python) |
| `requirements.txt` / `requirements-keys.txt` | Base dependencies / key-mapping dependencies (frida) |
| `wheels/` | Drop offline wheel packages here and offline installation is used automatically (optional) |
| `runtime/` | Drop a Python embeddable runtime here (optional, for deployments that skip installing Python) |
---
## 8. Environment
Windows 10 / 11 x64 · Python 3.10 or newer · bleak 3.0.2 · sounddevice 0.5.6 · frida 17.18 ·
VB-CABLE 4.5 · Qianwen IME (optional).
Remote: a Google TV (Chromecast) Bluetooth remote.
## 9. Open source
MIT (see `LICENSE`). Personal use, study, modification and redistribution are all fine;
for commercial use (selling it, integrating it into a commercial product, or offering a commercial service with it),
please contact the author for authorization first.
Licenses of the third-party components in the packaged distribution are in `THIRD-PARTY-NOTICES.md`.
+404
View File
@@ -0,0 +1,404 @@
# 遥控器客户端(VibeMote)
> 🌐 **中文** | [English](说明.en.md)
把 **Google Chromecast / Google TV 蓝牙遥控器**变成 Windows 上的说话与快捷键设备。
| 遥控器按键 | 默认效果 |
|---|---|
| **语音键**(带话筒那个) | **按住说话** —— 遥控器麦克风 → 虚拟声卡 → 输入法出字 |
| 上 / 下 | 鼠标滚轮上 / 下(**按住不放连续滚**,轻点滚一格) |
| 左 / 右 | `Ctrl+Shift+[` / `Ctrl+Shift+]` |
| OK / 返回 / 主页 / 电源 / 输入源 / 静音 / YouTube / NETFLIX / 音量± | 见界面「按键映射」页,随便改 |
---
## 一、这个版本干净在哪
- ✅ **不装驱动**(没有虚拟 HID、没有内核过滤驱动)
- ✅ **语音部分不需要管理员权限**。按键映射(可选)要**一次**提权(Frida 注入蓝牙 HID 驱动宿主),点一次「一键优化」后由最高权限计划任务代劳、之后不再弹;另外就是装虚拟声卡那一次 UAC。细节见第五节第 5 小节
- ✅ **不弹黑框**:单进程 + 全程隐藏窗口启动,自启动也不会闪任何窗口
- ✅ **不开测试模式、不关 Secure Boot、不碰系统文件**
- ✅ **不联网**(除了你自己点「安装依赖」那一下)
三条设计上的硬规矩,都是为了上面这几条:
1. **只有一个进程**。语音和按键都在同一个进程的线程里,不 Popen 子进程 ——
所以架构上就不可能冒出控制台窗口。(唯一起子进程的地方是装依赖,那里显式隐藏窗口。)
2. **界面只监听 `127.0.0.1`**。安全,而且不会触发 Windows 防火墙弹窗
(监听 `0.0.0.0` 才会弹,而且会反复弹)。
3. **配置以后端 `config.json` 为准**,前端不缓存、不反推。
---
## 二、快速开始(三步)
> **首页顶部有一张「首次准备」清单**,把下面这几件事的状态和按钮都摆在一起了
> (运行依赖 / 虚拟声卡 / 千问注入 / 开机自启 / 一键优化),照着从上到下点一遍就行 ——
> 不用自己去「设置」页里翻。**虚拟声卡是必需项**:不装它,按遥控器语音键不会有任何反应
> (遥控器麦克风没地方出声,输入法读不到)。
| 步 | 做什么 |
|---|---|
| 1 | 装 **Python 3.10+**(python.org 官方安装包,**务必勾选 Add python.exe to PATH**) |
| 2 | 双击 **`启动遥控器.vbs`** —— 界面会自动在浏览器打开(`http://127.0.0.1:8787/`) |
| 3 | 界面首页清单上点 **「全部安装」**(约 140MB,联网一次)—— 语音依赖 + 按键映射依赖 + 虚拟声卡一次装完;只想先用语音键,就点「只装语音依赖」(约 10MB) |
然后:
| 还想做什么 | 在哪 |
|---|---|
| 配对遥控器 | Windows 设置 → 蓝牙和其他设备 → 添加设备 → 同时按住遥控器 **返回 + 主页** 进配对 |
| 装虚拟声卡(语音必需的前置) | 首页清单「虚拟声卡」那一行点**一键安装**,或「设置 → 虚拟声卡」那张卡。**首次启动时程序会自动帮你把它拉起来**(只自动试一次)。⚠ 会弹一次 UAC,还要在安装器窗口里点一下「Install Driver」,**装完必须重启一次**(VB-Audio 官方要求) |
| 输入法里选麦克风 | 选 **`CABLE Output (VB-Audio Virtual Cable)`**。⚠ 只改**输入法里**这一处,**系统默认录音设备保持你原来的真实麦克风** —— 默认设备若设成虚拟声卡,微信语音/录音机这些应用也会拿到遥控器这一线 |
| 千问输入法用户 | 首页清单那一行点**打开**(程序首次启动也会自动帮你打开);千问默认丢弃程序发的按键 |
| 开机自动跑 | 首页清单「开机自启」点**打开**(也可以去「设置 → 开机自启」)。**普通权限,不弹 UAC,开机后台静默启动** |
| **让 UAC 一次都不要再弹** | 首页清单「一键优化」点一下(弹一次管理员)—— 详见下面第二·补节 |
| **想彻底删干净 / 重装一遍** | 「设置 → 彻底卸载 / 清理」—— 详见第二·补二节 |
---
## 二·补、Frida 为什么会弹 UAC?怎么让它一次都不弹
**现象**:用着用着弹出「你要允许此应用对你的设备进行更改吗?」,标题附近能看到 `frida-helper`;
不管它或者点「否」,过一会儿又弹。
**原因**:按键映射靠 Frida 读蓝牙驱动里的报文。Frida 要注入一个不属于自己的进程时,
会**尝试注册一个临时服务**(叫 `frida-<PID>-x86_64`)来做这件事 —— 而注册系统服务
需要管理员,于是就有了 UAC。没批准或中途中断,这条服务就变成死残留。
反复重试 = 反复弹框 = 攒下一堆残留服务。
**两件事一起做才能根治**:
1. **程序侧**(已内置):挂载失败改成**指数退避**,连撞 3 次权限失败就**停 5 分钟**,
不再疯狂重试刷框。
2. **权限侧**:「设置 → **一键优化(弹一次管理员)**」。它会:
- 清掉所有 `frida-*` 残留服务和助手进程;
- 把开机自启从「普通权限的 Run 项」换成「**最高权限 + 登录触发**的计划任务」——
开机由任务计划程序以管理员身份静默启动,Frida 要的权限都是现成的,
**从此一次 UAC 都不会再弹**;
- 删掉原来的 Run 项,避免两份同时启动抢蓝牙通道。
> 只想聊天说话、不需要方向键/OK 映射的话,**干脆不装 frida**(不点「安装按键映射依赖」),
> 那就永远不会有这个弹窗,也不会有下面这些麻烦。
**用按键映射要提前知道的三件事**(详细清单见 README 第五节第 5 小节):
- **杀软可能误报/误隔离**:frida 的模块名在多家杀软的特征库里。离线包里它约 47MB —— 被静默隔离的话,
表现就是「装了依赖但方向键/OK 没反应」。把程序目录加进白名单,再点一次「安装按键映射依赖」即可。
**不需要**关 Defender、**不需要**开测试模式。
- **它靠的是未公开接口**:Windows 蓝牙 HID 驱动的一个未公开 IOCTL + 一个注册表路径,并锁定
`frida >=17.18,<18`。系统或 frida 更新后可能**静默失效** —— 失效时语音照常,按键映射会停用并在日志说明。
- **注入的宿主同时服务你其他蓝牙键鼠**:我们只改被映射那几个键的报文(把原生动作抹掉),
但万一这个宿主崩了,蓝牙键鼠会一起掉线。真遇到就重启客户端或拔插一下适配器。
- **受保护的游戏**:注入行为跟作弊器加载器同类,少数带内核反作弊的游戏可能起不来 —— 玩游戏前退出客户端最省事。
---
> **刚开机就能用**:开机那一刻 Windows 的音频服务 / 虚拟声卡常常比程序晚就绪。
> 本程序**不会因此罢工** —— 语音通道每 5 秒重试一次,声卡一就绪就自动接上,中途掉了也会重建。
> (只在启动那一刻找一次声卡的话,开机后按语音键会一直没声音。)
---
## 二·补二、清理 / 卸载(逐项勾选,想重装一遍时用)
「设置 → **清理 / 卸载(逐项勾选)**」。**默认什么都不勾、勾哪项删哪项**,没勾的一律不动。
可勾的 12 项(界面上就是这份,带 🔒 的会弹一次 UAC)。
**每一项后面还会标出「现在在不在/多大」** —— 比如轻包上「删除依赖(`.venv`)」会显示
「不存在」,不会让你对着不存在的项瞎勾:
| 项 | 说明 | 🔒 |
|---|---|---|
| 取消开机自启(计划任务) | 删除最高权限计划任务 `VibeMote` | ✓ |
| 取消开机自启(启动项) | 删除 HKCU Run 里的 `VibeMote` | |
| 结束客户端进程 | 语音桥接 + 按键映射进程 | |
| 结束 frida 助手进程 | `frida-helper-x86 / x86_64` | |
| 清理 frida 残留服务 | HKLM 里的 `frida-<pid>-x86*` | ✓ |
| 删除依赖(`.venv`) | 装过的 bleak / sounddevice / cffi / frida | |
| 删除 `__pycache__` | Python 字节码缓存 | |
| 删除 `python.txt` | 指定解释器的指向文件 | |
| 删除日志 | `elevate_setup.log` / `client.log` | |
| 删除 `config.json` | **你的按键映射会丢**,回到默认方案 | |
| 删除千问「允许注入」开关 | 删了千问会重新丢弃程序发的按键 | |
| 卸载虚拟声卡(VB-CABLE) | **系统级音频驱动**,见下面那段;默认不勾 | ✓ |
| 清除虚拟声卡残留(注册表 / 驱动包) | **装/卸卡住时的修复动作**,见下面那段;默认不勾 | ✓ |
三个辅助按钮:**全不选** / **选「推荐清理」**(上面 9 项,保留配置、千问开关和虚拟声卡)/ **全选**。
> ⚠ **「卸载虚拟声卡」是清单里唯一一个"点完还要人动手 + 重启"的项**,所以它
> **默认不勾、也不在「推荐清理」里**:
> - 它是**系统级音频驱动**,OBS / 变声器 / 直播工具可能也在用它,删了它们就没声了;
> - 官方**没有静默卸载**:Windows「应用和功能」里那条 `UninstallString` 就是安装器本身、
> 不带任何参数,人必须在它窗口里点一下「**Remove Driver**」;
> - 按 VB-Audio 官方 readme,**卸完必须重启一次**系统才算卸完。
>
> **「清除虚拟声卡残留」** 是给"装也装不上、卸也卸不干净"准备的:注册表里显示装了、
> 设备却枚举不出来,在官方安装器里点 Install Driver 也没反应 —— 那就是残留把新安装挡住了。
> 它只删 VB-CABLE 自己的那几处(两个服务键、`HKLM\SOFTWARE\VB-Audio\Cable`、
> 「应用和功能」里那条、DriverStore 里的驱动包、`C:\Program Files\VB\CABLE`),
> **同属 VB-Audio 的 Voicemeeter 的键和目录一个都不碰**;清完**重启一次**再重装。
> 首页出现「虚拟声卡现在用不了」那个黄色横幅时,右边的按钮就是它。
>
> 勾了它、点开始清理之后:程序会弹一次 UAC 并**把官方卸载器拉起来**,
> 剩下的两下(点 Remove Driver、重启)得你自己来。日志里会逐条说明。
> 程序**不会**去核实"卸载成功没有" —— 你还没点完就报"没删掉"是误报。
> ⚠ **清单里故意没有「删除内置 Python 运行时(`runtime\`)」这一项。**
> 它是离线包自带的 Python(解压后 154MB),属于**程序本体**,不是"装出来的依赖";
> 而界面上的「全部安装」装的是 pip 包,**装不回**一个 Python 发行版 ——
> 一旦删掉,这台机器就再也不是"免安装"的了,而且没法一键恢复。
> 所以清理工具一直保留它。
>
> **想彻底卸载整个程序?两步,顺序别反:**
> 1. 在这里点「**选「推荐清理」**」→ 开始清理(清掉开机自启 + frida 残留服务 + 依赖);
> 2. 然后**直接删掉整个程序目录**。
>
> 程序是纯绿色部署,没有安装器、不写系统目录,所以第 2 步之后系统里不会有任何残留。
**执行顺序是固定的**(不按勾选顺序,顺序错了会失败):
```
先断自启 → 再杀进程 → 再删服务/文件 → 最后结束自己
```
尤其是「先杀进程再删 `.venv`」—— Windows 上**正在运行的 `pythonw.exe` 是被锁住的**,
顺序反了就报「删不掉」。极端情况下(机器上只有包内这一个解释器)程序会自动安排一个后台命令,
等本进程退出后接着删(日志里会写「已安排删除 .venv…约 2 秒」),你不需要做任何事。
**只想重装依赖** → 点「选「推荐清理」」即可,`config.json` 会留着。
**想完全回到"没装过"** → 再手动勾上 `config.json` 和千问开关(或直接「全选」)。
命令行等价写法:
```bat
python uninstall.py --items=venv,pycache :: 只删这两项
python uninstall.py --items=all :: 全删(含 config.json、千问开关和虚拟声卡;但**不会**动 runtime\)
python uninstall.py --items=task,venv --dry-run :: 只列清单,什么都不做
python uninstall.py --dry-run :: 一个都不勾 = 什么都不做
```
---
## 二·补三、依赖下载走哪个源?(国内没 VPN 也能装)
「安装依赖」**自动轮询国内镜像**,一个失败就换下一个,最后才试官方 PyPI。
每次会打印用的是哪个源,所以出问题你能一眼看到。
**这张表按可用性排的**(每个都验证过能下,不是照抄网上推荐):
| 顺序 | 源 | 结果 | 备注 |
|---|---|---|---|
| 1 | 腾讯云 | ✓ 1.1s | 最快 |
| 2 | 中科大 USTC | ✓ 1.0s | frida 索引稍慢(8.6s) |
| 3 | 阿里云 | ✓ 1.4s | |
| 4 | 华为云 | ✓ 1.1s | |
| 5 | 清华 TUNA | ✗ 返回空索引 | 已降级到最后 |
| 6 | 官方 PyPI | ✓ 2.5s | 兜底(有代理/国外网络时最稳) |
> **没收录网易 163**:它的 PyPI 镜像早就停止同步了(bleak 只到 0.5.0),
> 收录它反而会装出老版本、报出莫名其妙的错。也别用网上那种"一次列 8 个源"的配置。
**全失败怎么办**(程序会把这三条打在日志里):
1. 换个网络(手机热点)再点一次
2. 手动改源:编辑 `app.py` 顶部的 `PIP_MIRRORS` 表(一行一个源)
3. **纯离线**:见下一节的 `wheels\` 目录 —— 程序检测到它**会优先使用,完全不联网**
---
## 三、界面怎么用
左侧五个页面:
- **状态**:运行状态、遥控器地址、语音/按键通道是否就绪、依赖缺什么、启动/停止/重启
- **按键映射**:**顶部先选「预设方案」**(见第三节·补),下面按遥控器实物排布画的图。
点一个键 → 右边选**映射类型** → 选目标键 → 保存
- 映射类型:
| 类型 | 行为 |
|---|---|
| `单键` / `组合键` | 按一下就走一次(按下立刻抬起) |
| `按住不放` / `按住组合键` | 遥控器键按住期间保持按下,松开才抬起(输入法「按住说话」就靠它) |
| `鼠标` | 按一下发一次鼠标动作 |
| **`按住鼠标`** | 鼠标**按住不放**:滚轮=**按住一直滚**(轻点滚一格)、左/中/右键=按住不放(拖拽用),松开才抬 |
| `文本` | 按一下输入一段文字 |
- 上和下的默认就是 **`按住鼠标` + 滚轮** —— 轻点滚一格、按住一直滚,不用自己配
- 不知道物理键对应界面上哪个?打开 **「按下遥控器键来定位」**,按一下遥控器,界面自动跳过去
- 填不出目标?点 **「按键捕获」**:按一下物理键盘上的键,**或者滚一下滚轮、点一下鼠标键** ——
抓到鼠标动作时会按当前"按一下 / 按住"自动选好类型(在「按住不放」状态下滚轮,直接得到"按住连续滚")
- 已映射的键会在遥控器按键下面直接显示它映射成了什么;按下去界面有动画和信号灯
- **语音**:语音键目标(按住 / 按一下 + 修饰键 + 主键)、输入法预设、测试发键
- **设置**:蓝牙地址 + 扫描、音频增益 / AGC / 内置麦克风直通、开机自启、环境体检、装依赖
- **日志**:实时日志,出问题先看这里
改完映射**立即生效**,不用重启。
---
## 三·补、预设方案:选「应用」+ 选「输入法」,一键配好整套
「按键映射」页顶部有两条下拉,选完点「应用这套预设」,14 个按键 + 语音键一次配好。
下面会**先把整套映射预览出来**再让你决定,不会偷偷改。
### 应用(决定 14 个按键怎么映射)
| 预设 | 依据 |
|---|---|
| **WorkBuddy** | **它自己的「设置 → 快捷键」页**(最准):`Ctrl+[`/`Ctrl+]` 上一个/下一个任务、`Ctrl+N` 新建对话、`Ctrl+B` 左侧栏、`Ctrl+Shift+B` 右侧产物面板、`Ctrl+F` 对话内搜索、`Ctrl+,` 打开设置、`F11` 全屏、`Shift+Alt+W` 唤起主窗口 |
| **Codex / ChatGPT 客户端** | **OpenAI 官方 Commands 参考页(Windows)**:`Ctrl+Shift+[`/`]` 上一个/下一个聊天、`Ctrl+Alt+A` 下一个需要关注的聊天、`Ctrl+N` 新建聊天、`Ctrl+B` 侧边栏、`Ctrl+J` 底部面板、``Ctrl+` `` 终端、`Ctrl+P` 搜索文件、`Ctrl+=`/`Ctrl+-` 字号、`F11` 全屏 |
| **Claude Code** | 按 Claude Code 命令行的习惯:`↑`/`↓`/`←`/`→` 普通方向键、`Enter` 提交、`Backspace` 删字、`Esc` 取消、`Shift+Tab` 反向切换(**静音键也是 `Enter`**,跟 OK 一样 —— 有意重复);三个"打字"键 —— `/clear` 清上下文、`commit + push` 让它提交并推送、`/exit` 退出(**打完字自己按 OK 回车**);侧边 ± 仍是按住连续滚 |
| **通用** | 只用各程序都常见的键(`Ctrl+PageUp/PageDown` 切标签、`Ctrl+T` 新标签、`Ctrl+L` 地址栏……) |
### ★ 审批的 Yes / No
YouTube / NETFLIX 两个键固定映射成**审批的同意 / 拒绝**:
- **ChatGPT / Codex 官方定义:核准请求 = `Enter`,拒绝请求 = `Esc`**
- 所以这两个键就是 `Enter` / `Esc`(和 OK / 返回 同键,等于多了两个"随手同意/拒绝"的大按钮)
> 如果你用的程序审批框认的是别的键(比如 `y` / `n` 或 `1` / `2`),
> 改 `profiles.py` 里的 `_approve_yes()` / `_approve_no()` 两个函数即可,一处生效。
### 输入法(决定语音键发什么)
| 输入法 | 触发键 | 形态 | 状态 |
|---|---|---|---|
| 千问输入法 | 右 Alt | 按住 | ✅ 可用(它自己的配置里写着 `windows.vk.165`) |
| 搜狗语音输入 | 右 Ctrl | 按住 | ✅ 可用(搜狗语音助手日志里写着长按键是 RightCtrl) |
| 微信输入法 | Ctrl + Win | 按住 | ⚠️ 微信不提供静默改键接口,改完要在它的设置里核对 |
| Windows 语音输入 | Win + H | **按一下** | ✅ 系统固定,且是开关式不是按住 |
| 豆包输入法 | 右 Alt | 按住 | ❌ 不行:豆包会拦程序注入的按键 |
| 自定义 | —— | —— | 保持你当前在「语音」页的设置 |
---
## 四、语音键要换成别的输入法
遥控器的语音键发什么键,取决于你用的输入法。**最快的做法是上面三·补里的预设下拉**
(选输入法即填好);也可以手动在界面「语音」页设:
| 输入法 | 它的「按住说话」快捷键 | 状态 |
|---|---|---|
| **千问输入法** | 按住 **右 Alt** | ✅ 已验证(需要开那个「允许注入」开关) |
| **搜狗语音输入** | 按住 **右 Ctrl** | 照预设填;若它设置里是别的键,以它显示的为准 |
| **微信输入法** | 按住 **Ctrl + Win** | 预设已填;微信不提供静默改键,需在它自己设置里核对一致 |
| **Windows 语音输入** | 按一下 **Win + H** | 系统自带;**模式要选「按一下」** |
| **豆包输入法** | 按住 **右 Alt** | ⚠️ 已知不行:豆包会拦程序注入的按键 |
| 自定义 | 你自己定 | 给别的语音软件留的口子 |
---
## 五、拷到别的电脑 / U 盘部署
整个 `vibe-mote` 文件夹拷过去即可,**包体不到 1MB**(依赖装在目标机上)。
**方式 A:联网装依赖**(默认)
目标机双击 `启动遥控器.vbs` → 界面点「安装基础依赖」。
**方式 B:纯离线**(推荐给没网的机器)
在一台**有网且系统相同**的机器上把依赖下载成 wheel 包,塞进 `wheels\` 目录,再整个拷过去:
```bat
python -m pip download -r requirements.txt -d wheels
python -m pip download -r requirements-keys.txt -d wheels :: 需要按键映射才下,约 130MB
```
之后目标机点「安装依赖」时,程序**自动检测到 `wheels\` 目录就会改走 `--no-index --find-links wheels`**,
全程离线安装。
**方式 C:指定一个现成的解释器**(机器上已有装了依赖的环境时最省事)
在程序目录放一个 `python.txt`,里面**只写一行**:`pythonw.exe` 的完整路径。
启动器会**最先**用它,找不到就自动往下走(`.venv` → `runtime` → 系统 Python),所以这个文件放错了也不会出事。
```
D:\某处\vibe-mote\python.txt
→ C:\Users\你\AppData\Local\Programs\Python\Python313\pythonw.exe
```
**方式 D:直接拿「离线全量包」(连 Python 都不装,最省事)**
`vibe-mote-offline-v1.zip`(约 61MB)里已经带好了 Python 运行时和全部依赖:
```
对方机器上:解压 → 双击「启动遥控器.vbs」→ 直接用
不需要装 Python、不需要联网(按键映射仍要那一次提权,见 README 第五节第 5 小节)
```
**方式 E:自己放一个 runtime**(已经有一份现成的 Python 环境时)
把 Python 的 embeddable 包解压成 `runtime\` 放在程序目录里,启动脚本会优先用它
(查找顺序:`python.txt` → `.venv\` → `runtime\` → 常见安装位置 → PATH)。
注意 embeddable 包**默认关掉 `site`**、而且**没有 pip**:要让依赖能被 import 到,得打开
`python313._pth` 里的 `import site` 并加上一行 `Lib\site-packages`,依赖用外部
`pip install --target runtime\Lib\site-packages` 预装进去。直接用现成的离线全量包就不用管这些。
> 关于体积的实话:`frida` 的 `_frida.pyd` 一个文件就 **127MB**,且不可裁剪 ——
> 按键映射(方向键/OK/音量这些)只能靠它。所以「全功能离线包」解压后约 200MB
> (压缩后 61MB)。只想要语音键的话,约 15MB 就够,**不用 frida**。
---
## 六、出问题怎么查
**永远先点「设置 → 环境体检」**,它会逐项告诉你哪一层断了。
| 现象 | 多半是 | 怎么办 |
|---|---|---|
| **一直弹 frida-helper 的 UAC** | Frida 为注入注册临时服务要管理员;反复重试就反复弹 | 「设置 → **一键优化(弹一次管理员)**」,一次解决;详见第二·补节 |
| **蓝牙设备全没了、设置里没有蓝牙开关** | 蓝牙电台被禁用了(系统蓝牙开关 = 禁用该 USB 电台;关了没开回来) | 设备管理器 → 蓝牙 → 右键适配器 → 「启用设备」;不行就**完全关机**再开机;或用新版客户端「重置蓝牙电台」(它会先启用回来,只启用不再关开) |
| **客户端不知什么时候就没了** | 计划任务只在登录时跑一次,进程异常退出后没人拉起 | 点一次「设置 → **一键优化**」;新版任务带**每 5 分钟自检**,挂了会自动回来。启动时日志若写「上次运行没有正常退出」,那就是它上次是崩溃/被强杀/休眠关机没走正常流程 |
| **按键都能用、只有语音键不行** | BLE 只允许一条连接,系统 HID 攥着它,程序建不起语音通道(日志「没扫到广播」) | 程序会**自动解卡**(自己把蓝牙关开一次,不需要管理员,10 分钟最多一次);手动备选:按一下遥控器键 → 设置里关开一次蓝牙(记得开回来)→ 遥控器拔电池 10 秒 |
| **日志刷 `Insufficient Authentication`(或一直「缺少 ATVV 特征」)** | 遥控器的链路**配对/加密没完成**:BLE 外设要求一条已加密的连接 | 「设置 → 蓝牙和其他设备」把遥控器**删除设备** → 按住遥控器【**返回 + 主页**】重新配对 → 回程序点「重启」。**不用**动驱动、不用重装 |
| **所有输入电平都是 0、声音进不去** | 系统默认录音设备被设成了 `CABLE Output`(没人往 CABLE Input 灌声音 → 所有用"默认麦克风"的程序都是纯静音) | 「设置 → 系统 → 声音 → 输入」改回真实麦克风;点「设置 → 诊断 → **麦克风自检**」可以立刻验证(会打印默认设备名 + 实测峰值) |
| **刚开机按语音键没声音** | 开机时音频服务/虚拟声卡还没就绪 | **等 5~10 秒**即可(程序每 5 秒重试,日志会写「等虚拟声卡就绪…」→ 之后自动接上) |
| **装虚拟声卡弹 `Unknown Error 1072`** | 那个服务卡在「**待删除**」状态(之前的清残留删了服务、没先停驱动)。它提示「请用管理员运行」是**误导** —— 你本来就是管理员 | **重启一次**,再点「一键安装虚拟声卡」;还不行就右键 `vbcable\VBCABLE_Setup_x64.exe` →「以管理员身份运行」→ 点 Install Driver |
| 界面打不开 | 客户端没在跑 / 端口被占 | 双击 `启动遥控器.vbs` 再试;端口在「设置」页能看到 |
| 日志刷 `缺少 ATVV 特征` | **有另一个客户端占着遥控器的蓝牙通道**,或者本进程会话残留 | 关掉其它客户端(含旧版),再点「重启」。日志会打印枚举到的服务表 |
| 日志刷 `连接超时` | 遥控器在休眠 | **按一下遥控器任意键**,3 秒内会自动连上 |
| 按语音键没反应,但日志有 `▼ 开始` | 输入法不认注入的按键 | 千问:点「打开千问『允许注入』」;豆包:不支持 |
| 语音键连 `▼ 开始` 都没有 | 蓝牙没连上 / 别的客户端占着 | 看状态页的「语音」通道;先「重启」 |
| 中文出字但没人说话也出字 | 输入法的麦克风选错了 | 选 `CABLE Output (VB-Audio Virtual Cable)` |
| 识别不连贯、像有回声、或一直有底噪 | 系统默认录音设备被设成了 `CABLE Output`(「内置麦克风直通」会跟着把这一路转回去) | 把系统默认录音设备改回真实麦克风:「设置 → 系统 → 声音 → 输入」。虚拟声卡只需要出现在**输入法自己的**麦克风设置里 |
| 方向键/OK 没反应 | 没装 frida / 挂载失败 | 状态页看「按键」那行;点「安装按键映射依赖」 |
| 音量键按了会同时调系统音量 | 理论上不会(映射的键会在驱动层被抹掉原生动作);若发生说明该键**没映射** | 映射表里加上它就自动屏蔽了 |
| 开机没自动起 | 自启项没写成功 | 「设置」页把开关关掉再打开;日志会给结果 |
| **遥控器按键有用,但按住语音键没反应 / 日志一直刷「连接超时」** | 蓝牙 BLE 设备只接受**一个**连接,Windows 的 HID 有时会把那条唯一的连接攥住不放 → 遥控器从此不再广播,程序就永远连不上 | 「设置 → 诊断 → **重置蓝牙电台**」(弹一次管理员,期间蓝牙设备会短暂断开);不行就把遥控器**电池取出 10 秒**再装回 |
---
## 七、目录里都有什么
| 文件 | 作用 |
|---|---|
| `启动遥控器.vbs` | **唯一入口**(纯 ASCII + CRLF,故意不写中文注释,见文件头说明) |
| `app.py` | 全部逻辑:界面服务、配置、两个角色的管理、自启、体检、装依赖 |
| `i18n.py` | 中英双语:后端产生的界面消息(依赖说明 / 预设方案 / 清理清单 / 体检 / 动作回复) |
| `voice.py` | 语音:ATVV 语音键 + 遥控器麦克风 → 虚拟声卡(纯 Python 音频,不依赖 numpy) |
| `keys.py` | 按键:Frida 旁路读遥控器 HID 报文 → 合成键盘/鼠标 |
| `tap.js` | Frida 脚本(按键映射用) |
| `web/index.html` | 界面(单文件,零外部资源,离线可用) |
| `config.json` | 配置(首次运行自动生成) |
| `profiles.py` | **预设方案表**:应用(WorkBuddy / Codex / Claude Code)× 输入法 → 整套映射。改键位就改这里 |
| `uninstall.py` | 清理 / 卸载脚本(界面「清理」按钮调它,也可以命令行单独跑) |
| `elevate_setup.py` | 一次性优化(清 frida 残留 + 装最高权限自启 + 用新代码重启客户端) |
| `python.txt` | **可选**:指定用哪个 `pythonw.exe`(一行路径)。没有就自动找 |
| `.venv/` | **自动生成**:「安装依赖」建的包内虚拟环境(不污染系统 Python) |
| `requirements.txt` / `requirements-keys.txt` | 基础依赖 / 按键映射依赖(frida) |
| `wheels/` | 放离线 wheel 包就会自动走离线安装(可选) |
| `runtime/` | 放 Python embeddable 运行时(可选,用于免装 Python 的部署) |
---
## 八、运行环境
Windows 10 / 11 x64 · Python 3.10 或更高 · bleak 3.0.2 · sounddevice 0.5.6 · frida 17.18 ·
VB-CABLE 4.5 · 千问输入法(可选)。
遥控器:Google TV(Chromecast)蓝牙遥控器。
## 九、开源
MIT(见 `LICENSE`)。个人使用、学习、修改、二次分发都没问题;
商用(对外销售、集成进商业产品、以它提供商业服务)请先联系作者获得授权。
打包分发的第三方组件许可见 `THIRD-PARTY-NOTICES.md`。