vibe-mote v1.1.0 — Google TV 遥控器 → Windows 语音键 + 快捷键面板
This commit is contained in:
commit
fb30e47969
23 files changed
+14440
No files matched your search
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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 虚拟声卡。
|
||||
@@ -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
@@ -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.
|
||||
@@ -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,但本项目**未实测**)。
|
||||
**风险款**:十几块钱的「适用谷歌」兼容款 —— 能用的不少,但批次差异大,按上面三步挑。
|
||||
@@ -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\` 目录。
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+3678
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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`。
|
||||
Reference in new issue
Block a user