3360 lines
169 KiB
Python
3360 lines
169 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""遥控器客户端 —— 单一入口(纯标准库,零第三方依赖也能起界面)。
|
||
|
||
设计上的三条硬规矩(都是为了「不弹黑框」和「好维护」):
|
||
|
||
1. **只有一个进程**。语音和按键都在本进程的线程里跑,不 Popen 任何子进程,
|
||
所以不可能冒出控制台窗口。唯一会起子进程的地方是「安装依赖」(pip),
|
||
那里显式加了 CREATE_NO_WINDOW。
|
||
2. **界面只监听 127.0.0.1**。既安全,又不会触发 Windows 防火墙弹窗
|
||
(监听 0.0.0.0 才会弹,而且弹了还会反复弹)。
|
||
3. **配置以后端 config.json 为准**。前端不许用 localStorage 缓存配置再推回来
|
||
—— 历史上这个 bug 把用户配置冲掉过。
|
||
|
||
启动方式:
|
||
双击「启动遥控器.vbs」(无黑框);或 python app.py [--silent] [--port N]
|
||
--silent 只起服务不开浏览器(开机自启用这个)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import queue
|
||
import select
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import webbrowser
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import urlparse, parse_qs
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# ★ 必须把自己的目录塞进 sys.path —— 离线全量包靠这行才能跑起来。
|
||
# 原因:官方 embeddable Python 带一个 `python313._pth`,一旦存在就进入**隔离模式**:
|
||
# sys.path 被换成 _pth 里列的几项(runtime\ + python313.zip + Lib\site-packages),
|
||
# **脚本所在目录不会自动加进去**(`_pth` 里的 `.` 指的是 runtime 目录本身,不是程序目录)。
|
||
# 后果:`import voice` / `import keys` 直接 ModuleNotFoundError → 进程当场退出;
|
||
# 而 pythonw 没有控制台,用户那端看到的就是"双击没反应",毫无线索。
|
||
# 放在这里(模块级、所有延迟 import 之前)就一劳永逸,系统 Python / .venv 下也不会有副作用。
|
||
if HERE not in sys.path:
|
||
sys.path.insert(0, HERE)
|
||
|
||
# 中英双语:界面自己管界面上的字,后端管"要显示在界面上的消息"(见 i18n.py)。
|
||
# 只依赖标准库,所以可以模块级导入 —— 不必像 voice/keys 那样延迟导入。
|
||
import i18n # noqa: E402 (必须在 sys.path 之后就位)
|
||
|
||
APP_NAME = "VibeMote"
|
||
APP_VERSION = "1.1.0" # 报 bug 时请带上这个版本号(界面「设置 → 界面」能看到)
|
||
WEB = os.path.join(HERE, "web")
|
||
CONFIG = os.path.join(HERE, "config.json")
|
||
REQUIREMENTS = os.path.join(HERE, "requirements.txt")
|
||
WHEELS = os.path.join(HERE, "wheels")
|
||
LOCKS = 49741 # 单实例锁(只在 127.0.0.1)
|
||
LOCK_PORT = LOCKS
|
||
VENV = os.path.join(HERE, ".venv")
|
||
VENV_PY = os.path.join(VENV, "Scripts", "python.exe")
|
||
AUTOSTART_NAME = "VibeMote"
|
||
AUTOSTART_VBS = os.path.join(HERE, "启动遥控器.vbs")
|
||
# 启动器靠它判断"界面真的起来了"(内容:端口 + pid)
|
||
READY = os.path.join(HERE, "_ready.txt")
|
||
# 「上次是不是正常退出」的记录:正常退出时删掉(见 main 的清理段)
|
||
RUNSTATE = os.path.join(HERE, "_run_state.txt")
|
||
|
||
DEFAULT_PORT = 8787
|
||
CREATE_NO_WINDOW = 0x08000000
|
||
|
||
RECOMMENDED = {
|
||
"ok": {"type": "key", "value": "ENTER", "mods": [], "text": ""},
|
||
"back": {"type": "key", "value": "ESC", "mods": [], "text": ""},
|
||
# 上/下 = 滚轮,而且是**按住不放连续滚**:轻点滚一格、按住一直滚。
|
||
# 出厂默认就是这个(用户要的行为),"按一下只滚一格"反而是不好用的那个。
|
||
"up": {"type": "holdmouse", "value": "wheel_up", "mods": [], "text": ""},
|
||
"down": {"type": "holdmouse", "value": "wheel_down", "mods": [], "text": ""},
|
||
"left": {"type": "combo", "value": "LBRACKET", "mods": ["CTRL", "SHIFT"], "text": ""},
|
||
"right": {"type": "combo", "value": "RBRACKET", "mods": ["CTRL", "SHIFT"], "text": ""},
|
||
"home": {"type": "combo", "value": "A", "mods": ["CTRL", "ALT"], "text": ""},
|
||
"power": {"type": "combo", "value": "N", "mods": ["CTRL"], "text": ""},
|
||
"input": {"type": "combo", "value": "P", "mods": ["CTRL"], "text": ""},
|
||
"youtube": {"type": "combo", "value": "BACKQUOTE", "mods": ["CTRL"], "text": ""},
|
||
"mute": {"type": "combo", "value": "J", "mods": ["CTRL"], "text": ""},
|
||
"netflix": {"type": "combo", "value": "R", "mods": ["CTRL"], "text": ""},
|
||
"volup": {"type": "combo", "value": "EQUAL", "mods": ["CTRL"], "text": ""},
|
||
"voldown": {"type": "combo", "value": "MINUS", "mods": ["CTRL"], "text": ""},
|
||
}
|
||
DEFAULT_CONFIG = {
|
||
"_comment": "遥控器客户端配置。以后端这份为准;界面改完点保存会写回这里。",
|
||
"remote_addr": "",
|
||
"ui_port": DEFAULT_PORT,
|
||
"profile": {"app": "workbuddy", "ime": "qwen"},
|
||
"voice": {"mode": "hold", "key": "RALT", "mods": []},
|
||
"audio": {"gain": 4.0, "agc": True, "relay": True},
|
||
"keys": RECOMMENDED,
|
||
}
|
||
TYPES = ("key", "hold", "combo", "holdcombo", "mouse", "holdmouse", "text")
|
||
|
||
REMOTE_LABELS = {
|
||
"power": "电源", "input": "输入源", "up": "上", "down": "下",
|
||
"left": "左", "right": "右", "ok": "OK", "back": "返回", "home": "主页",
|
||
"voice": "语音", "mute": "静音", "youtube": "YouTube", "netflix": "NETFLIX",
|
||
"volup": "音量+", "voldown": "音量-",
|
||
}
|
||
MOUSE_LABELS = {"wheel_up": "滚轮上", "wheel_down": "滚轮下",
|
||
"left": "鼠标左键", "right": "鼠标右键", "middle": "鼠标中键"}
|
||
|
||
|
||
def describe_mapping(m):
|
||
"""给日志用的人类可读写法。"""
|
||
t, v = (m or {}).get("type"), (m or {}).get("value")
|
||
if t == "mouse":
|
||
return MOUSE_LABELS.get(v, str(v))
|
||
if t == "holdmouse":
|
||
# 滚轮按住是"连续滚",这一点日志里要说清,否则用户会以为按住只滚一格
|
||
tail = "(按住·连续)" if v in ("wheel_up", "wheel_down") else "(按住)"
|
||
return MOUSE_LABELS.get(v, str(v)) + tail
|
||
if t == "text":
|
||
return f"文本「{str(m.get('text') or '')[:12]}」"
|
||
mods = "+".join(m.get("mods") or [])
|
||
name = (mods + "+" if mods else "") + str(v)
|
||
return name + ("(按住)" if t in ("hold", "holdcombo") else "")
|
||
|
||
|
||
# ============================================================ 控制台编码
|
||
def _utf8_console():
|
||
"""中文 Windows 的控制台是 GBK,打印设备名里的 ® 会直接把进程打死。"""
|
||
for s in (sys.stdout, sys.stderr):
|
||
try:
|
||
s.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ============================================================ 事件总线
|
||
class Bus:
|
||
"""日志环 + SSE 订阅者。所有角色都通过它往前推消息。
|
||
|
||
⚠ 为什么必须**同时写文件**:程序是用 `pythonw.exe` 启动的(为了不弹黑框),
|
||
而 pythonw 下 `sys.stdout` 是 `None` —— `print()` 不会报错,但**什么都不输出**。
|
||
于是"双击没反应"这类问题在用户手上完全没有线索:进程从哪一步退出、退出前报了什么,
|
||
全都拿不到。所以从第一行日志开始就落盘到 `client.log`,再加两个崩溃钩子兜底。
|
||
"""
|
||
LOG = os.path.join(HERE, "client.log")
|
||
LOG_MAX = 512 * 1024 # 超过就重开一次,别让它无限长大
|
||
MAX_SUBS = 8 # SSE 订阅者上限(界面正常只开 1~2 条)
|
||
|
||
def __init__(self, capacity=800):
|
||
self.lines = []
|
||
self.capacity = capacity
|
||
self.subs = []
|
||
self.lock = threading.Lock()
|
||
self.fh = None
|
||
self._log_tried = False
|
||
# ⚠ **故意不在这里开文件**。本对象是模块级的(BUS = Bus()),
|
||
# 要是构造时就落盘,那任何"只是 import 一下"的场景(状态探针、测试脚本)
|
||
# 都会在程序目录里凭空多出一个 client.log —— 一眼看去像程序自己跑过了。
|
||
# 改成**第一条日志到达时才开**;崩溃钩子也走 log(),所以 traceback 不会丢。
|
||
|
||
def _open_log(self):
|
||
self._log_tried = True
|
||
try:
|
||
if os.path.exists(self.LOG) and os.path.getsize(self.LOG) > self.LOG_MAX:
|
||
os.remove(self.LOG)
|
||
self.fh = open(self.LOG, "a", encoding="utf-8", buffering=1)
|
||
self.fh.write(f"\n===== 启动 {time.strftime('%Y-%m-%d %H:%M:%S')} "
|
||
f"pid={os.getpid()} {sys.executable} =====\n")
|
||
except Exception:
|
||
self.fh = None
|
||
|
||
def log(self, msg):
|
||
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||
with self.lock:
|
||
if not self._log_tried:
|
||
self._open_log()
|
||
self.lines.append(line)
|
||
if len(self.lines) > self.capacity:
|
||
del self.lines[:len(self.lines) - self.capacity]
|
||
if self.fh:
|
||
try:
|
||
self.fh.write(line + "\n")
|
||
except Exception:
|
||
self.fh = None
|
||
print(line, flush=True)
|
||
self.push({"ev": "log", "line": line})
|
||
|
||
def trace(self, where, exc=None):
|
||
"""把未捕获异常写进日志(崩溃钩子用)。"""
|
||
import traceback
|
||
head = f"!!未捕获异常({where}):"
|
||
if exc is None:
|
||
self.log(head)
|
||
self.log(traceback.format_exc())
|
||
else:
|
||
self.log(head + f"{type(exc).__name__}: {exc}")
|
||
try:
|
||
self.log("".join(traceback.format_exception(
|
||
type(exc), exc, exc.__traceback__)))
|
||
except Exception:
|
||
pass
|
||
|
||
def push(self, msg):
|
||
with self.lock:
|
||
subs = list(self.subs)
|
||
for q in subs:
|
||
try:
|
||
q.put_nowait(msg)
|
||
except Exception:
|
||
pass
|
||
|
||
def subscribe(self):
|
||
q = queue.Queue(maxsize=500)
|
||
with self.lock:
|
||
self.subs.append(q)
|
||
return q
|
||
|
||
def subscriber_count(self):
|
||
with self.lock:
|
||
return len(self.subs)
|
||
|
||
def unsubscribe(self, q):
|
||
with self.lock:
|
||
if q in self.subs:
|
||
self.subs.remove(q)
|
||
|
||
def tail(self, n=200):
|
||
with self.lock:
|
||
return self.lines[-n:]
|
||
|
||
|
||
BUS = Bus()
|
||
|
||
|
||
# ============================================================ 配置
|
||
def _merge_defaults(cfg):
|
||
out = dict(DEFAULT_CONFIG)
|
||
out.update(cfg or {})
|
||
prof = dict(DEFAULT_CONFIG["profile"])
|
||
prof.update(cfg.get("profile") or {})
|
||
out["profile"] = prof
|
||
voice = dict(DEFAULT_CONFIG["voice"])
|
||
voice.update(cfg.get("voice") or {})
|
||
out["voice"] = voice
|
||
audio = dict(DEFAULT_CONFIG["audio"])
|
||
audio.update(cfg.get("audio") or {})
|
||
out["audio"] = audio
|
||
keys_map = cfg.get("keys")
|
||
out["keys"] = dict(keys_map) if isinstance(keys_map, dict) else dict(RECOMMENDED)
|
||
return out
|
||
|
||
|
||
def load_config():
|
||
# ★ 加了重试:config.json 被另一个进程(**或者自己的保存动作**)正在写的那一刻,
|
||
# Windows 会直接抛 PermissionError(共享冲突)—— 原来一失败就"用默认值继续跑",
|
||
# 用户看到的是"我的设置莫名其妙变回默认了"(真机日志里出现过:
|
||
# `配置读取失败…PermissionError`,同一秒 config.json 正好被写入)。
|
||
# 重试几次基本都能读到;真失败才退回默认值,并且日志写清楚。
|
||
last = None
|
||
for attempt in range(6):
|
||
try:
|
||
# utf-8-sig:记事本另存为 "UTF-8" 会加 BOM,用 utf-8 读会 JSONDecodeError
|
||
# → 用户的配置被**静默重置成默认值**(还只留一行"配置读取失败")。吃 BOM 最省事。
|
||
with open(CONFIG, encoding="utf-8-sig") as f:
|
||
return _merge_defaults(json.load(f))
|
||
except FileNotFoundError:
|
||
return _merge_defaults({})
|
||
except Exception as e:
|
||
last = e
|
||
time.sleep(0.15 * (attempt + 1)) # 0.15→0.9 秒,够写入方收尾
|
||
if last is not None:
|
||
BUS.log(i18n.L(f"配置读取失败(重试 6 次仍失败),用默认值:"
|
||
f"{type(last).__name__}: {last}",
|
||
f"Failed to read the config (6 retries), using defaults: "
|
||
f"{type(last).__name__}: {last}"))
|
||
return _merge_defaults({})
|
||
|
||
|
||
def save_config(cfg):
|
||
clean = {"_comment": DEFAULT_CONFIG["_comment"]}
|
||
# ⚠ 语言必须在这里保住:save_config 只保留白名单字段,界面保存配置时不会带上 lang,
|
||
# 不写这一行的话用户每次点「保存」都会把语言重置掉(表现为"切成英文又自己变回中文")。
|
||
clean["lang"] = i18n.current()
|
||
clean["remote_addr"] = str(cfg.get("remote_addr") or DEFAULT_CONFIG["remote_addr"])
|
||
clean["ui_port"] = int(cfg.get("ui_port") or DEFAULT_PORT)
|
||
p = cfg.get("profile") or {}
|
||
clean["profile"] = {"app": str(p.get("app") or DEFAULT_CONFIG["profile"]["app"]),
|
||
"ime": str(p.get("ime") or DEFAULT_CONFIG["profile"]["ime"])}
|
||
v = cfg.get("voice") or {}
|
||
clean["voice"] = {"mode": "tap" if v.get("mode") == "tap" else "hold",
|
||
"key": str(v.get("key") or "RALT"),
|
||
"mods": [str(x).upper() for x in (v.get("mods") or [])]}
|
||
a = cfg.get("audio") or {}
|
||
clean["audio"] = {"gain": float(a.get("gain", 4.0)),
|
||
"agc": bool(a.get("agc", True)),
|
||
"relay": bool(a.get("relay", True))}
|
||
keys_map = {}
|
||
voice_alias = None
|
||
for k, m in (cfg.get("keys") or {}).items():
|
||
if k.startswith("_") or not isinstance(m, dict):
|
||
continue
|
||
if m.get("type") not in TYPES or not isinstance(m.get("value"), str):
|
||
continue
|
||
# 界面在「按键映射」页也能点「语音」键 —— 那份写法存成 keys.voice。
|
||
# 语音键其实不走 HID、由语音通道单独处理,所以这里把它归一到顶层 voice,
|
||
# 免得同一个设置有两个真相。
|
||
if k == "voice":
|
||
if m.get("type") in ("hold", "holdcombo", "key", "combo"):
|
||
voice_alias = {"mode": "hold" if m["type"].startswith("hold") else "tap",
|
||
"key": m["value"],
|
||
"mods": [str(x).upper() for x in (m.get("mods") or [])]}
|
||
continue
|
||
keys_map[k] = {"type": m["type"], "value": m["value"],
|
||
"mods": [str(x).upper() for x in (m.get("mods") or [])],
|
||
"text": str(m.get("text") or "")}
|
||
clean["keys"] = keys_map
|
||
if voice_alias:
|
||
clean["voice"] = voice_alias
|
||
# ★ 原子写:读的人不会读到半个文件。两处细节别改回去:
|
||
# · 临时文件名带 pid —— i18n.save_to_config() 也写 config.json,
|
||
# 原来两边共用 `config.json.tmp`,并发时会互相 replace 掉,
|
||
# 结果丢修改、还在程序目录里留下一个带完整配置的 .tmp;
|
||
# · 用 i18n.CONFIG_LOCK 串行化,保证「保存设置」和「切语言」不会互相覆盖。
|
||
with i18n.CONFIG_LOCK:
|
||
tmp = f"{CONFIG}.{os.getpid()}.tmp"
|
||
try:
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(clean, f, ensure_ascii=False, indent=2)
|
||
f.write("\n")
|
||
os.replace(tmp, CONFIG)
|
||
except OSError:
|
||
try:
|
||
os.remove(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
return clean
|
||
|
||
|
||
# ============================================================ 依赖 / 环境
|
||
DEPS = [("bleak", i18n.pair("语音键与遥控器麦克风", "voice button + remote microphone")),
|
||
("sounddevice", i18n.pair("音频输出", "audio output")),
|
||
("cffi", i18n.pair("sounddevice 依赖", "a sounddevice dependency"))]
|
||
KEY_DEPS = [("frida", i18n.pair("按键映射", "key mapping"))]
|
||
|
||
|
||
def _venv_has(mod):
|
||
"""包内 .venv 里有没有这个顶层模块。
|
||
|
||
⚠ 别用 dist-info 目录名去反推包名:
|
||
`sounddevice-0.5.6.dist-info` 里版本带点,rsplit("-",1) 得到的是
|
||
`sounddevice-0.5.6`,永远匹配不上 `sounddevice`;而 bleak/cffi 因为刚好
|
||
同时有同名目录才没露馅。结果就是**装成功了却报「缺 sounddevice」**,
|
||
用户看到的是"点了没反应"。
|
||
直接看真实可导入的形态(目录 / .py / .pyd)最可靠。
|
||
"""
|
||
if not venv_python():
|
||
return False
|
||
sp = os.path.join(VENV, "Lib", "site-packages")
|
||
try:
|
||
entries = os.listdir(sp)
|
||
except OSError:
|
||
return False
|
||
if mod in entries: # bleak/ frida/ cffi/
|
||
return True
|
||
for suf in (".py", ".pyd", ".so"): # sounddevice.py
|
||
if mod + suf in entries:
|
||
return True
|
||
return any(n.startswith(mod + ".") and n.endswith(".pyd") for n in entries)
|
||
|
||
|
||
def deps_state():
|
||
"""依赖是否齐全。
|
||
|
||
⚠ 要同时看**两个地方**,只看一个就会骗人:
|
||
· 当前进程的解释器(app 自己跑在哪)
|
||
· 包内 .venv(「安装依赖」装到哪)
|
||
因为 app 很可能跑在系统 Python 上而依赖装在 .venv 里:那种情况其实装成功了,
|
||
只看当前解释器就会一直显示"缺依赖"。这种状态标记 restart_needed,界面会提示重启。
|
||
"""
|
||
proc_missing = [m for m, _ in DEPS if importlib.util.find_spec(m) is None]
|
||
proc_missing_keys = [m for m, _ in KEY_DEPS if importlib.util.find_spec(m) is None]
|
||
missing = [i18n.L(f"{m}({i18n.pick(why)})", f"{m} ({i18n.pick(why)})")
|
||
for m, why in DEPS
|
||
if importlib.util.find_spec(m) is None and not _venv_has(m)]
|
||
missing_keys = [i18n.L(f"{m}({i18n.pick(why)})", f"{m} ({i18n.pick(why)})")
|
||
for m, why in KEY_DEPS
|
||
if importlib.util.find_spec(m) is None and not _venv_has(m)]
|
||
restart_needed = (bool(venv_python()) and bool(proc_missing) and not missing)
|
||
return {"ok": not missing, "missing": missing,
|
||
"keys_ok": not missing_keys, "missing_keys": missing_keys,
|
||
"in_venv": bool(venv_python()), "restart_needed": restart_needed,
|
||
# 离线全量包:依赖预装在自带的 runtime\ 里(界面上要说明,
|
||
# 否则用户会奇怪"为什么没有 .venv 也说依赖齐全")
|
||
"bundled": is_bundled_runtime()}
|
||
|
||
|
||
def _run_hidden(cmd, on_line, collect=None):
|
||
"""跑一个子进程并把输出逐行喂给前端。这是全程序唯一会起子进程的地方,
|
||
所以必须带 CREATE_NO_WINDOW —— 否则就会冒出黑框。
|
||
|
||
`collect`:可选的列表,用来留一份输出(给"失败时判因"用,比如认代理错误)。
|
||
只留最后 200 行,别让一个话痨进程把内存占了。
|
||
"""
|
||
p = subprocess.Popen(cmd, cwd=HERE, stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT, text=True,
|
||
encoding="utf-8", errors="replace",
|
||
creationflags=CREATE_NO_WINDOW)
|
||
for line in p.stdout:
|
||
s = line.rstrip()
|
||
on_line(s)
|
||
if collect is not None:
|
||
collect.append(s)
|
||
if len(collect) > 200:
|
||
del collect[:len(collect) - 200]
|
||
return p.wait()
|
||
|
||
|
||
def system_proxy():
|
||
"""读系统(WinINET)里配的代理,返回 "host:port" 或 ""。
|
||
|
||
★ 为什么要读它:**pip 在 Windows 上会走系统代理**。而"代理软件没开、但系统里还留着
|
||
127.0.0.1:7890"这种事极其常见(换网络、代理软件退出、重启之后都很容易这样)。
|
||
这时所有 PyPI 源都连不上,而 pip 报的是
|
||
ERROR: Could not find a version that satisfies the requirement bleak==3.0.2
|
||
(from versions: none)
|
||
—— 看着像"这个包不存在",用户根本想不到是代理:
|
||
五个国内镜像依次"没有这个包",其实是本机 7890 端口没人听。
|
||
所以这里主动读出来,失败时直接告诉用户。
|
||
"""
|
||
try:
|
||
import winreg
|
||
with winreg.OpenKey(
|
||
winreg.HKEY_CURRENT_USER,
|
||
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") as k:
|
||
if not winreg.QueryValueEx(k, "ProxyEnable")[0]:
|
||
return ""
|
||
return str(winreg.QueryValueEx(k, "ProxyServer")[0] or "")
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
def looks_like_proxy_failure(text):
|
||
"""pip 的输出看起来是不是"被代理挡住了"。
|
||
|
||
两种典型表现:
|
||
· 直接报代理连不上:`ProxyError` / `Cannot connect to proxy` / WinError 10061;
|
||
· 更阴的:源连不上时 pip 说的是 "from versions: none"(看着像包不存在)——
|
||
这种情况配合"系统里确实配着代理"一起判断,否则会误报。
|
||
"""
|
||
t = (text or "").lower()
|
||
if "proxyerror" in t or "cannot connect to proxy" in t or "10061" in t:
|
||
return True
|
||
return bool(system_proxy()) and "from versions: none" in t
|
||
|
||
|
||
def venv_python():
|
||
return VENV_PY if os.path.exists(VENV_PY) else None
|
||
|
||
|
||
def _pip_ok(py):
|
||
"""这个解释器能用 pip 吗?—— 半成品 .venv(建到一半没 pip)必须先识别出来,
|
||
否则启动器会优先选它,然后「装依赖」永远失败。"""
|
||
try:
|
||
p = subprocess.run([py, "-m", "pip", "--version"],
|
||
capture_output=True, timeout=30,
|
||
creationflags=CREATE_NO_WINDOW)
|
||
return p.returncode == 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _rmtree_manual(path):
|
||
"""手工删目录。别用 shutil.rmtree —— 会被某些宿主劫持成「移到回收站」,
|
||
在无桌面/沙箱环境下直接抛 safe-delete 错误。"""
|
||
for root, dirs, files in os.walk(path, topdown=False):
|
||
for f in files:
|
||
try:
|
||
os.unlink(os.path.join(root, f))
|
||
except OSError:
|
||
pass
|
||
for d in dirs:
|
||
try:
|
||
os.rmdir(os.path.join(root, d))
|
||
except OSError:
|
||
pass
|
||
try:
|
||
os.rmdir(path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def ensure_venv(on_line):
|
||
"""确保有一个可用的包内 .venv,返回其中的 python;做不到就返回 None。
|
||
|
||
为什么优先 .venv:装在 Program Files 里的 Python 往 site-packages 写东西
|
||
需要管理员 —— 「装依赖」这个动作绝不该弹 UAC。包内 .venv 还能随目录一起拷。
|
||
"""
|
||
py = venv_python()
|
||
if py:
|
||
if _pip_ok(py):
|
||
on_line(i18n.L(f"使用包内虚拟环境:{py}",
|
||
f"Using the bundled virtualenv: {py}"))
|
||
return py
|
||
on_line(i18n.L("包内 .venv 不完整(没有 pip),删掉重建…",
|
||
"The bundled .venv is incomplete (no pip); deleting and rebuilding…"))
|
||
_rmtree_manual(VENV)
|
||
|
||
on_line(i18n.L("创建包内虚拟环境 .venv(不污染系统 Python,也不需要管理员)…",
|
||
"Creating the bundled virtualenv .venv (does not touch the system "
|
||
"Python and needs no administrator rights)…"))
|
||
rc = _run_hidden([sys.executable, "-m", "venv", VENV], on_line)
|
||
py = venv_python()
|
||
if rc != 0 or not py or not _pip_ok(py):
|
||
on_line(i18n.L("✗ 建 .venv 没成功,改用当前解释器 + --user 安装",
|
||
"✗ Failed to create .venv; falling back to the current interpreter "
|
||
"with --user"))
|
||
if py:
|
||
_rmtree_manual(VENV) # 别留半成品害下一次启动
|
||
return None
|
||
return py
|
||
|
||
|
||
# 下载源:按顺序尝试,**上一个失败自动换下一个**。
|
||
#
|
||
# 为什么必须这样:默认走官方 pypi.org,国内很多人没 VPN —— 慢、断流、超时。
|
||
# 而且报错只有 "Read timed out",用户完全不知道怎么办。
|
||
#
|
||
# ⚠ 这张表按 bleak==3.0.2 的实际可下载情况排序:
|
||
# 腾讯云 ✓ 1.1s frida 索引 0.9s ← 最快
|
||
# 中科大 ✓ 1.0s frida 索引 8.6s
|
||
# 阿里云 ✓ 1.4s frida 索引 6.2s
|
||
# 华为云 ✓ 1.1s
|
||
# 官方 PyPI ✓ 2.5s frida 索引 2.4s ← 国外/有代理时最稳,放兜底
|
||
# 清华 TUNA ✗ 返回空索引(from versions: none)→ 降级到最后
|
||
# 网易 163 ✗ 索引停更(bleak 只到 0.5.0)→ **不收录**,会装出老版本
|
||
#
|
||
# 想加/换源:直接改这张表(显示名, index-url);空字符串 = 用 pip 默认(官方)。
|
||
PIP_MIRRORS = [
|
||
("腾讯云", "https://mirrors.cloud.tencent.com/pypi/simple"),
|
||
("中科大 USTC", "https://mirrors.ustc.edu.cn/pypi/simple"),
|
||
("阿里云", "https://mirrors.aliyun.com/pypi/simple"),
|
||
("华为云", "https://mirrors.huaweicloud.com/repository/pypi/simple"),
|
||
("清华 TUNA", "https://pypi.tuna.tsinghua.edu.cn/simple"),
|
||
("官方 PyPI", ""),
|
||
]
|
||
|
||
|
||
def _pip_sources():
|
||
"""按尝试顺序返回 [(显示名, 额外参数)]。包内 wheels 目录排第一(纯离线)。"""
|
||
src = []
|
||
if os.path.isdir(WHEELS) and any(os.listdir(WHEELS)):
|
||
src.append(("包内 wheels(纯离线)", ["--no-index", "--find-links", WHEELS]))
|
||
for name, url in PIP_MIRRORS:
|
||
src.append((name, ["-i", url] if url else []))
|
||
return src
|
||
|
||
|
||
def run_pip(args, on_line, python=None, user=False):
|
||
"""装依赖:多源轮着试,第一个成功就停。"""
|
||
py = python or venv_python() or sys.executable
|
||
last = 1
|
||
told_proxy = False
|
||
sources = _pip_sources()
|
||
for i, (name, extra) in enumerate(sources, 1):
|
||
base = [py, "-m", "pip", "install", "--disable-pip-version-check",
|
||
"--timeout", "30", "--retries", "2"]
|
||
if user:
|
||
base.append("--user")
|
||
cmd = base + extra + args
|
||
on_line(i18n.L(f"── 下载源 {i}/{len(sources)}:{name}",
|
||
f"── Download source {i}/{len(sources)}: {name}"))
|
||
on_line("$ " + " ".join(cmd))
|
||
buf = []
|
||
rc = _run_hidden(cmd, on_line, collect=buf)
|
||
if rc == 0:
|
||
on_line(i18n.L(f"✓ 用「{name}」装好了", f"✓ Installed using “{name}”"))
|
||
return 0
|
||
last = rc
|
||
# ★ 认出"其实是系统代理挡住了"。不认的话用户看到的是
|
||
# "Could not find a version … (from versions: none)" —— 看着像包不存在,
|
||
# 完全想不到是本机的代理端口没人听。
|
||
if not told_proxy and looks_like_proxy_failure("\n".join(buf)):
|
||
told_proxy = True
|
||
ps = system_proxy()
|
||
on_line(i18n.L(
|
||
f"⚠ 看起来不是「源里没有这个包」,而是**系统代理连不上**:"
|
||
f"你系统里配着代理 {ps or '(已开启但没写地址)'},但那个代理软件现在没在跑。",
|
||
f"⚠ This does not look like “the source lacks this package” but like a "
|
||
f"**dead system proxy**: Windows has a proxy configured "
|
||
f"({ps or 'enabled, no address'}) but the proxy app is not running."))
|
||
on_line(i18n.L(
|
||
" 两个办法(任选一个再点一次):① 把代理软件打开;"
|
||
"② 关掉系统代理:Windows 设置 → 网络和 Internet → 代理 → 关掉「使用代理服务器」。",
|
||
" Two options (pick one and click again): (1) start your proxy app; "
|
||
"(2) turn the system proxy off: Windows Settings → Network & Internet → "
|
||
"Proxy → disable “Use a proxy server”."))
|
||
if i < len(sources):
|
||
on_line(i18n.L(f"✗「{name}」这次没成(返回码 {rc}),自动换下一个源…",
|
||
f"✗ “{name}” did not work this time (exit code {rc}); "
|
||
f"trying the next source…"))
|
||
on_line(i18n.L("✗ 所有下载源都失败了。三个办法:",
|
||
"✗ Every download source failed. Three options:"))
|
||
on_line(i18n.L(" 1) 换个网络(手机热点)再点一次;",
|
||
" 1) Switch networks (phone hotspot) and click again;"))
|
||
on_line(i18n.L(" 2) 手动指定源:在「设置 → 诊断」里看日志里最后一个失败的源名;",
|
||
" 2) Pick a source manually: the Logs page shows the last source "
|
||
"that failed;"))
|
||
on_line(i18n.L(" 3) 走纯离线:把 wheels 目录(见 说明.md 第五节)放到程序目录,"
|
||
"程序会自动优先用它,完全不联网。",
|
||
" 3) Go fully offline: put the wheels directory (see section 5 of "
|
||
"the README) next to the program — it is then preferred automatically, "
|
||
"with no network access at all."))
|
||
if system_proxy() and not told_proxy:
|
||
# 全失败 + 系统里配着代理 → 最可能就是这个(pip 的报错不会明说是代理)
|
||
on_line(i18n.L(
|
||
f"⚠ 另外注意:你系统里配着代理 {system_proxy()}。如果那个代理软件没在跑,"
|
||
f"上面的「这个包不存在」其实全是它造成的 —— 打开代理软件,或关掉系统代理再试。",
|
||
f"⚠ Also note: Windows has a proxy configured ({system_proxy()}). If that "
|
||
f"proxy app is not running, all of the “package does not exist” errors above "
|
||
f"are caused by it — start the proxy app, or turn the system proxy off and "
|
||
f"try again."))
|
||
return last
|
||
|
||
|
||
def is_bundled_runtime():
|
||
"""本程序是不是跑在**自带的 Python 运行时**上(离线全量包 / U 盘版)。
|
||
|
||
为什么必须认出来:官方 embeddable 包**没有 pip、也建不了 venv**(缺 ensurepip)。
|
||
不认的话,界面上的「安装依赖」会一路走到 `python -m venv` + `pip install`,
|
||
最后报一堆看不懂的错 —— 而这个包里依赖本来就是预装好的,根本不该走到那一步。
|
||
"""
|
||
try:
|
||
exe = os.path.normcase(os.path.abspath(sys.executable or ""))
|
||
rt = os.path.normcase(os.path.join(HERE, "runtime")) + os.sep
|
||
return exe.startswith(rt)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _install(on_line, req):
|
||
path = os.path.join(HERE, req)
|
||
if not os.path.exists(path):
|
||
on_line(i18n.L(f"找不到 {req}", f"{req} not found"))
|
||
return 1
|
||
if is_bundled_runtime():
|
||
# 到这儿说明内置运行时里确实缺东西(正常情况不会)。嵌入式包没法就地 pip 安装,
|
||
# 所以别硬试,直接给一条用户能走通的路。
|
||
on_line(i18n.L("这是「离线全量包」:依赖内置在 runtime\\ 里,不能再往里面装。",
|
||
"This is the offline full bundle: the dependencies are built into "
|
||
"runtime\\ and nothing can be added to it."))
|
||
on_line(i18n.L("修法:换用带联网安装的轻包(vibe-mote-v1.zip),"
|
||
"或者重新解压一份离线包(可能被误删了文件)。",
|
||
"Fix: switch to the light package with online installation "
|
||
"(vibe-mote-v1.zip), or unpack the offline bundle again "
|
||
"(files may have been deleted by accident)."))
|
||
return 1
|
||
py = ensure_venv(on_line)
|
||
if py:
|
||
return run_pip(["-r", path], on_line, python=py)
|
||
# 兜底:装到当前解释器的用户目录(%APPDATA%\Python),同样不需要管理员
|
||
return run_pip(["-r", path], on_line, python=sys.executable, user=True)
|
||
|
||
|
||
def install_deps(on_line):
|
||
if deps_state()["ok"]:
|
||
on_line(i18n.L("基础依赖已经齐全,不用重装 ✓",
|
||
"Base dependencies are all present; no reinstall needed ✓"))
|
||
return 0
|
||
rc = _install(on_line, "requirements.txt")
|
||
if rc == 0:
|
||
on_line(i18n.L("✓ 基础依赖装好了", "✓ Base dependencies installed"))
|
||
if venv_python():
|
||
on_line(i18n.L("→ 请关掉客户端,再双击「启动遥控器.vbs」"
|
||
"(启动器优先用包内 .venv,装好的依赖才生效)",
|
||
"→ Close the client, then double-click 启动遥控器.vbs "
|
||
"(the launcher prefers the bundled .venv, where the "
|
||
"dependencies went)"))
|
||
else:
|
||
on_line(i18n.L("→ 重启客户端后生效",
|
||
"→ Takes effect after restarting the client"))
|
||
return rc
|
||
|
||
|
||
def install_keys_deps(on_line):
|
||
"""frida 约 130MB,单独一个动作 —— 要用按键映射时才装。"""
|
||
if deps_state()["keys_ok"]:
|
||
on_line(i18n.L("按键映射依赖(frida)已经齐全,不用重装 ✓",
|
||
"The key-mapping dependency (frida) is present; no reinstall needed ✓"))
|
||
return 0
|
||
rc = _install(on_line, "requirements-keys.txt")
|
||
if rc == 0:
|
||
on_line(i18n.L("✓ 按键映射依赖装好了(重启客户端后生效)",
|
||
"✓ Key-mapping dependencies installed (takes effect after "
|
||
"restarting the client)"))
|
||
return rc
|
||
|
||
|
||
def find_cable_names():
|
||
"""虚拟声卡端点的名字(「环境体检」和「虚拟声卡」那张卡靠它判断能不能用)。
|
||
|
||
★ 必须跟 voice.find_cable() 用**同一套**判定。以前这里自己写了一遍
|
||
`"cable input" in name`,voice 那边也写了一遍 —— 端点被 Windows 改名时
|
||
(驱动被 PnP 重装后,渲染端点会变成「扬声器 (2- VB-Audio Virtual Cable)」)
|
||
两边会同时失灵;更糟的是以后只修一边,就会出现"体检说没找到、清单说已装"
|
||
这种自相矛盾的诊断。所以统一委托给 voice。
|
||
"""
|
||
try:
|
||
import voice
|
||
return voice.cable_device_names()
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def cable_registry_present():
|
||
"""注册表探测:VB-CABLE 装了没有。**不依赖 sounddevice**。
|
||
|
||
为什么必须有这个兜底:`find_cable_names()` 靠 sounddevice,
|
||
而 sounddevice 就在"依赖"里 —— 当本进程是**系统 Python**、依赖却装在 `.venv`
|
||
(`deps.restart_needed` 那种状态)时,`import sounddevice` 直接失败,
|
||
于是明明装好了虚拟声卡也会被报成"没装":横幅一直喊"还差虚拟声卡"、
|
||
首页清单那一行标红,用户照着点还会去重装一遍。
|
||
官方安装会留下两处痕迹,读它们跟 Python 依赖无关:
|
||
· HKLM\\...\\Uninstall\\VB:VBCABLE {…}(Windows「应用和功能」里那条)
|
||
· HKLM\\SYSTEM\\CurrentControlSet\\Services\\{VB-Cable, VBAudioVACMME}
|
||
(uninstall.py 里有一份同样用途的探测,那个是独立进程、要单独能判断,所以是刻意重复。)
|
||
"""
|
||
import winreg
|
||
try:
|
||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") as k:
|
||
i = 0
|
||
while True:
|
||
try:
|
||
sub = winreg.EnumKey(k, i)
|
||
except OSError:
|
||
break
|
||
i += 1
|
||
if "vbcable" in sub.lower().replace(" ", ""):
|
||
return True
|
||
except OSError:
|
||
pass
|
||
for svc in ("VB-Cable", "VBAudioVACMME"):
|
||
try:
|
||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||
r"SYSTEM\CurrentControlSet\Services" + "\\" + svc):
|
||
return True
|
||
except OSError:
|
||
continue
|
||
return False
|
||
|
||
|
||
# ============================================================ 虚拟声卡(VB-CABLE)
|
||
CABLE_DIR = os.path.join(HERE, "vbcable")
|
||
CABLE_SETUP = os.path.join(CABLE_DIR, "VBCABLE_Setup_x64.exe")
|
||
# 官方下载地址(VB-Audio 的虚拟声卡安装包,约 1.3MB)。仓库里不放这个二进制:
|
||
# 它是第三方闭源安装器,放进源码仓库有许可与体积问题;但要装就让它一键能装成,
|
||
# 所以包内没带安装器时从这里下。
|
||
CABLE_URL = "https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip"
|
||
CABLE_HOME = "https://vb-audio.com/Cable/"
|
||
CABLE_CREDIT = "https://vb-cable.com/"
|
||
|
||
# ★★ 安装器**不是自包含的**,它从**自己所在的目录**读驱动文件:
|
||
# 只把 VBCABLE_Setup_x64.exe 放进包里,一键安装会**必然失败**,而本机因为早就装好了
|
||
# 虚拟声卡,所以一直没暴露。证据(对 VBCABLE_Setup_x64.exe 937,728 字节做的静态分析):
|
||
# · 整个二进制里**没有任何 INF 内容**(无 DriverVer / ServiceBinary / [Version] /
|
||
# Signature / Manufacturer,ASCII 与 UTF-16 都查过)→ 它不是把驱动嵌在自己身上;
|
||
# · 但有一组用于**运行时拼名字**的片段:
|
||
# "vbMmeCable" + "64" + "_win10.inf" / "vbaudio_cable" + "64" + "_win10.sys";
|
||
# · 导入表里有 GetModuleFileNameA/W(取自己的目录)+ SetupDiGetINFClassA(要 INF 路径)
|
||
# + UpdateDriverForPlugAndPlayDevicesA(装驱动)。
|
||
# 官方 readme.txt 也是这么说的:「Just UNZIP the package in a folder somewhere on your
|
||
# hard-disk and launch the Setup program from there」。
|
||
# 所以:**必须把下面这几个文件一起发**,缺一个按钮就是坏的。
|
||
CABLE_REQUIRED = ("vbMmeCable64_win10.inf", "vbaudio_cable64_win10.sys")
|
||
CABLE_PACK_FILES = CABLE_REQUIRED + ("vbaudio_cable64_win10.cat", "readme.txt",
|
||
"VBCABLE_Setup_x64.exe", "VBCABLE_ControlPanel.exe")
|
||
# 首启自动尝试装虚拟声卡,只试**一次**(用户拒了 UAC 就不要再每次开机弹他)。
|
||
# 删掉这个文件 = 允许下次启动再自动试一次。
|
||
CABLE_AUTOTRIED = os.path.join(HERE, "cable_autotried")
|
||
# 「退出后再删」那条分离进程留下结果的地方(uninstall.py 写,这里读一次就说清楚)
|
||
DEFERRED_RESULT = os.path.join(HERE, "_deferred_result.txt")
|
||
# 官方 Pack45 里那个安装器的 SHA256。**提权运行之前必须核对**(理由见 check_cable_installer):
|
||
# vbcable\ 在程序目录里、普通用户就能写,不核对就等于"谁都能让本程序把一个自己写的
|
||
# exe 提升到管理员"。官方换新版本时这个值会失配 —— 那种情况靠数字签名兜底,不会卡死。
|
||
CABLE_SETUP_SHA256 = "734C35DFA6D98F48782A451633CEB471166EC70D60482FD89A1123D0EE3C4F41"
|
||
# 同一时间只允许一个虚拟声卡安装流程(三个入口可能几乎同时触发,见 install_cable)
|
||
CABLE_INSTALL_LOCK = threading.Lock()
|
||
|
||
|
||
def cable_files_missing():
|
||
"""包内缺哪些安装必需的文件(空 = 齐全)。"""
|
||
return [n for n in CABLE_REQUIRED
|
||
if not os.path.exists(os.path.join(CABLE_DIR, n))]
|
||
|
||
|
||
def cable_state():
|
||
"""虚拟声卡状态:装没装、包内文件全不全。界面据此决定按钮怎么写。
|
||
|
||
`installed` = **现在真的能用**(枚举到输出端点);`registered` = 注册表里装了。
|
||
两者分开是因为它们会不一致,而混在一起会给出自相矛盾的诊断:
|
||
· 依赖装在 .venv、本进程是系统 Python → `import sounddevice` 失败 →
|
||
设备枚举不出来,但注册表说装了。这时若把两者取"或"就变成"已装",
|
||
而同一个体检里的 do_check 又照设备列表打出"没找到 VB-CABLE ✗" —— 用户懵。
|
||
· 设备被禁用 / 只装了半边 / 卸载残留 → `registered=True` 但 `installed=False`,
|
||
这时该提示"修一下/重启一次",而不是"已装、不用管"。
|
||
"""
|
||
names = find_cable_names()
|
||
return {"installed": bool(names),
|
||
"registered": cable_registry_present(),
|
||
"device": (names[0] if names else ""),
|
||
"bundled": os.path.exists(CABLE_SETUP),
|
||
"files_ok": not cable_files_missing(),
|
||
"missing_files": cable_files_missing(),
|
||
"url": CABLE_URL,
|
||
"home": CABLE_HOME,
|
||
"credit": CABLE_CREDIT}
|
||
|
||
|
||
def _cable_fetch_pack(on_line):
|
||
"""从官网下一份官方包,把需要的文件解到 vbcable\\。返回 0 = 齐了。"""
|
||
on_line(i18n.L("从 VB-Audio 官网下载安装包(约 1.3MB)…",
|
||
"Downloading the installer package from VB-Audio (~1.3MB)…"))
|
||
try:
|
||
import io
|
||
import urllib.request
|
||
import zipfile
|
||
with urllib.request.urlopen(CABLE_URL, timeout=60) as r:
|
||
data = r.read()
|
||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||
# zip 里的文件名大小写/路径不一定规整,按 basename 小写索引
|
||
byname = {}
|
||
for n in zf.namelist():
|
||
byname.setdefault(os.path.basename(n.replace("/", "\\")).lower(), n)
|
||
os.makedirs(CABLE_DIR, exist_ok=True)
|
||
got = []
|
||
for n in CABLE_PACK_FILES:
|
||
src = byname.get(n.lower())
|
||
if not src:
|
||
continue
|
||
# ★ 先写 .part 再原子改名:直接写最终文件名的话,中途失败(磁盘满 / 被杀 /
|
||
# 杀软插手)会留下**半截文件**,而 cable_files_missing() 只 exists() 一下就
|
||
# 认为"有了",下一次点击就把半截 exe 提权跑起来。
|
||
final = os.path.join(CABLE_DIR, n)
|
||
tmp = final + ".part"
|
||
with zf.open(src) as fsrc, open(tmp, "wb") as dst:
|
||
dst.write(fsrc.read())
|
||
os.replace(tmp, final)
|
||
got.append(n)
|
||
missing_now = [n for n in CABLE_PACK_FILES if n not in got]
|
||
if missing_now:
|
||
# ★ 官方包改结构(比如 Pack46 换了文件名)时**必须说出来**。
|
||
# 原来的写法是"✓ 已解出 0 个文件"然后当作成功 —— 于是会拿 vbcable\ 里
|
||
# 上一版的残留继续用,装的不是你以为的那一版。
|
||
on_line(i18n.L("✗ 官方包里没找到这些文件:" + "、".join(missing_now),
|
||
"✗ These files are not in the official package: "
|
||
+ ", ".join(missing_now)))
|
||
on_line(i18n.L(" (官方可能改了打包结构。可以从官网手动下载解压到 vbcable\\)",
|
||
" (The vendor may have changed the package layout. You can "
|
||
"download it manually and unzip it into vbcable\\)"))
|
||
return 1
|
||
on_line(i18n.L(f"✓ 已解出 {len(got)} 个文件到 vbcable\\:",
|
||
f"✓ Extracted {len(got)} file(s) into vbcable\\: ")
|
||
+ "、".join(got))
|
||
except Exception as e:
|
||
on_line(i18n.L(f"✗ 下载失败:{type(e).__name__}: {e}",
|
||
f"✗ Download failed: {type(e).__name__}: {e}"))
|
||
on_line(i18n.L(f" 手动下载:{CABLE_URL}",
|
||
f" Download manually: {CABLE_URL}"))
|
||
on_line(i18n.L(" 解压后把**整个包**里的 VBCABLE_Setup_x64.exe、vbMmeCable64_win10.inf、"
|
||
"vbaudio_cable64_win10.sys 一起放进程序目录的 vbcable\\ 里,再点一次本按钮",
|
||
" Unzip it and put VBCABLE_Setup_x64.exe, vbMmeCable64_win10.inf and "
|
||
"vbaudio_cable64_win10.sys — **the whole set** — into the program's "
|
||
"vbcable\\ folder, then click this button again"))
|
||
return 0 if not cable_files_missing() and os.path.exists(CABLE_SETUP) else 1
|
||
|
||
|
||
def install_cable(on_line):
|
||
"""装虚拟声卡:包内带安装器就直接提权运行;没带(或不全)就先去官网补齐再运行。
|
||
|
||
为什么要提权:VB-CABLE 是内核音频驱动,它**自己的安装器**要求管理员 ——
|
||
这次 UAC 与本程序无关(本体是纯用户态),日志里要讲清楚,免得用户以为程序在要权限。
|
||
|
||
⚠ 这里只能"把官方安装器拉起来",**不能静默装**:该 exe 没有任何命令行开关
|
||
(二进制里没有 -i / -s / -h / /S / usage / silent 之类的串),它是个必须人点
|
||
「Install Driver」的向导。官方 readme 还写明装完**必须重启**系统才算装完。
|
||
"""
|
||
# ★ 闸门:同一时间只允许一个安装流程。三个入口(界面按钮 / 「全部安装」第③步 /
|
||
# 首启自动)都可能几乎同时触发,原来会**弹两次 UAC、开两个向导**去动同一套
|
||
# .inf/.sys,通常两个都装不成。非阻塞拿锁,拿不到就直说"已经在装了"。
|
||
if not CABLE_INSTALL_LOCK.acquire(blocking=False):
|
||
on_line(i18n.L("虚拟声卡的安装已经在进行了,这次不重复拉(看上面的输出)",
|
||
"The virtual-audio-cable install is already in progress; not "
|
||
"starting another one (see the output above)"))
|
||
return 0
|
||
try:
|
||
# 再复查一次:用户可能在自动流程的 6 秒等待里已经自己点了按钮装完了。
|
||
if cable_state()["installed"]:
|
||
on_line(i18n.L("虚拟声卡已经在用了,跳过 ✓",
|
||
"The virtual audio cable is already in use, skipping ✓"))
|
||
return 0
|
||
return _install_cable_locked(on_line)
|
||
finally:
|
||
CABLE_INSTALL_LOCK.release()
|
||
|
||
|
||
def _install_cable_locked(on_line):
|
||
exe = CABLE_SETUP
|
||
if not os.path.exists(exe) or cable_files_missing():
|
||
miss = cable_files_missing()
|
||
if miss:
|
||
on_line(i18n.L("安装器不完整(缺 " + "、".join(miss) + "),先去官网补全…",
|
||
"The installer is incomplete (missing " + ", ".join(miss)
|
||
+ "); fetching the full set from the official site…"))
|
||
if _cable_fetch_pack(on_line) != 0:
|
||
on_line(i18n.L("✗ 没凑齐安装必需的文件,一键安装做不了。"
|
||
"(只放一个 VBCABLE_Setup_x64.exe 是不够的 —— 它要从自己所在目录读 "
|
||
"vbMmeCable64_win10.inf 和 vbaudio_cable64_win10.sys)",
|
||
"✗ The files needed to install are still missing, so one-click "
|
||
"install cannot work. (Dropping in VBCABLE_Setup_x64.exe alone is not "
|
||
"enough — it reads vbMmeCable64_win10.inf and "
|
||
"vbaudio_cable64_win10.sys from its own folder.)"))
|
||
return 1
|
||
|
||
# ★ 提权运行之前必须确认"这就是那个安装器"(详见 check_cable_installer 的注释:
|
||
# vbcable\ 普通用户就能写,不查就等于给低权限程序一条提权路)。
|
||
ok_sig, why = check_cable_installer(exe)
|
||
on_line(i18n.L(f"校验安装器:{why}", f"Verifying the installer: {why}"))
|
||
if not ok_sig:
|
||
on_line(i18n.L("✗ 拒绝提权运行它 —— 这个 exe 既不是官方那一版,也没有有效数字签名。"
|
||
"请从 VB-Audio 官网手动下载安装,或把官方 vbcable 包重新解压到 vbcable\\",
|
||
"✗ Refusing to run it elevated — this exe is neither the official "
|
||
"revision nor validly signed. Install it manually from the VB-Audio "
|
||
"site, or unzip the official vbcable package into vbcable\\ again"))
|
||
return 1
|
||
|
||
on_line(i18n.L("用管理员权限启动 VB-CABLE 安装器(会弹一次 UAC,点「是」)…",
|
||
"Launching the VB-CABLE installer elevated (one UAC prompt — click Yes)…"))
|
||
try:
|
||
import ctypes
|
||
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, "", CABLE_DIR, 1)
|
||
except Exception as e:
|
||
on_line(i18n.L(f"✗ 启动安装器失败:{e!r}", f"✗ Could not start the installer: {e!r}"))
|
||
return 1
|
||
if rc <= 32:
|
||
on_line(i18n.L(f"✗ 提权被拒绝或失败(ShellExecute 返回 {rc})",
|
||
f"✗ Elevation denied or failed (ShellExecute returned {rc})"))
|
||
return 1
|
||
on_line(i18n.L("安装器窗口出来后,点它上面的「Install Driver」,等它说 Installation Complete。",
|
||
"When the installer window appears, click “Install Driver” in it and wait "
|
||
"for “Installation Complete”."))
|
||
on_line(i18n.L("⚠ VB-Audio 官方 readme 写明:装完**必须重启一次**系统才算装完。",
|
||
"⚠ VB-Audio's own readme states that you **must reboot** to finalize the "
|
||
"installation."))
|
||
on_line(i18n.L("重启后回「设置 → 环境体检」确认虚拟声卡已被识别;"
|
||
"再在输入法里把麦克风选成 “CABLE Output (VB-Audio Virtual Cable)”。",
|
||
"After rebooting, come back to Settings → Environment check to confirm the "
|
||
"cable is detected, then select “CABLE Output (VB-Audio Virtual Cable)” as "
|
||
"the microphone in your input method editor."))
|
||
on_line(i18n.L("(本程序每 5 秒重试一次找设备:万一没重启设备就出现了,它会自己接上,不用管。)",
|
||
"(This program retries every 5 seconds: if the device shows up without a "
|
||
"reboot, it will pick it up on its own.)"))
|
||
return 0
|
||
|
||
|
||
def _sha256_file(path):
|
||
try:
|
||
import hashlib
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest().upper()
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
def _authenticode_ok(path):
|
||
"""WinVerifyTrust:这个 exe 有没有**有效的** Authenticode 签名(含证书链)。
|
||
|
||
纯 ctypes,不启外部进程 —— 策略受限的机器上也能用;故意设成
|
||
「不联网查吊销 + 只用本地缓存」,免得在离线机器上卡住。
|
||
返回 (是否有效, 说明)。
|
||
"""
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
|
||
class GUID(ctypes.Structure):
|
||
_fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD),
|
||
("Data3", wintypes.WORD), ("Data4", ctypes.c_ubyte * 8)]
|
||
|
||
class WINTRUST_FILE_INFO(ctypes.Structure):
|
||
_fields_ = [("cbStruct", wintypes.DWORD),
|
||
("pcwszFilePath", wintypes.LPCWSTR),
|
||
("hFile", wintypes.HANDLE),
|
||
("pgKnownSubject", ctypes.POINTER(GUID))]
|
||
|
||
class WINTRUST_DATA(ctypes.Structure):
|
||
_fields_ = [
|
||
("cbStruct", wintypes.DWORD),
|
||
("pPolicyCallbackData", ctypes.c_void_p),
|
||
("pSIPClientData", ctypes.c_void_p),
|
||
("dwUIChoice", wintypes.DWORD),
|
||
("fdwRevocationChecks", wintypes.DWORD),
|
||
("dwUnionChoice", wintypes.DWORD),
|
||
("pFile", ctypes.POINTER(WINTRUST_FILE_INFO)),
|
||
("dwStateAction", wintypes.DWORD),
|
||
("hWVTStateData", wintypes.HANDLE),
|
||
("pwszURLReference", wintypes.LPCWSTR),
|
||
("dwProvFlags", wintypes.DWORD),
|
||
("dwUIContext", wintypes.DWORD),
|
||
("pSignatureSettings", ctypes.c_void_p),
|
||
]
|
||
|
||
# WINTRUST_ACTION_GENERIC_VERIFY_V2 {00AAC56B-CD44-11d0-8CC2-00C04FC295EE}
|
||
action = GUID(0x00AAC56B, 0xCD44, 0x11D0,
|
||
(ctypes.c_ubyte * 8)(0x8C, 0xC2, 0x00, 0xC0, 0x4F, 0xC2, 0x95, 0xEE))
|
||
fi = WINTRUST_FILE_INFO(ctypes.sizeof(WINTRUST_FILE_INFO),
|
||
ctypes.c_wchar_p(os.path.abspath(path)), None, None)
|
||
wd = WINTRUST_DATA()
|
||
wd.cbStruct = ctypes.sizeof(WINTRUST_DATA)
|
||
wd.dwUIChoice = 2 # WTD_UI_NONE:绝不弹窗
|
||
wd.fdwRevocationChecks = 0 # WTD_REVOKE_NONE:不联网
|
||
wd.dwUnionChoice = 1 # WTD_CHOICE_FILE
|
||
wd.pFile = ctypes.pointer(fi)
|
||
wd.dwStateAction = 1 # WTD_STATEACTION_VERIFY
|
||
wd.dwProvFlags = 0x1000 | 0x100 # CACHE_ONLY_URL_RETRIEVAL | SAFER_FLAG
|
||
try:
|
||
wt = ctypes.windll.wintrust
|
||
hr = wt.WinVerifyTrust(None, ctypes.byref(action), ctypes.byref(wd))
|
||
# 必须收尾释放状态,否则句柄泄漏
|
||
wd.dwStateAction = 2 # WTD_STATEACTION_CLOSE
|
||
try:
|
||
wt.WinVerifyTrust(None, ctypes.byref(action), ctypes.byref(wd))
|
||
except Exception:
|
||
pass
|
||
if hr == 0:
|
||
return True, "签名有效"
|
||
return False, "0x%08X" % (hr & 0xFFFFFFFF)
|
||
except Exception as e:
|
||
return False, f"{type(e).__name__}: {e}"
|
||
|
||
|
||
def check_cable_installer(path):
|
||
"""★ 提权运行之前必须过这一关:**这个 exe 是不是我们认得的那个**。
|
||
|
||
为什么非做不可 —— 这是一条真实的提权路径:
|
||
`vbcable\\` 就在程序目录里,**普通用户权限就能写**。一个低权限的恶意程序
|
||
把 `VBCABLE_Setup_x64.exe` 换成自己的东西,然后等用户点「一键安装虚拟声卡」,
|
||
我们的 `ShellExecuteW(None, "runas", exe, ...)` 就会把它提升到管理员。
|
||
UAC 框上显示的还是用户预期的那个名字 —— 从"普通用户进程"到"管理员"就这么一步。
|
||
|
||
判定规则(两选一即可,这样官方以后发新版本也不会把一键安装卡死):
|
||
· SHA256 命中我们审过的那份官方文件;**或者**
|
||
· 有有效的 Authenticode 签名(证书链可信)—— 官方包是 BUREL VINCENT 签的。
|
||
返回 (是否放行, 给用户看的一行说明)。
|
||
"""
|
||
if not path or not os.path.exists(path):
|
||
return False, i18n.L("文件不存在", "file does not exist")
|
||
digest = _sha256_file(path)
|
||
if digest and digest == CABLE_SETUP_SHA256:
|
||
return True, i18n.L(f"SHA256 与官方文件一致({digest[:16]}…)",
|
||
f"SHA256 matches the official file ({digest[:16]}…)")
|
||
ok, why = _authenticode_ok(path)
|
||
if ok:
|
||
return True, i18n.L(f"不是我们审过的那一版,但有有效数字签名({why})—— 放行",
|
||
f"Not the revision we audited, but it carries a valid digital "
|
||
f"signature ({why}) — allowed")
|
||
return False, i18n.L(
|
||
f"既不是官方文件(SHA256={digest[:16] or '读取失败'}…),也没有有效数字签名({why})",
|
||
f"Neither the official file (SHA256={digest[:16] or 'unreadable'}…) nor validly "
|
||
f"signed ({why})")
|
||
|
||
|
||
def is_elevated():
|
||
try:
|
||
import ctypes
|
||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _read_python_txt():
|
||
"""读 python.txt 里那一行解释器路径。
|
||
|
||
⚠ 编码:启动器(vbs)是用 **ANSI/GBK** 读这个文件的,而这里有中文路径时
|
||
用 UTF-8 读会得到乱码、`os.path.exists` 直接为假 —— 两边读法不一致就是个雷。
|
||
所以先按 UTF-8 试,不成就按系统 ANSI 再来一次。
|
||
"""
|
||
p = os.path.join(HERE, "python.txt")
|
||
try:
|
||
raw = open(p, "rb").read()
|
||
except OSError:
|
||
return None
|
||
for enc in ("utf-8-sig", "utf-8", "mbcs", "gbk"):
|
||
try:
|
||
line = raw.decode(enc).strip().splitlines()[0].strip()
|
||
except (UnicodeDecodeError, LookupError):
|
||
continue
|
||
except IndexError:
|
||
return None
|
||
if line and os.path.exists(line):
|
||
return line
|
||
return None
|
||
|
||
|
||
def host_pythonw():
|
||
"""跑「清理脚本 / 一次性优化」该用哪个解释器 —— **必须在 .venv 和 runtime 之外**。
|
||
|
||
⚠ 必须排除 .venv 和 runtime 里的解释器:以前这里用 sys.executable,而客户端自己就跑在
|
||
`.venv\\Scripts\\pythonw.exe` 上,于是清理脚本也由那个 pythonw.exe 启动 ——
|
||
而 Windows 上**正在运行的镜像文件是锁住的**,结果 .venv 永远删不干净:
|
||
日志里是「✗ 删不掉 .venv(可能还有进程占着)」,最后只剩一个
|
||
`Scripts\\pythonw.exe` 的空壳。更糟的是下次双击启动器会优先选这个壳
|
||
→ 界面根本打不开,"双击没反应"。
|
||
|
||
离线全量包(内置 `runtime\\`)是同一个问题的翻版:清理脚本会跑在
|
||
`runtime\\pythonw.exe` 上,于是那 154MB 的运行时死活删不掉。所以这里一并排除。
|
||
真的一个外部解释器都没有时(离线包 + 目标机没装 Python),只能退回包内的 ——
|
||
那种情况由 `uninstall.py` 的「退出后再删」兜底,不再是死路。
|
||
"""
|
||
def outside_app_env(p):
|
||
if not p or not os.path.exists(p):
|
||
return False
|
||
try:
|
||
a = os.path.normcase(os.path.abspath(p))
|
||
for d in (VENV, os.path.join(HERE, "runtime")):
|
||
if a.startswith(os.path.normcase(os.path.abspath(d)) + os.sep):
|
||
return False
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
line = _read_python_txt()
|
||
if line and outside_app_env(line):
|
||
return line
|
||
cands = [os.path.join(os.path.dirname(sys.executable), "pythonw.exe")]
|
||
for ver in ("313", "312", "311", "310"):
|
||
cands.append(os.path.join(
|
||
os.environ.get("LOCALAPPDATA", ""), "Programs", "Python",
|
||
f"Python{ver}", "pythonw.exe"))
|
||
cands.append(rf"C:\Python{ver}\pythonw.exe")
|
||
for c in cands:
|
||
if outside_app_env(c):
|
||
return c
|
||
# 一个外部解释器都没有:退回包内的(清理脚本会走"退出后再删"那条路)
|
||
for p in (os.path.join(VENV, "Scripts", "pythonw.exe"),
|
||
os.path.join(HERE, "runtime", "pythonw.exe")):
|
||
if os.path.exists(p):
|
||
return p
|
||
return sys.executable
|
||
|
||
|
||
def best_pythonw():
|
||
"""该用哪个解释器重启自己 —— 必须跟启动器(vbs)同一套优先级。
|
||
|
||
⚠ 不能用 sys.executable:当 app 跑在系统 Python、
|
||
依赖却装在 .venv 里时,重启只会拉起又一个没依赖的自己 —— 死循环,
|
||
用户看到的就是"重启了还是缺依赖"。
|
||
"""
|
||
v = os.path.join(VENV, "Scripts", "pythonw.exe")
|
||
if os.path.exists(v):
|
||
return v
|
||
line = _read_python_txt()
|
||
if line:
|
||
return line
|
||
w = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
|
||
return w if os.path.exists(w) else sys.executable
|
||
|
||
|
||
def relaunch_elevated():
|
||
"""以管理员身份重新拉起自己 —— **只会弹一次 UAC**。
|
||
|
||
Frida 首次挂载 WUDFHost 可能要一次提权。与其让它在后台反复重试、把授权框
|
||
弹个不停,不如给用户一个明确的一次性入口:点一下 → 弹一次 → 点「是」。
|
||
"""
|
||
try:
|
||
import ctypes
|
||
except Exception:
|
||
return False, i18n.L("无法提权(ctypes 不可用)",
|
||
"Cannot elevate (ctypes unavailable)")
|
||
exe = sys.executable
|
||
params = f'"{os.path.join(HERE, "app.py")}" --silent --takeover'
|
||
try:
|
||
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params, HERE, 1)
|
||
except Exception as e:
|
||
return False, i18n.L(f"提权启动失败:{e!r}", f"Elevation launch failed: {e!r}")
|
||
if rc <= 32:
|
||
return False, i18n.L(f"提权被拒绝或失败(ShellExecute 返回 {rc})",
|
||
f"Elevation was denied or failed "
|
||
f"(ShellExecute returned {rc})")
|
||
return True, i18n.L("已拉起管理员实例。请在 UAC 里点「是」,约 5 秒后刷新本页面",
|
||
"Administrator instance launched. Click “Yes” in the UAC "
|
||
"prompt, then refresh this page in about 5 seconds")
|
||
|
||
|
||
def qwen_flag_state():
|
||
"""千问输入法是否已允许「程序注入的按键」。它默认会丢弃注入按键。"""
|
||
try:
|
||
import winreg
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as k:
|
||
v, _ = winreg.QueryValueEx(k, "QIANWEN_IME_UTILITY_VOICE_HOOK_ALLOW_INJECTED")
|
||
return str(v) not in ("", "0")
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def qwen_installed():
|
||
"""千问输入法装了吗?只读目录/卸载信息,不启动任何东西、不写注册表。"""
|
||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||
lad = os.environ.get("LOCALAPPDATA", "")
|
||
for p in (os.path.join(pf, "QianwenIME"), os.path.join(pf86, "QianwenIME"),
|
||
os.path.join(lad, "QianwenIME") if lad else ""):
|
||
if p and os.path.isdir(p):
|
||
return True
|
||
try:
|
||
import winreg
|
||
for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||
for sub in (r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||
r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"):
|
||
try:
|
||
with winreg.OpenKey(root, sub) as k:
|
||
n = winreg.QueryInfoKey(k)[0]
|
||
except OSError:
|
||
continue
|
||
for i in range(n):
|
||
try:
|
||
with winreg.OpenKey(k, winreg.EnumKey(k, i)) as sk:
|
||
dn, _ = winreg.QueryValueEx(sk, "DisplayName")
|
||
except OSError:
|
||
continue
|
||
s = str(dn).lower().replace(" ", "")
|
||
if "qianwen" in s or "千问" in s:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
# 用户在「清理」里删过千问开关 → 留个标记,之后不再自动加回来。
|
||
# 为什么要有它:否则用户删了、下次启动又冒出来,看着像个删不掉的 bug。
|
||
QWEN_OPTOUT = os.path.join(HERE, "qwen_optout")
|
||
|
||
|
||
def auto_qwen_flag(on_line):
|
||
"""**首次启动就替用户把千问「允许注入」打开** —— 这才是默认该有的样子。
|
||
|
||
为什么必须自动化:这个开关决定"语音键在千问里到底出不出字"。
|
||
把它做成设置页里的一个按钮,结果就是用户按了语音键没反应、也不知道还有这么个开关。
|
||
写 HKCU\\Environment 不需要管理员、随时可以删,
|
||
没有任何理由让用户自己去找。
|
||
"""
|
||
if os.path.exists(QWEN_OPTOUT):
|
||
return False
|
||
if not qwen_installed():
|
||
return False # 没装千问就别往注册表留垃圾
|
||
if qwen_flag_state():
|
||
return False # 已经是开的,不折腾(也就不重启输入法)
|
||
on_line(i18n.L("千问输入法:检测到已安装,自动打开「允许注入」"
|
||
"(不开的话千问会丢弃程序发的语音快捷键)",
|
||
"Qianwen IME: found, turning on “allow injection” automatically "
|
||
"(without it Qianwen discards the voice hotkey this program sends)"))
|
||
enable_qwen_flag(on_line)
|
||
return True
|
||
|
||
|
||
def qwen_exe(name):
|
||
"""找到千问某个 exe 的完整路径(版本子目录会变,挑版本号最大的那个)。"""
|
||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||
lad = os.environ.get("LOCALAPPDATA", "")
|
||
hits = []
|
||
for root in (os.path.join(pf, "QianwenIME"), os.path.join(pf86, "QianwenIME"),
|
||
(os.path.join(lad, "QianwenIME") if lad else "")):
|
||
if not root or not os.path.isdir(root):
|
||
continue
|
||
direct = os.path.join(root, name)
|
||
if os.path.exists(direct):
|
||
hits.append(("", direct))
|
||
try:
|
||
subs = os.listdir(root)
|
||
except OSError:
|
||
continue
|
||
for sub in subs:
|
||
p = os.path.join(root, sub, name)
|
||
if os.path.exists(p):
|
||
hits.append((sub, p))
|
||
if not hits:
|
||
return None
|
||
# 版本号倒序:0.9.0.32 > 0.9.0.9(按数字段比,别按字符串比)
|
||
def key(item):
|
||
return [int(x) if x.isdigit() else 0 for x in item[0].replace("-", ".").split(".")]
|
||
hits.sort(key=key, reverse=True)
|
||
return hits[0][1]
|
||
|
||
|
||
def frida_leftovers():
|
||
"""Frida 的历史残留:几条 frida-* 服务、几个 frida-helper 进程。
|
||
|
||
为什么要专门看这个:Frida 每挂载一个不属于自己的进程(我们的目标 WUDFHost
|
||
是 SYSTEM),都会注册一条临时服务 `frida-<helper pid>-x86*/x86_64` ——
|
||
而注册服务需要管理员,所以**普通权限下每挂一次就弹一次 UAC**。
|
||
客户端重启一次就重新挂一次,于是"重启几次就弹几次"。残留会随重启不断累积
|
||
(攒到几十条很常见)。
|
||
一次性优化(`一键优化`)就是干这个的:清残留 + 让自启以最高权限跑,之后一次都不弹。
|
||
"""
|
||
svc = []
|
||
try:
|
||
import winreg
|
||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||
r"SYSTEM\CurrentControlSet\Services") as k:
|
||
i = 0
|
||
while True:
|
||
try:
|
||
n = winreg.EnumKey(k, i)
|
||
except OSError:
|
||
break
|
||
if n.lower().startswith("frida-"):
|
||
svc.append(n)
|
||
i += 1
|
||
except OSError:
|
||
pass
|
||
help_n = 0
|
||
for exe in ("frida-helper-x86.exe", "frida-helper-x86_64.exe"):
|
||
if _proc_running(exe):
|
||
help_n += 1
|
||
return {"services": len(svc), "helpers": help_n, "names": svc}
|
||
|
||
|
||
def _proc_running(name):
|
||
"""进程在不在(只读,tasklist)。名字要带 .exe。"""
|
||
try:
|
||
out = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {name}", "/NH"],
|
||
capture_output=True, timeout=15,
|
||
creationflags=CREATE_NO_WINDOW).stdout or b""
|
||
return name.lower().encode() in out.lower()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _broadcast_env_change():
|
||
"""告诉已运行的进程"环境变量变了"(标准做法)。失败无所谓。"""
|
||
try:
|
||
import ctypes
|
||
HWND_BROADCAST, WM_SETTINGCHANGE, SMTO_ABORTIFHUNG = 0xFFFF, 0x1A, 0x0002
|
||
ctypes.windll.user32.SendMessageTimeoutW(
|
||
HWND_BROADCAST, WM_SETTINGCHANGE, 0, "Environment",
|
||
SMTO_ABORTIFHUNG, 2000, None)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def enable_qwen_flag(on_line):
|
||
"""打开千问的「允许注入」,**并且真的把它重启到生效**。
|
||
|
||
三步缺一不可,少一步就等于没做:
|
||
1. 写 `HKCU\\Environment`(静默、不需要管理员);
|
||
2. **同步更新本进程的 os.environ** —— 子进程继承的是父进程的环境块,
|
||
只写注册表的话,下面拉起来的千问读到的还是旧值,重启等于白重启;
|
||
3. **杀完必须拉回来**。千问的 `QianwenIMEServer.exe` / `QianwenIMEUiClient.exe`
|
||
被杀之后**不会自己回来**(维护服务是 Automatic 但不管保活),
|
||
只杀不拉就是"把用户的输入法弄瘸了"。
|
||
那两个 exe 本身是启动器壳:跑一下就派生真正的进程然后自己退出,所以直接拉它们就行。
|
||
"""
|
||
import winreg
|
||
name = "QIANWEN_IME_UTILITY_VOICE_HOOK_ALLOW_INJECTED"
|
||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, "Environment") as k:
|
||
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, "1")
|
||
os.environ[name] = "1" # ← 关键:让下面拉起的子进程继承到
|
||
_broadcast_env_change()
|
||
on_line(i18n.L(f"已写入用户环境变量 {name}=1",
|
||
f"Wrote user environment variable {name}=1"))
|
||
|
||
exes = [qwen_exe("QianwenIMEServer.exe"), qwen_exe("QianwenIMEUiClient.exe")]
|
||
killed = False
|
||
for exe in ("QianwenIMEServer.exe", "QianwenIMEUiClient.exe"):
|
||
try:
|
||
rc = subprocess.run(["taskkill", "/IM", exe, "/F"],
|
||
creationflags=CREATE_NO_WINDOW,
|
||
capture_output=True, timeout=20).returncode
|
||
if rc == 0:
|
||
killed = True
|
||
except Exception:
|
||
pass
|
||
# 杀掉后要等一下,否则新进程可能被旧实例的残留挡回去
|
||
if killed:
|
||
time.sleep(1.5)
|
||
|
||
launched, failed = [], []
|
||
for p in exes:
|
||
if not p:
|
||
continue
|
||
try:
|
||
subprocess.Popen([p], cwd=os.path.dirname(p),
|
||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
creationflags=0x00000008 | 0x00000200) # DETACHED | NEW_GROUP
|
||
launched.append(os.path.basename(p))
|
||
except Exception:
|
||
failed.append(os.path.basename(p)) # 多半是"需要提升"——见下面的兜底核实
|
||
|
||
# 别信"启动成功",隔几秒回头核实(QianwenIMEUiClient.exe 带 requireAdministrator
|
||
# 清单,非管理员的我们根本拉不起来,但千问的维护服务会自己把它捞回来)。
|
||
if killed or failed:
|
||
time.sleep(4)
|
||
for p in exes:
|
||
if not p:
|
||
continue
|
||
base = os.path.basename(p)
|
||
if _proc_running(base):
|
||
if base in failed:
|
||
failed.remove(base)
|
||
if base not in launched:
|
||
launched.append(base)
|
||
if launched:
|
||
on_line(i18n.L("已重启千问输入法(" + "、".join(launched) + "),新开关已生效",
|
||
"Restarted the Qianwen IME (" + ", ".join(launched)
|
||
+ "); the new switch is now in effect"))
|
||
if failed:
|
||
on_line(i18n.L("⚠ 这几个千问进程没起来:" + "、".join(failed)
|
||
+ "(通常由千问的维护服务自动拉起;若语音键仍无效,手动打开一次千问即可)",
|
||
"⚠ These Qianwen processes did not come back: " + ", ".join(failed)
|
||
+ " (its maintenance service usually restarts them; if the voice key still "
|
||
"does nothing, just open Qianwen once by hand)"))
|
||
if not killed and not launched:
|
||
on_line(i18n.L("千问当时没在跑,下次它启动时会带上新开关",
|
||
"Qianwen was not running; it will pick up the new switch next time it starts"))
|
||
|
||
|
||
# ============================================================ 自启动
|
||
def autostart_value():
|
||
# --boot:告诉启动器/app"这一份是开机自启拉起来的",app 靠它关掉
|
||
# "自动弹 UAC 装虚拟声卡"那条路(见 main() 里 auto_ok 的注释)。
|
||
return f'wscript.exe "{AUTOSTART_VBS}" --silent --boot'
|
||
|
||
|
||
def autostart_state():
|
||
"""开机自启是否已设置。两种方式任一存在即算:HKCU Run,或最高权限计划任务。
|
||
|
||
计划任务那条是为了**彻底不弹 UAC**:普通权限启动时,Frida 给助手提权会弹框;
|
||
以最高权限的计划任务启动,权限现成,一次都不会弹。
|
||
"""
|
||
if plan_task_exists():
|
||
return True
|
||
try:
|
||
import winreg
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
|
||
r"Software\Microsoft\Windows\CurrentVersion\Run") as k:
|
||
v, _ = winreg.QueryValueEx(k, AUTOSTART_NAME)
|
||
return bool(v)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def plan_task_exists(name=AUTOSTART_NAME):
|
||
"""只看 Tasks 目录里的文件 —— 不需要管理员,比 schtasks 可靠。"""
|
||
p = os.path.join(os.environ.get("WINDIR", r"C:\Windows"),
|
||
"System32", "Tasks", name)
|
||
return os.path.exists(p)
|
||
|
||
|
||
ELEVATE_PY = os.path.join(HERE, "elevate_setup.py")
|
||
ELEVATE_LOG = os.path.join(HERE, "elevate_setup.log")
|
||
UNINSTALL_PY = os.path.join(HERE, "uninstall.py")
|
||
UNINSTALL_LOG = os.path.join(HERE, "uninstall.log")
|
||
UNINSTALL_RESULT = os.path.join(HERE, "_uninstall_result.json")
|
||
|
||
|
||
def relaunch_elevated_script(script, wait_hide=True):
|
||
"""用 UAC 提权跑一个脚本 —— **只弹这一次**。"""
|
||
try:
|
||
import ctypes
|
||
rc = ctypes.windll.shell32.ShellExecuteW(
|
||
None, "runas", sys.executable, f'"{script}"', HERE,
|
||
0 if wait_hide else 1)
|
||
except Exception as e:
|
||
return False, i18n.L(f"提权失败:{e!r}", f"Elevation failed: {e!r}")
|
||
if rc <= 32:
|
||
return False, i18n.L(f"提权被拒绝或失败(ShellExecute 返回 {rc})",
|
||
f"Elevation was denied or failed "
|
||
f"(ShellExecute returned {rc})")
|
||
return True, i18n.L("已请求管理员权限", "Administrator rights requested")
|
||
|
||
|
||
def reset_bluetooth_radio():
|
||
"""重置蓝牙电台(要管理员):拆掉可能"卡住"的那条 BLE 连接。
|
||
|
||
为什么需要(真机踩过):BLE 外设只接受**一个**连接。Windows 的 HID 栈有时会把
|
||
那条唯一的连接攥住不放 —— 遥控器从此不再广播,而程序要自己建一条 GATT 连接
|
||
才能收语音,于是"永远连不上"(表现:日志一直刷连接超时,但按键还有用)。
|
||
拔电池/关开蓝牙开关能解开;这个动作就等于"用代码关开一次蓝牙电台"。
|
||
"""
|
||
try:
|
||
import ctypes
|
||
rc = ctypes.windll.shell32.ShellExecuteW(
|
||
None, "runas", sys.executable, f'"{ELEVATE_PY}" --reset-bt', HERE, 0)
|
||
except Exception as e:
|
||
return False, i18n.L(f"提权失败:{e!r}", f"Elevation failed: {e!r}")
|
||
if rc <= 32:
|
||
return False, i18n.L(f"提权被拒绝或失败(ShellExecute 返回 {rc})",
|
||
f"Elevation was denied or failed (ShellExecute returned {rc})")
|
||
return True, i18n.L("已请求管理员权限:正在重置蓝牙电台,几秒后会自动重连",
|
||
"Administrator rights requested: resetting the Bluetooth radio; "
|
||
"it reconnects automatically in a few seconds")
|
||
|
||
|
||
def run_reset_bt(on_line):
|
||
"""已经以管理员身份在跑:直接执行提权脚本的 --reset-bt,把输出喂给界面。"""
|
||
import subprocess
|
||
on_line(i18n.L("重置蓝牙电台:禁用 → 等 3 秒 → 启用(期间蓝牙设备会短暂断开)…",
|
||
"Resetting the Bluetooth radio: disable → wait 3 s → enable "
|
||
"(Bluetooth devices drop briefly)…"))
|
||
try:
|
||
p = subprocess.run([sys.executable, ELEVATE_PY, "--reset-bt"],
|
||
capture_output=True, text=True, encoding="utf-8",
|
||
errors="replace", cwd=HERE, timeout=120)
|
||
for ln in (p.stdout or "").splitlines():
|
||
on_line(ln)
|
||
if p.stderr:
|
||
on_line((p.stderr or "").strip()[:300])
|
||
on_line(i18n.L("完成 —— 几秒后会自动重连遥控器",
|
||
"Done — the remote reconnects automatically in a few seconds"))
|
||
except Exception as e:
|
||
on_line(i18n.L(f"失败:{e!r}", f"Failed: {e!r}"))
|
||
|
||
|
||
def follow_elevate_log():
|
||
"""提权后的进程没有 stdout 给我们,所以它把过程写进 elevate_setup.log,
|
||
这里跟着读出来显示在界面上(daemon 线程,进程退出就一起走)。"""
|
||
deadline = time.time() + 120
|
||
seen = 0
|
||
while time.time() < deadline:
|
||
if os.path.exists(ELEVATE_LOG):
|
||
try:
|
||
with open(ELEVATE_LOG, encoding="utf-8", errors="replace") as f:
|
||
lines = f.read().splitlines()
|
||
except Exception:
|
||
lines = []
|
||
for ln in lines[seen:]:
|
||
BUS.log(ln)
|
||
seen = len(lines)
|
||
if lines and lines[-1].strip().endswith("DONE"):
|
||
BUS.push({"ev": "state",
|
||
"state": state_snapshot(load_config(), ROLES)})
|
||
return
|
||
time.sleep(0.5)
|
||
BUS.log(i18n.L("(等提权进程结束超时;稍后可直接看 elevate_setup.log)",
|
||
"(Timed out waiting for the elevated process; you can read elevate_setup.log later)"))
|
||
|
||
|
||
def run_elevate_setup(on_line):
|
||
"""已经在管理员身份下就直接跑(省掉一次 UAC)。"""
|
||
import elevate_setup
|
||
rc = elevate_setup.main()
|
||
try:
|
||
with open(ELEVATE_LOG, encoding="utf-8", errors="replace") as f:
|
||
for ln in f.read().splitlines():
|
||
on_line(ln)
|
||
except Exception:
|
||
pass
|
||
return rc
|
||
|
||
|
||
def follow_uninstall_log():
|
||
"""跟着读卸载日志。卸载脚本最后会结束本进程,所以这里大概率读不到结尾 —— 正常。"""
|
||
deadline = time.time() + 180
|
||
seen = 0
|
||
while time.time() < deadline:
|
||
if os.path.exists(UNINSTALL_LOG):
|
||
try:
|
||
with open(UNINSTALL_LOG, encoding="utf-8", errors="replace") as f:
|
||
lines = f.read().splitlines()
|
||
except Exception:
|
||
lines = []
|
||
for ln in lines[seen:]:
|
||
BUS.log(ln)
|
||
seen = len(lines)
|
||
if lines and lines[-1].strip().endswith("DONE"):
|
||
# 卸载脚本最后会把本进程杀掉,所以这里要**抢在死之前**把结果推给界面,
|
||
# 否则用户那边只会看到"忽然读不到状态",以为程序崩了。
|
||
msg = i18n.L("已按你勾选的项清理完毕",
|
||
"Cleanup of the items you selected is complete")
|
||
failed = []
|
||
kills = True
|
||
try:
|
||
with open(UNINSTALL_RESULT, encoding="utf-8-sig") as f:
|
||
d = json.load(f)
|
||
items = d.get("items") or []
|
||
failed = d.get("failed") or []
|
||
kills = bool(d.get("kills_client", True))
|
||
msg = i18n.L(f"已清理 {d.get('count', len(items))} 项:",
|
||
f"Cleaned {d.get('count', len(items))} item(s): ") \
|
||
+ i18n.L("、", ", ").join(items)
|
||
if failed:
|
||
# 报成功但东西还在 —— 必须如实说,否则用户下次启动失败会更懵
|
||
msg += i18n.L(";但有 ", "; but ") \
|
||
+ i18n.L(f"{len(failed)} 项没删掉:",
|
||
f"{len(failed)} item(s) were not removed: ") \
|
||
+ i18n.L("、", ", ").join(failed)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
# 读完就删:它是清理脚本留下的"回执",我们已经把内容推给界面了。
|
||
# 留着的话,"清理干净"的程序目录里会多一个没人再读的 json。
|
||
try:
|
||
os.remove(UNINSTALL_RESULT)
|
||
except OSError:
|
||
pass
|
||
BUS.push({"ev": "uninstalled", "msg": msg, "failed": failed,
|
||
"kills_client": kills})
|
||
if kills:
|
||
BUS.log(i18n.L("清理完成 ✅ —— 客户端马上退出(页面读不到状态是正常的)",
|
||
"Cleanup finished ✅ — the client is about to exit "
|
||
"(losing the status feed is normal)"))
|
||
else:
|
||
BUS.log(i18n.L("清理完成 ✅ —— 客户端继续运行,页面不用关。",
|
||
"Cleanup finished ✅ — the client keeps running; "
|
||
"leave this page open."))
|
||
if failed:
|
||
BUS.log(i18n.L("⚠ 没删掉的项:", "⚠ Items not removed: ")
|
||
+ i18n.L("、", ", ").join(failed)
|
||
+ i18n.L(" —— 多半还有进程占着,关掉相关程序后再点一次「清理」。",
|
||
" — something is probably still holding them; close "
|
||
"the related programs and click “Cleanup” again."))
|
||
time.sleep(1.2) # 留点时间让 SSE 推出去再被结束
|
||
return
|
||
time.sleep(0.5)
|
||
|
||
|
||
def set_autostart(on, on_line=None):
|
||
"""用当前用户的 Run 项:**不需要管理员,所以永远不会弹 UAC**。
|
||
|
||
(不用「启动文件夹 + VBS」或计划任务:它们会因为权限/编码各种翻车,
|
||
而 Run 项在登录时以当前用户身份静默启动,最稳。)
|
||
"""
|
||
import winreg
|
||
key = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||
try:
|
||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key) as k:
|
||
if on:
|
||
if not os.path.exists(AUTOSTART_VBS):
|
||
return False, i18n.L(f"找不到启动脚本:{AUTOSTART_VBS}",
|
||
f"Launcher script not found: {AUTOSTART_VBS}")
|
||
val = autostart_value()
|
||
winreg.SetValueEx(k, AUTOSTART_NAME, 0, winreg.REG_SZ, val)
|
||
got, _ = winreg.QueryValueEx(k, AUTOSTART_NAME) # 回读校验
|
||
if got != val:
|
||
return False, i18n.L("写入后回读不一致,自启可能没生效",
|
||
"Read-back after writing does not match; "
|
||
"autostart may not be active")
|
||
BUS.log(i18n.L(f"已写入开机自启:{val}", f"Autostart written: {val}"))
|
||
return True, i18n.L("已设置开机自启(当前用户,无需管理员)",
|
||
"Autostart enabled (current user, no administrator needed)")
|
||
try:
|
||
winreg.DeleteValue(k, AUTOSTART_NAME)
|
||
return True, i18n.L("已取消开机自启", "Autostart disabled")
|
||
except FileNotFoundError:
|
||
return True, i18n.L("本来就没设置", "It was not enabled in the first place")
|
||
except PermissionError as e:
|
||
return False, i18n.L(f"没有权限写自启项(被安全策略挡了):{e}",
|
||
f"No permission to write the autostart entry "
|
||
f"(blocked by security policy): {e}")
|
||
except OSError as e:
|
||
return False, i18n.L(f"写自启项失败:{type(e).__name__}: {e}",
|
||
f"Failed to write the autostart entry: "
|
||
f"{type(e).__name__}: {e}")
|
||
|
||
|
||
# ============================================================ 角色管理
|
||
class Roles:
|
||
"""把语音和按键两个角色装在本进程的线程里,并提供启动/停止/重启。"""
|
||
|
||
def __init__(self, cfg):
|
||
self.cfg = cfg
|
||
self.voice = None
|
||
self.keytap = None
|
||
self.lock = threading.Lock()
|
||
|
||
def _on_event(self, kind, payload=None):
|
||
if kind == "log":
|
||
BUS.log(payload)
|
||
elif kind == "key":
|
||
p = payload or {}
|
||
kid, down = p.get("id"), bool(p.get("down"))
|
||
BUS.push({"ev": "key", "id": kid, "down": down})
|
||
# 按键事件必须同时写一行日志:用户「按了半天没反应」时,
|
||
# 只有日志能区分「根本没读到」和「读到了但没映射」。
|
||
if down:
|
||
label = REMOTE_LABELS.get(kid, str(kid))
|
||
if kid == "voice":
|
||
spec = self.cfg.get("voice") or {}
|
||
how = "按一下" if spec.get("mode") == "tap" else "按住"
|
||
BUS.log(f"[按键] 语音 → {how} {spec.get('key')}")
|
||
else:
|
||
m = (self.cfg.get("keys") or {}).get(kid)
|
||
BUS.log(f"[按键] {label} → "
|
||
+ (describe_mapping(m) if m else "(未映射)"))
|
||
elif kind == "status":
|
||
BUS.push({"ev": "state", "state": state_snapshot(self.cfg, self)})
|
||
|
||
def start(self):
|
||
d = deps_state()
|
||
if not d["ok"]:
|
||
return False, i18n.L(
|
||
"缺依赖(" + "、".join(d["missing"]) +
|
||
")—— 先在「设置 → 诊断」点「安装基础依赖」",
|
||
"Missing dependencies (" + ", ".join(d["missing"]) +
|
||
") — click “Install base dependencies” under “Settings → Diagnostics” first")
|
||
if d.get("restart_needed"):
|
||
return False, i18n.L(
|
||
"依赖已经装进包内 .venv 了,但当前进程用的是另一个 Python —— "
|
||
"点「重启整个客户端」才生效",
|
||
"The dependencies are in the bundled .venv, but this process runs on "
|
||
"a different Python — click “Restart the whole client” to apply")
|
||
# 千问「允许注入」开关:语音键能不能出字的前提,必须在角色起来**之前**弄好,
|
||
# 否则会出现"刚启动那几秒按了没反应"。它由 _ensure_qwen_flag() 负责(见那里的注释)。
|
||
self._ensure_qwen_flag()
|
||
with self.lock:
|
||
if self.voice is None:
|
||
import voice as voice_mod
|
||
self.voice = voice_mod.VoiceRole(
|
||
self._on_event, addr=self.cfg.get("remote_addr"),
|
||
audio=self.cfg.get("audio"), voice_spec=self.cfg.get("voice"),
|
||
relay=bool((self.cfg.get("audio") or {}).get("relay", True)))
|
||
self.voice.start()
|
||
if self.keytap is None and importlib.util.find_spec("frida") is not None:
|
||
import keys as keys_mod
|
||
self.keytap = keys_mod.KeyTap(self._on_event)
|
||
self.keytap.set_mapping(self.cfg.get("keys") or {})
|
||
if self.voice is not None:
|
||
# 遥控器一发报文就叫醒语音角色去重连:它只在按键后醒一小会,
|
||
# 而这正是唯一能连上的窗口(退避最多 45 秒,靠等会全错过)。
|
||
self.keytap.on_activity = self.voice.poke
|
||
self.keytap.start()
|
||
BUS.log(i18n.L("客户端已启动", "Client started"))
|
||
BUS.push({"ev": "state", "state": state_snapshot(self.cfg, self)})
|
||
return True, i18n.L("已启动", "Started")
|
||
|
||
def _ensure_qwen_flag(self):
|
||
"""语音键在千问里能不能出字,取决于这个全局开关 —— 起角色前先弄好。
|
||
|
||
⚠ 刻意**放在"启动角色"这条必经之路上**(而不是 main() 里无条件调用):
|
||
`--no-start` 的语义是"只开界面、什么都不启动",而 main() 里无条件调用会让
|
||
**打包自检**也去改用户 HKCU 里的全局环境变量、还顺带重启一次千问输入法 ——
|
||
自检不该留下这种副作用(本机构建完自检完,机器就不"干净"了)。
|
||
放在这里还有个附带好处:`--no-start` 起来之后在界面点「启动」,开关照样会被弄好。
|
||
"""
|
||
try:
|
||
auto_qwen_flag(BUS.log)
|
||
except Exception as e:
|
||
BUS.log(i18n.L(
|
||
f"自动打开千问注入开关失败(不影响使用,可在设置页手动点):{e!r}",
|
||
f"Could not turn on Qianwen's injection switch automatically (not fatal — "
|
||
f"you can click it on the Settings page): {e!r}"))
|
||
try:
|
||
self._warn_ime_mismatch()
|
||
except Exception:
|
||
pass
|
||
|
||
def _warn_ime_mismatch(self):
|
||
"""预设里的输入法和"本机实际装的"对不上 —— 那语音键就是白按。
|
||
|
||
真机踩过两次:预设被切成「微信输入法」之后,遥控器发的是 **Ctrl+Win**,而千问
|
||
只认 **RALT** —— 连接、音频、音频帧全都正常,唯独千问的浮层不弹,用户看到的是
|
||
"语音键没反应 / 千问不能用了",而且这个选择**存在配置里**,重启后照旧。
|
||
预设本来就把握手键绑在输入法上,所以启动时对一次、对不上直接说清楚。
|
||
"""
|
||
ime = str((self.cfg.get("profile") or {}).get("ime") or "")
|
||
v = self.cfg.get("voice") or {}
|
||
key = str(v.get("key") or "").upper()
|
||
mods = [str(m).upper() for m in (v.get("mods") or [])]
|
||
if not qwen_installed():
|
||
return
|
||
if key == "RALT" and not mods:
|
||
return # 千问认的就是「按住 RALT」,没问题
|
||
spec = "+".join(mods + [key]) if key else "(空)"
|
||
BUS.log(i18n.L(
|
||
f"⚠ 本机装的是**千问**输入法,它只认「按住 RALT」,而现在注入的是 {spec}"
|
||
+ (f"(预设选的是「{ime}」)" if ime else "")
|
||
+ " —— 按语音键不会出字。到「按键映射 → 预设方案」把输入法切回「千问输入法」即可。",
|
||
f"⚠ This machine has the **Qianwen** IME, which only answers “hold RALT”, but {spec} "
|
||
f"is being injected" + (f" (preset: “{ime}”)" if ime else "")
|
||
+ " — the voice key will do nothing. Switch the preset's IME back to Qianwen."))
|
||
|
||
def stop(self, quiet=False):
|
||
with self.lock:
|
||
if self.voice:
|
||
self.voice.stop()
|
||
self.voice = None
|
||
if self.keytap:
|
||
self.keytap.stop()
|
||
self.keytap = None
|
||
if not quiet:
|
||
BUS.log(i18n.L("客户端已停止", "Client stopped"))
|
||
BUS.push({"ev": "state", "state": state_snapshot(self.cfg, self)})
|
||
return True, i18n.L("已停止", "Stopped")
|
||
|
||
def restart(self):
|
||
self.stop(quiet=True)
|
||
time.sleep(0.4)
|
||
return self.start()
|
||
|
||
def apply_config(self, cfg):
|
||
"""配置保存后热更新:映射即时生效,不用重启。
|
||
|
||
★ 遥控器地址也要一起热更新:以前这里只管按键映射和语音键,地址改了却要
|
||
重启客户端才生效 —— 用户看到的是"地址填对了、语音键还是没反应"。
|
||
"""
|
||
self.cfg = cfg
|
||
addr = str(cfg.get("remote_addr") or "").strip()
|
||
changed = False
|
||
with self.lock:
|
||
if self.keytap:
|
||
self.keytap.set_mapping(cfg.get("keys") or {})
|
||
if self.voice:
|
||
self.voice.set_voice_spec(cfg.get("voice") or {})
|
||
changed = self.voice.set_addr(addr)
|
||
if changed:
|
||
BUS.log(i18n.L(
|
||
f"遥控器地址已更新为 {addr or '(空)'} —— 立刻按新地址重连(无需重启)",
|
||
f"Remote address updated to {addr or '(empty)'} — reconnecting now"))
|
||
BUS.log(i18n.L("配置已生效(映射热更新,无需重启)",
|
||
"Config applied (mappings update live, no restart needed)"))
|
||
|
||
@property
|
||
def running(self):
|
||
return self.voice is not None
|
||
|
||
|
||
ROLES: Roles | None = None
|
||
|
||
|
||
def state_snapshot(cfg, roles=None):
|
||
roles = roles or ROLES
|
||
d = deps_state()
|
||
cable = cable_state()
|
||
# 虚拟声卡是语音的**硬性前提**(没它语音出不了字),但**不并进 deps["missing"]**:
|
||
# deps["ok"] 还管着"要不要启动语音/按键角色",而按键映射根本不需要声卡 ——
|
||
# 并进去会变成"没装声卡就整个不启动",那是更糟的结果。所以单独给 cable_ok,
|
||
# 由界面的「首次准备」把这几件事一起摆出来。
|
||
# cable_ok 取"设备 + 注册表"两者之或:**装了就别再劝人重装**。
|
||
# 另外单独给 cable_usable(设备真的能用)与 cable_ghost(注册表有、设备没有):
|
||
# 后者是"卸完还没重启 / 驱动被禁用 / 只装了半边"那种状态 —— 语音同样是坏的,
|
||
# 顶部横幅必须说话,但要说的**不是**"点安装"而是"先重启一次"。
|
||
d = dict(d,
|
||
cable_ok=bool(cable["installed"] or cable["registered"]),
|
||
cable_usable=bool(cable["installed"]),
|
||
cable_ghost=bool(cable["registered"] and not cable["installed"]))
|
||
v = getattr(roles, "voice", None)
|
||
k = getattr(roles, "keytap", None)
|
||
return {
|
||
"running": bool(roles and roles.running),
|
||
"remote_addr": cfg.get("remote_addr"),
|
||
"voice": {
|
||
"mode": (cfg.get("voice") or {}).get("mode", "hold"),
|
||
"key": (cfg.get("voice") or {}).get("key", "RALT"),
|
||
"mods": (cfg.get("voice") or {}).get("mods", []),
|
||
"ready": bool(v and v.voice.ok),
|
||
"connected": bool(v and v.connected),
|
||
"note": (v.note if v else i18n.L("未启动", "Not started")),
|
||
},
|
||
"keys": {
|
||
"ready": bool(k and k.ready),
|
||
"note": (k.note if k else (i18n.L("未安装 frida", "frida not installed")
|
||
if not d["ok"]
|
||
else i18n.L("未启动", "Not started"))),
|
||
"blocked_count": len(getattr(k, "blocked_usages", []) or []),
|
||
},
|
||
"deps": d,
|
||
"autostart": autostart_state(),
|
||
"qwen_flag": qwen_flag_state(),
|
||
# 界面要靠这个决定"要不要摆那个按钮":没装千问的机器上摆一个点了没反应的
|
||
# 按钮比不摆更糟(用户会以为程序坏了)。
|
||
"qwen_installed": qwen_installed(),
|
||
"qwen_optout": os.path.exists(QWEN_OPTOUT),
|
||
# 当前语言(zh/en)。界面靠它确认"后端跟界面说的是同一种话"。
|
||
"lang": i18n.current(),
|
||
# 虚拟声卡:语音的物理前提。界面靠这个决定"要不要显示安装按钮"。
|
||
"cable": cable,
|
||
"frida_leftovers": frida_leftovers(),
|
||
"elevated": is_elevated(),
|
||
# 「一键优化」做过没有 = 最高权限计划任务在不在。做了之后 Frida 不再弹 UAC。
|
||
"optimized": plan_task_exists(),
|
||
"versions": {"python": sys.version.split()[0], "app": APP_VERSION,
|
||
"bundled": is_bundled_runtime(),
|
||
"runtime": (i18n.L("内置运行时(离线包)",
|
||
"Bundled runtime (offline bundle)")
|
||
if is_bundled_runtime()
|
||
else (".venv" if venv_python()
|
||
else i18n.L("系统 Python", "system Python")))},
|
||
"home": HERE, # 界面会显示它 —— 防止"跑的是另一份副本"这种事故
|
||
}
|
||
|
||
|
||
# ============================================================ 后台任务
|
||
JOBS = {}
|
||
JOBS_LOCK = threading.Lock()
|
||
JOB_SEQ = [0]
|
||
MAX_JOBS = 40 # 同时在内存里保留的任务上限(超了淘汰最老的)
|
||
RUNNING = {} # 标题 -> jid:同一个任务不许并发跑两份
|
||
|
||
|
||
def new_job(title):
|
||
# ⚠ 原来的 id 是 `int(time.time()*1000) % 1000000` —— 同一毫秒内起两个任务会**撞 id
|
||
# 并互相覆盖**(真实但难撞)。加一个自增序号就不会了,而且仍然按时间递增。
|
||
with JOBS_LOCK:
|
||
JOB_SEQ[0] += 1
|
||
jid = f"job{int(time.time() * 1000) % 1000000}{JOB_SEQ[0] % 1000}"
|
||
JOBS[jid] = {"title": title, "lines": [], "done": False}
|
||
# 淘汰:JOBS 原来只增不减,每个任务还能挂 4000 行 —— 攒久了就是纯泄漏。
|
||
# 从最老的开始扔(dict 保持插入顺序);**正在跑的那些不扔**(RUNNING 里那几条),
|
||
# 否则用户会看到"任务在跑但输出一片空白"。RUNNING 的条数 = 不同动作标题数(十来个),
|
||
# 所以这个上限是硬的。
|
||
if len(JOBS) > MAX_JOBS:
|
||
live = set(RUNNING.values())
|
||
for old in list(JOBS):
|
||
if len(JOBS) <= MAX_JOBS:
|
||
break
|
||
if old in live or old == jid:
|
||
continue
|
||
JOBS.pop(old, None)
|
||
return jid
|
||
|
||
|
||
def job_line(jid, line):
|
||
with JOBS_LOCK:
|
||
j = JOBS.get(jid)
|
||
if j and len(j["lines"]) < 4000:
|
||
j["lines"].append(line)
|
||
BUS.push({"ev": "job", "job": jid, "line": line, "done": False})
|
||
|
||
|
||
def job_done(jid):
|
||
with JOBS_LOCK:
|
||
if jid in JOBS:
|
||
JOBS[jid]["done"] = True
|
||
BUS.push({"ev": "job", "job": jid, "line": "", "done": True})
|
||
|
||
|
||
def background(title, fn):
|
||
"""起一个后台任务。**同一个标题不许并发跑两份** —— 返回已经在跑的那个 jid。
|
||
|
||
为什么必须去重(这不是防 DoS,是防真损坏):重复点「全部安装」/「安装基础依赖」
|
||
会同时起两个 `pip install --target .venv` 进程,往同一个目录里写 —— 轻则报错,
|
||
重则把 .venv 装成半成品(而启动器会优先选那个半成品,表现为"双击没反应")。
|
||
"""
|
||
with JOBS_LOCK:
|
||
running = RUNNING.get(title)
|
||
if running and not JOBS.get(running, {}).get("done"):
|
||
BUS.log(i18n.L(f"「{title}」已经在跑了,这次不重复起",
|
||
f"“{title}” is already running; not starting another one"))
|
||
return running
|
||
jid = new_job(title)
|
||
with JOBS_LOCK:
|
||
RUNNING[title] = jid
|
||
BUS.log(i18n.L(f"开始:{title}", f"Started: {title}"))
|
||
|
||
def runner():
|
||
try:
|
||
rc = fn(lambda s: job_line(jid, s))
|
||
job_line(jid, i18n.L(f"完成(返回码 {rc})", f"Done (return code {rc})")
|
||
if rc is not None else i18n.L("完成", "Done"))
|
||
except Exception as e:
|
||
job_line(jid, i18n.L(f"出错:{type(e).__name__}: {e}",
|
||
f"Error: {type(e).__name__}: {e}"))
|
||
finally:
|
||
job_done(jid)
|
||
with JOBS_LOCK:
|
||
if RUNNING.get(title) == jid:
|
||
RUNNING.pop(title, None) # 跑完了才允许同名任务再来一份
|
||
BUS.log(i18n.L(f"结束:{title}", f"Finished: {title}"))
|
||
BUS.push({"ev": "state", "state": state_snapshot(load_config(), ROLES)})
|
||
|
||
threading.Thread(target=runner, daemon=True, name=f"job-{title}").start()
|
||
return jid
|
||
|
||
|
||
# ============================================================ 环境体检
|
||
def do_check(on_line):
|
||
cfg = load_config()
|
||
on_line(f"{APP_NAME} v{APP_VERSION}")
|
||
on_line(i18n.L(f"Python:{sys.version.split()[0]} ({sys.executable})",
|
||
f"Python: {sys.version.split()[0]} ({sys.executable})"))
|
||
on_line(i18n.L(f"包目录:{HERE}", f"Package directory: {HERE}"))
|
||
vpy = venv_python()
|
||
if is_bundled_runtime():
|
||
on_line(i18n.L("运行方式:**离线全量包**(自带 Python 运行时 + 预装依赖,"
|
||
"目标机器不需要装 Python,也不需要联网)",
|
||
"Mode: **offline full bundle** (bundles a Python runtime + "
|
||
"pre-installed dependencies; the target machine needs neither "
|
||
"Python nor internet access)"))
|
||
else:
|
||
on_line(i18n.L("包内虚拟环境:", "Bundled virtualenv: ")
|
||
+ (i18n.L(f"有({vpy})", f"yes ({vpy})") if vpy
|
||
else i18n.L("没有 —— 点「安装基础依赖」会自动创建",
|
||
"none — clicking “Install base dependencies” creates one "
|
||
"automatically")))
|
||
d = deps_state()
|
||
if d["ok"]:
|
||
on_line(i18n.L("基础依赖(语音):齐全 ✓",
|
||
"Base dependencies (voice): all present ✓"))
|
||
else:
|
||
for m in d["missing"]:
|
||
on_line(i18n.L(f"基础依赖(语音):缺 {m}",
|
||
f"Base dependencies (voice): missing {m}"))
|
||
if d["keys_ok"]:
|
||
on_line(i18n.L("按键映射依赖(frida):齐全 ✓",
|
||
"Key-mapping dependency (frida): present ✓"))
|
||
else:
|
||
on_line(i18n.L("按键映射依赖(frida):缺 ",
|
||
"Key-mapping dependency (frida): missing ")
|
||
+ i18n.L("、", ", ").join(d["missing_keys"])
|
||
+ i18n.L("(不装也可以用语音键)",
|
||
" (the voice button works without it)"))
|
||
cables = find_cable_names()
|
||
cab = cable_state()
|
||
if cables:
|
||
on_line(i18n.L(f"虚拟声卡:{cables[0]} ✓",
|
||
f"Virtual audio cable: {cables[0]} ✓"))
|
||
on_line(i18n.L(" ⚠ 输入法里的麦克风要选 “CABLE Output (VB-Audio Virtual Cable)”",
|
||
" ⚠ In your input method editor, select "
|
||
"“CABLE Output (VB-Audio Virtual Cable)” as the microphone"))
|
||
elif cab["registered"]:
|
||
# 注册表说装了、但枚举不到能用的输出端点。**必须跟 cable_state 说一致**
|
||
# (以前这里只看设备列表,而别处取"设备或注册表",同一个体检里会一个说
|
||
# "已装"、一个说"没找到",用户完全不知道该信谁)。
|
||
on_line(i18n.L("虚拟声卡:注册表里装了 VB-CABLE,但**现在枚举不到可用的输出端点** ✗",
|
||
"Virtual audio cable: VB-CABLE is registered but **no usable output "
|
||
"endpoint is enumerated right now** ✗"))
|
||
on_line(i18n.L(" 常见原因:设备被禁用了 / 只装了半边 / 卸载残留 / 刚装完还没重启。"
|
||
"先重启一次;还不行就在设备管理器里看 “VB-Audio Virtual Cable”,"
|
||
"或从界面点「安装虚拟声卡」重装一次",
|
||
" Usual causes: the device is disabled / only one half got installed / "
|
||
"uninstall leftovers / installed but not rebooted yet. Reboot once; if "
|
||
"it still fails, look for “VB-Audio Virtual Cable” in Device Manager, "
|
||
"or reinstall from the UI's install button"))
|
||
else:
|
||
on_line(i18n.L("虚拟声卡:没找到 VB-CABLE ✗(遥控器麦克风没地方出声)",
|
||
"Virtual audio cable: VB-CABLE not found ✗ (the remote "
|
||
"microphone has nowhere to play)"))
|
||
miss = cable_files_missing()
|
||
if miss:
|
||
# 只放一个 exe 是不够的 —— 安装器要从自己所在目录读 .inf/.sys。
|
||
# 这里明说,免得用户点了按钮只看到一个装不上的报错。
|
||
on_line(i18n.L(" ⚠ 包内安装器不全(缺 " + "、".join(miss)
|
||
+ "):点按钮时程序会先去官网补齐,也可以手动把整个包解压到 "
|
||
"vbcable\\ 里",
|
||
" ⚠ The bundled installer is incomplete (missing "
|
||
+ ", ".join(miss) + "): the program will fetch the full set from "
|
||
"the official site when you click the button, or you can unzip "
|
||
"the whole package into vbcable\\ yourself"))
|
||
if cable_state()["bundled"]:
|
||
on_line(i18n.L(" 装法:点界面「安装虚拟声卡(VB-CABLE)」—— 包内已带安装器,需要一次管理员",
|
||
" How to install: click “Install the virtual audio cable "
|
||
"(VB-CABLE)” in the UI — the installer is bundled and needs "
|
||
"administrator rights once"))
|
||
else:
|
||
on_line(i18n.L(" 装法:点界面「安装虚拟声卡(VB-CABLE)」—— 会自动从官网下载(约 1.3MB)再安装,"
|
||
"需要一次管理员",
|
||
" How to install: click “Install the virtual audio cable "
|
||
"(VB-CABLE)” in the UI — it downloads from the official site "
|
||
"(~1.3MB) and installs; needs administrator rights once"))
|
||
on_line(i18n.L(f" 手动下载:{CABLE_URL}", f" Manual download: {CABLE_URL}"))
|
||
on_line(i18n.L(" ⚠ VB-Audio 官方要求:装完**必须重启一次**系统才算装完",
|
||
" ⚠ VB-Audio's own requirement: you **must reboot** to finalize "
|
||
"the installation"))
|
||
try:
|
||
import keys as keys_mod
|
||
pid = keys_mod.find_wudfhost_pid()
|
||
if pid:
|
||
on_line(i18n.L(f"蓝牙 HID 驱动宿主:PID {pid} ✓(遥控器在 HID 通道上活着)",
|
||
f"Bluetooth HID driver host: PID {pid} ✓ (the remote is alive "
|
||
f"on the HID channel)"))
|
||
else:
|
||
on_line(i18n.L("蓝牙 HID 驱动宿主:没找到 —— 遥控器配对了吗?按一下遥控器键试试",
|
||
"Bluetooth HID driver host: not found — is the remote paired? "
|
||
"Press a button on the remote and try again"))
|
||
except Exception as e:
|
||
on_line(i18n.L(f"蓝牙 HID 驱动宿主:查询失败 {type(e).__name__}",
|
||
f"Bluetooth HID driver host: query failed {type(e).__name__}"))
|
||
# ★ 蓝牙电台本身的状态:被禁用时**所有蓝牙设备都会从列表里消失**(用户会以为"设备全没了")。
|
||
# 真机上出现过(重置电台那一步如果中途被打断就会这样)—— 这里主动查一次并给出手动恢复步骤。
|
||
try:
|
||
import subprocess as _sp
|
||
rc, out = 0, ""
|
||
try:
|
||
p = _sp.run(["powershell", "-NoProfile", "-Command",
|
||
"$d = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
|
||
"Where-Object { $_.InstanceId -like 'USB*' });"
|
||
"if ($d.Count -eq 0) { 'NO-RADIO' } else {"
|
||
" $d | ForEach-Object { $_.Status + ' | ' + $_.FriendlyName } }"],
|
||
capture_output=True, timeout=25, creationflags=CREATE_NO_WINDOW)
|
||
out = ""
|
||
raw = (p.stdout or b"") + (p.stderr or b"")
|
||
for enc in ("utf-8", "gbk", "mbcs"): # 中文设备名会是 GBK,写死 utf-8 会乱码
|
||
try:
|
||
out = raw.decode(enc)
|
||
break
|
||
except (UnicodeDecodeError, LookupError):
|
||
continue
|
||
else:
|
||
out = raw.decode("utf-8", "replace")
|
||
except Exception as e:
|
||
out = f"QUERY-FAIL {e!r}"
|
||
text = (out or "").strip()
|
||
broken = []
|
||
for line in text.splitlines():
|
||
low = line.lower()
|
||
if "|" in line and ("error" in low or "unknown" in low or "disabled" in low
|
||
or "已禁用" in line or "错误" in line):
|
||
broken.append(line.strip())
|
||
if "NO-RADIO" in text:
|
||
on_line(i18n.L("蓝牙电台:没找到 USB 上的蓝牙适配器 —— 设备管理器里看看它是否被禁用/掉了",
|
||
"Bluetooth radio: no USB Bluetooth adapter found — check Device Manager "
|
||
"whether it is disabled or missing"))
|
||
elif broken:
|
||
on_line(i18n.L(
|
||
f"⚠ 蓝牙电台状态不对({broken[0]})—— **所有蓝牙设备都会消失**。救回来:"
|
||
f"设备管理器 → 蓝牙 → 右键那个适配器 → 「启用设备」;不行就**完全关机**"
|
||
f"(不是重启)再开机。",
|
||
f"⚠ Bluetooth radio is not healthy ({broken[0]}) — **every Bluetooth device "
|
||
f"disappears** while it is like this. Fix: Device Manager → Bluetooth → "
|
||
f"right-click the adapter → Enable device; if that fails, do a full **shutdown** "
|
||
f"(not a restart) and power on."))
|
||
elif text and "|" in text:
|
||
on_line(i18n.L(f"蓝牙电台:正常({text.splitlines()[0]})",
|
||
f"Bluetooth radio: OK ({text.splitlines()[0]})"))
|
||
except Exception:
|
||
pass
|
||
# Frida 残留 / UAC 会不会反复弹 —— 用户最容易被这个吓到,必须明说
|
||
fl = frida_leftovers()
|
||
if fl["services"] or fl["helpers"]:
|
||
on_line(i18n.L(
|
||
f"Frida 残留:{fl['services']} 条 frida-* 服务、{fl['helpers']} 个 helper 进程",
|
||
f"Frida leftovers: {fl['services']} frida-* service(s), "
|
||
f"{fl['helpers']} helper process(es)"))
|
||
if is_elevated():
|
||
on_line(i18n.L(" 当前已是管理员,挂载不会再弹 UAC;点「一键优化」可以顺手清掉这些残留",
|
||
" Already running as administrator, so attaching will not "
|
||
"prompt for UAC again; “One-click optimization” can clear "
|
||
"these leftovers too"))
|
||
else:
|
||
on_line(i18n.L(" ⚠ 普通权限下**每次挂载都会重新注册服务 → 每挂一次弹一次 UAC**"
|
||
"(重启客户端也算一次)",
|
||
" ⚠ Without administrator rights **every attach re-registers "
|
||
"the service → one UAC prompt per attach** (restarting the "
|
||
"client counts as one too)"))
|
||
on_line(i18n.L(" 修法:点「一键优化」—— 弹**最后一次** UAC,之后自启走最高权限计划任务,"
|
||
"再也不会弹",
|
||
" Fix: click “One-click optimization” — the **last** UAC "
|
||
"prompt, after which autostart runs from a highest-privilege "
|
||
"scheduled task and never prompts again"))
|
||
elif os.path.exists(os.path.join(VENV, "Lib", "site-packages", "frida")):
|
||
on_line(i18n.L("Frida 残留:无 ✓(没有多余的服务和 helper 进程)",
|
||
"Frida leftovers: none ✓ (no extra services or helper processes)"))
|
||
on_line(i18n.L(f"界面端口:{cfg.get('ui_port')}(只监听 127.0.0.1)",
|
||
f"UI port: {cfg.get('ui_port')} (listening on 127.0.0.1 only)"))
|
||
on_line(i18n.L(f"开机自启:{'已设置' if autostart_state() else '未设置'}",
|
||
f"Autostart: {'enabled' if autostart_state() else 'not set'}"))
|
||
if is_elevated():
|
||
on_line(i18n.L("当前权限:管理员", "Privileges: administrator"))
|
||
else:
|
||
on_line(i18n.L("当前权限:普通用户(够用。只有按键映射提示「需要提权」时,"
|
||
"才在设置页点一次「以管理员身份重启」)",
|
||
"Privileges: standard user (enough. Only when key mapping says "
|
||
"“elevation required” do you click “Restart as administrator” once "
|
||
"on the Settings page)"))
|
||
if qwen_flag_state():
|
||
on_line(i18n.L("千问「允许注入」开关:已开 ✓",
|
||
"Qianwen “allow injection” switch: on ✓"))
|
||
elif os.path.exists(QWEN_OPTOUT):
|
||
on_line(i18n.L("千问「允许注入」开关:未开 —— 你之前在「清理」里明确删过它,"
|
||
"所以程序不再自动打开",
|
||
"Qianwen “allow injection” switch: off — you explicitly deleted "
|
||
"it under “Cleanup”, so the program no longer turns it back on "
|
||
"automatically"))
|
||
on_line(i18n.L(" 想再开:设置页点「打开千问允许注入开关」(会重启千问输入法)",
|
||
" To turn it on again: click “Turn on Qianwen's allow-injection "
|
||
"switch” on the Settings page (this restarts the Qianwen IME)"))
|
||
elif not qwen_installed():
|
||
on_line(i18n.L("千问「允许注入」开关:本机没装千问输入法,不需要",
|
||
"Qianwen “allow injection” switch: the Qianwen IME is not "
|
||
"installed on this machine, so it does not apply"))
|
||
else:
|
||
on_line(i18n.L("千问「允许注入」开关:未开 —— 千问会丢弃程序发的语音快捷键",
|
||
"Qianwen “allow injection” switch: off — Qianwen will discard the "
|
||
"injected voice hotkey"))
|
||
on_line(i18n.L(" 修法:重启一次客户端就会自动打开(或点设置页那个按钮)",
|
||
" Fix: restart the client once and it turns on automatically "
|
||
"(or click that button on the Settings page)"))
|
||
out_idx, in_idx = None, None
|
||
try:
|
||
import voice as voice_mod
|
||
out_idx, in_idx = voice_mod.find_cable()
|
||
except Exception:
|
||
pass
|
||
on_line(i18n.L(f"音频端点:出口={out_idx} 输入={in_idx}",
|
||
f"Audio endpoints: output={out_idx} input={in_idx}"))
|
||
on_line(i18n.L("蓝牙通道提醒:同一时间只允许一个客户端。若另一个客户端在跑,"
|
||
"本客户端会看到「缺少 ATVV 特征」。",
|
||
"Bluetooth channel reminder: only one client at a time. If another "
|
||
"client is running, this one reports “ATVV characteristic missing”."))
|
||
return 0
|
||
|
||
|
||
def do_scan(on_line, on_data=None):
|
||
try:
|
||
import asyncio
|
||
from bleak import BleakScanner
|
||
except ImportError:
|
||
on_line(i18n.L("缺 bleak,无法扫描。先点「安装依赖」。",
|
||
"bleak is missing, cannot scan. Click “Install dependencies” first."))
|
||
return 1
|
||
on_line(i18n.L("扫描 6 秒……(遥控器空闲时不广播,先按一下遥控器键再扫)",
|
||
"Scanning for 6 seconds… (the remote does not advertise while idle — "
|
||
"press any key on it first)"))
|
||
|
||
async def run():
|
||
return await BleakScanner.discover(timeout=6.0, return_adv=True)
|
||
|
||
try:
|
||
found = asyncio.run(run())
|
||
except Exception as e:
|
||
on_line(i18n.L(f"扫描失败 {type(e).__name__}: {e}",
|
||
f"Scan failed {type(e).__name__}: {e}"))
|
||
return 1
|
||
items = []
|
||
for addr, (dev, adv) in (found or {}).items():
|
||
name = dev.name or adv.local_name or i18n.L("(未知)", "(unknown)")
|
||
on_line(f" {addr} {name} RSSI={adv.rssi}")
|
||
items.append({"address": addr, "name": name, "rssi": adv.rssi})
|
||
if not items:
|
||
on_line(i18n.L("没扫到设备。遥控器不按键时不广播,这是正常的。",
|
||
"No devices found. The remote does not advertise unless a key is pressed — "
|
||
"that is normal."))
|
||
if on_data is not None:
|
||
on_data(items)
|
||
return 0
|
||
|
||
|
||
# ============================================================ HTTP
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "VibeMote"
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
# ★★ 本地 HTTP 服务必须自己防「借刀」。界面跑在 127.0.0.1 上、没有登录态,
|
||
# 所以**任何网页**都能朝它发请求。两种攻击都是真的,各挡一层:
|
||
#
|
||
# 1) DNS rebinding:攻击者的域名解析到 127.0.0.1,浏览器就以为"同源",
|
||
# 于是能读响应、也能免预检地发 POST。→ 用 **Host 头白名单**挡:
|
||
# rebinding 时 Host 是攻击者的域名,不是 127.0.0.1/localhost。
|
||
#
|
||
# 2) 跨站简单请求(CSRF):`fetch(url, {method:'POST', mode:'no-cors',
|
||
# headers:{'Content-Type':'text/plain'}, body:'{"action":"uninstall",…}'})`
|
||
# —— text/plain 属于 CORS 安全列表里的值,**不触发预检**,浏览器会直接把请求发出去;
|
||
# 而我们的 _body() 本来不看 Content-Type,直接 json.loads ——
|
||
# 于是攻击者读不到响应,但**动作已经真的做了**(装驱动弹 UAC、删配置、提权重启)。
|
||
# → 用 **要求 Content-Type: application/json** 挡:这会强制预检,
|
||
# 而我们**不返回任何 CORS 头**,预检必然失败,请求根本发不出去。
|
||
# 再叠一层 Origin 白名单,双保险。
|
||
#
|
||
# 代价:非浏览器客户端(启动器、命令行、curl)不发 Origin,所以"没有 Origin 就放行",
|
||
# 但**必须**带对 Content-Type 才能 POST。
|
||
ALLOWED_HOSTS = ("127.0.0.1", "localhost", "[::1]", "::1")
|
||
MAX_BODY = 1 << 20 # 1 MiB:配置/动作的 body 连 1KB 都用不到
|
||
|
||
def log_message(self, *a): # 别把访问日志打到 stderr
|
||
pass
|
||
|
||
# ---- 请求来源闸门 ----
|
||
def _host_ok(self):
|
||
h = (self.headers.get("Host") or "").strip().lower()
|
||
if not h:
|
||
return False
|
||
if h.startswith("["): # IPv6 形如 [::1]:8787
|
||
host = h.split("]", 1)[0] + "]"
|
||
elif ":" in h:
|
||
host = h.split(":", 1)[0]
|
||
else:
|
||
host = h
|
||
return host in self.ALLOWED_HOSTS
|
||
|
||
def _origin_ok(self):
|
||
o = (self.headers.get("Origin") or "").strip().lower()
|
||
if not o:
|
||
return True # 非浏览器客户端不发 Origin
|
||
if o == "null":
|
||
return False # file:// 页面 / 沙箱 iframe
|
||
try:
|
||
u = urlparse(o)
|
||
except Exception:
|
||
return False
|
||
return ((u.scheme or "") in ("http", "https")
|
||
and (u.hostname or "") in ("127.0.0.1", "localhost", "::1"))
|
||
|
||
def _fetch_site_ok(self):
|
||
"""Sec-Fetch-Site 兜底:`<img src="http://127.0.0.1:8787/api/events">` 这类
|
||
**不发 Origin** 的跨站请求,只能靠这个头挡(现代浏览器都会带)。
|
||
|
||
为什么单拎出来:`/api/events` 是长连接,服务端为每条连接挂一个线程 +
|
||
一个队列等着。跨站页面用 `<img>`/EventSource 挂着不放,就能把线程耗光 ——
|
||
而 `<img>` 的请求**没有 Origin**,上面那道闸门看不到它。
|
||
没有这个头(老浏览器、curl、启动器)就放行 —— 它们不是"网页借刀"的载体。
|
||
"""
|
||
s = (self.headers.get("Sec-Fetch-Site") or "").strip().lower()
|
||
if not s:
|
||
return True
|
||
return s in ("same-origin", "none")
|
||
|
||
def _guard(self, need_json=False):
|
||
"""通过返回 True;不通过时已经回过 4xx 了。"""
|
||
if not self._host_ok():
|
||
self._json({"ok": False, "msg": i18n.L(
|
||
"拒绝:请求的 Host 不是本机地址(DNS 重绑定防护)",
|
||
"Refused: the request Host is not this machine "
|
||
"(DNS-rebinding protection)")}, 403)
|
||
return False
|
||
if not self._origin_ok():
|
||
self._json({"ok": False, "msg": i18n.L(
|
||
"拒绝:请求来自站外页面(跨站请求防护)",
|
||
"Refused: the request came from another site's page "
|
||
"(cross-site request protection)")}, 403)
|
||
return False
|
||
if not self._fetch_site_ok():
|
||
self._json({"ok": False, "msg": i18n.L(
|
||
"拒绝:请求是站外页面发起的(跨站防护)",
|
||
"Refused: the request was initiated by another site's page "
|
||
"(cross-site protection)")}, 403)
|
||
return False
|
||
if need_json:
|
||
ct = (self.headers.get("Content-Type") or "").lower()
|
||
if "application/json" not in ct:
|
||
self._json({"ok": False, "msg": i18n.L(
|
||
"拒绝:POST 必须带 Content-Type: application/json"
|
||
"(跨站简单请求防护 —— 否则任何网页都能借这个服务做事)",
|
||
"Refused: POST must send Content-Type: application/json "
|
||
"(cross-site simple-request protection — otherwise any web page "
|
||
"could drive this service)")}, 415)
|
||
return False
|
||
return True
|
||
|
||
# ---- 工具 ----
|
||
def _send(self, code, body: bytes, ctype="application/json; charset=utf-8"):
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
# 三层纯纵深防御(界面现在没有注入点,这几条是给"万一以后有"兜底的):
|
||
# · nosniff:别让浏览器把 JSON/纯文本猜成 HTML 去执行;
|
||
# · X-Frame-Options: DENY:别让别的网页把本机控制台嵌进 iframe 里
|
||
# 做点击劫持(诱导用户点「开始清理」)。Host/Origin 闸门已经会挡掉
|
||
# 跨站 iframe 的文档请求,这条是第二道。
|
||
# · Referrer-Policy:别把本机地址泄漏给外站。
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("X-Frame-Options", "DENY")
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
self.end_headers()
|
||
try:
|
||
self.wfile.write(body)
|
||
except Exception:
|
||
pass
|
||
|
||
def _json(self, obj, code=200):
|
||
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"))
|
||
|
||
def _body(self):
|
||
try:
|
||
n = int(self.headers.get("Content-Length") or 0)
|
||
except (TypeError, ValueError):
|
||
return {}
|
||
# ★ 上限:Content-Length 是客户端说了算的。不设限的话,一句
|
||
# `Content-Length: 2000000000` 就能让本进程按这个大小往内存里读 ——
|
||
# 本地服务被人(或一个卡死的页面)拖爆内存不值得。
|
||
if n < 0 or n > self.MAX_BODY:
|
||
return None
|
||
try:
|
||
return json.loads(self.rfile.read(n).decode("utf-8")) if n else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
def _file(self, path, ctype):
|
||
try:
|
||
with open(path, "rb") as f:
|
||
self._send(200, f.read(), ctype)
|
||
except FileNotFoundError:
|
||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
||
|
||
# ---- GET ----
|
||
def do_GET(self):
|
||
# ★ 原来这里**没有任何异常处理**:`/api/log?n=abc` 的 int() 会抛出去,
|
||
# 连接被直接掐断(用户在页面上看到"连接被关闭",而 socket 层只是打了条 traceback)。
|
||
# 跟 do_POST 一样兜住,并且把 n 夹在合理范围里。
|
||
try:
|
||
self._do_GET()
|
||
except Exception as e:
|
||
try:
|
||
self._json({"ok": False, "msg": f"{type(e).__name__}: {e}"}, 500)
|
||
except Exception:
|
||
pass
|
||
|
||
def _do_GET(self):
|
||
if not self._guard():
|
||
return
|
||
u = urlparse(self.path)
|
||
path, qs = u.path, parse_qs(u.query)
|
||
cfg = load_config()
|
||
if path in ("/", "/index.html"):
|
||
return self._file(os.path.join(WEB, "index.html"),
|
||
"text/html; charset=utf-8")
|
||
if path == "/api/ping":
|
||
return self._json({"ok": True})
|
||
if path == "/api/state":
|
||
return self._json(state_snapshot(cfg, ROLES))
|
||
if path == "/api/config":
|
||
return self._json({"remote_addr": cfg["remote_addr"],
|
||
"profile": cfg["profile"],
|
||
"voice": cfg["voice"], "audio": cfg["audio"],
|
||
"keys": cfg["keys"]})
|
||
if path == "/api/profiles":
|
||
import profiles
|
||
return self._json(profiles.catalog())
|
||
if path == "/api/uninstall-items":
|
||
import uninstall as un_mod
|
||
# 带上"现在在不在 / 多大":界面上会显示「(154 MB)」「(不存在)」,
|
||
# 用户才知道哪些勾了真有用 —— 离线包上最容易误会(.venv 永远不存在,
|
||
# 真正占地方的是 runtime\)。
|
||
try:
|
||
pres = un_mod.presence()
|
||
except Exception:
|
||
pres = {}
|
||
return self._json({"items": [
|
||
{"id": i[0], "label": i18n.pick(i[1]), "desc": i18n.pick(i[2]),
|
||
"admin": i[3], "recommend": i[4],
|
||
"present": pres.get(i[0], {})} for i in un_mod.ITEMS]})
|
||
if path == "/api/log":
|
||
try:
|
||
n = int((qs.get("n") or ["200"])[0])
|
||
except (TypeError, ValueError):
|
||
n = 200
|
||
n = max(1, min(n, 2000)) # 夹住:别让 ?n=-1 / ?n=999999 玩出花样
|
||
return self._json({"ok": True, "lines": BUS.tail(n)})
|
||
if path == "/api/job":
|
||
jid = (qs.get("id") or [""])[0]
|
||
with JOBS_LOCK:
|
||
j = JOBS.get(jid)
|
||
if not j:
|
||
return self._json({"ok": False,
|
||
"msg": i18n.L("没有这个任务", "No such job")})
|
||
return self._json({"ok": True, "lines": list(j["lines"]),
|
||
"done": j["done"]})
|
||
if path == "/api/events":
|
||
return self._sse()
|
||
return self._send(404, b"not found", "text/plain; charset=utf-8")
|
||
|
||
def _sse(self):
|
||
# ★ 订阅者上限。SSE 是长连接,服务端每条连接挂一个线程 + 一个队列等着;
|
||
# 没有上限时,一个反复刷新的页面(或几个被塞进页面里的 <img>/iframe)
|
||
# 就能把线程耗光。正常的界面只开 1~2 条(多标签页也就几条)。
|
||
n = BUS.subscriber_count()
|
||
if n >= BUS.MAX_SUBS:
|
||
return self._send(503, b"too many event streams", "text/plain; charset=utf-8")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("Connection", "keep-alive")
|
||
self.end_headers()
|
||
q = BUS.subscribe()
|
||
try:
|
||
self.wfile.write(b": connected\n\n")
|
||
self.wfile.flush()
|
||
last_ping = time.time()
|
||
while True:
|
||
# ★ 边推边盯着对端活没活。原来直接 `q.get(timeout=15)`:页面早关了也要等
|
||
# 满 15 秒、等下一次心跳写失败,才走到 finally 释放订阅名额 ——
|
||
# 而名额是有限的(MAX_SUBS=8)。被别人反复占满的这段时间里,
|
||
# 用户自己的界面会连不上实时流(表现为"页面不动了")。
|
||
# 所以每 0.5 秒 select 一下:对端关闭/发来数据都能立刻看出来。
|
||
try:
|
||
ready, _, _ = select.select([self.connection], [], [], 0.5)
|
||
except Exception:
|
||
break
|
||
if ready:
|
||
try:
|
||
if self.connection.recv(1, socket.MSG_PEEK) == b"":
|
||
break # 对端已关闭 → 立刻释放名额
|
||
self.connection.recv(4096) # 客户端多发了东西:读掉丢弃
|
||
except Exception:
|
||
break
|
||
try:
|
||
msg = q.get_nowait()
|
||
except queue.Empty:
|
||
if time.time() - last_ping >= 15:
|
||
self.wfile.write(b": ping\n\n")
|
||
self.wfile.flush()
|
||
last_ping = time.time()
|
||
continue
|
||
data = json.dumps(msg, ensure_ascii=False)
|
||
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
||
self.wfile.flush()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
BUS.unsubscribe(q)
|
||
|
||
# ---- POST ----
|
||
def do_POST(self):
|
||
try:
|
||
self._do_POST()
|
||
except Exception as e:
|
||
# 任何路由异常都必须变成一条 JSON 回复:直接断连接会让用户看到
|
||
# 「连接被关闭」而完全不知道发生了什么。
|
||
try:
|
||
self._json({"ok": False, "msg": f"{type(e).__name__}: {e}"})
|
||
except Exception:
|
||
pass
|
||
|
||
def _do_POST(self):
|
||
path = urlparse(self.path).path
|
||
# ★ 两道闸门放在最前面:Host/Origin 白名单 + 必须 application/json。
|
||
# 没有这两条,任何网页都能 drive-by 让本机做危险动作(装驱动弹 UAC、删配置、
|
||
# 提权重启)—— 因为 mode:'no-cors' 的 text/plain POST 不触发预检。
|
||
if not self._guard(need_json=True):
|
||
return
|
||
body = self._body()
|
||
if body is None:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
"请求体过大(上限 1MB)", "Request body too large (limit 1MB)")}, 413)
|
||
cfg = load_config()
|
||
if path == "/api/config":
|
||
if not isinstance(body, dict):
|
||
return self._json({"ok": False,
|
||
"msg": i18n.L("配置格式不对", "Bad config format")})
|
||
merged = load_config()
|
||
if "keys" in body:
|
||
merged["keys"] = body["keys"] if isinstance(body["keys"], dict) else {}
|
||
if "voice" in body and isinstance(body["voice"], dict):
|
||
merged["voice"] = body["voice"]
|
||
if "audio" in body and isinstance(body["audio"], dict):
|
||
merged["audio"] = body["audio"]
|
||
if "remote_addr" in body:
|
||
merged["remote_addr"] = body["remote_addr"]
|
||
clean = save_config(merged)
|
||
if ROLES:
|
||
ROLES.apply_config(_merge_defaults(clean))
|
||
return self._json({"ok": True, "msg": i18n.L(
|
||
f"已保存 {len(clean['keys'])} 条映射",
|
||
f"Saved {len(clean['keys'])} key mappings")})
|
||
if path == "/api/action":
|
||
return self._action(body)
|
||
return self._send(404, b"not found", "text/plain; charset=utf-8")
|
||
|
||
def _action(self, body):
|
||
act = (body or {}).get("action")
|
||
cfg = load_config()
|
||
if act == "apply-profile":
|
||
import profiles
|
||
app_id = str((body or {}).get("app") or "").strip()
|
||
ime_id = str((body or {}).get("ime") or "").strip()
|
||
keys, voice = profiles.build(app_id, ime_id)
|
||
merged = load_config()
|
||
merged["keys"] = keys
|
||
if voice:
|
||
merged["voice"] = voice
|
||
merged["profile"] = {"app": app_id, "ime": ime_id}
|
||
clean = save_config(merged)
|
||
if ROLES:
|
||
ROLES.apply_config(_merge_defaults(clean))
|
||
na = i18n.pick(profiles.APPS.get(app_id, {}).get("name", app_id))
|
||
ni = i18n.pick(profiles.IMES.get(ime_id, {}).get("name", ime_id))
|
||
BUS.log(i18n.L(f"已应用预设:{na} + {ni}", f"Preset applied: {na} + {ni}"))
|
||
return self._json({
|
||
"ok": True,
|
||
"msg": i18n.L(
|
||
f"已应用「{na} + {ni}」:{len(clean['keys'])} 个按键映射",
|
||
f"Applied “{na} + {ni}”: {len(clean['keys'])} key mappings")
|
||
+ (i18n.L(",语音键已同步", ", voice key synced") if voice
|
||
else i18n.L("(语音键保持原样)", " (voice key unchanged)")),
|
||
"data": {"keys": clean["keys"], "voice": clean["voice"]}})
|
||
if act == "uninstall":
|
||
if not os.path.exists(UNINSTALL_PY):
|
||
return self._json({"ok": False,
|
||
"msg": i18n.L("缺少 uninstall.py", "uninstall.py is missing")})
|
||
import uninstall as un_mod
|
||
picked = [x for x in ((body or {}).get("items") or [])
|
||
if x in un_mod.ITEM_IDS]
|
||
if not picked:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
"还没勾选任何要删除的项目", "No items are selected for deletion yet")})
|
||
# 先自己把角色停掉,释放蓝牙通道和 frida,省得卸载脚本杀进程时打架
|
||
if ROLES:
|
||
ROLES.stop(quiet=True)
|
||
try:
|
||
if os.path.exists(UNINSTALL_LOG):
|
||
os.remove(UNINSTALL_LOG)
|
||
except OSError:
|
||
pass
|
||
import ctypes
|
||
params = f'"{UNINSTALL_PY}" --items=' + ",".join(picked)
|
||
# 只有勾了「需要管理员」的项(计划任务 / frida 服务)才提权 ——
|
||
# 只删 __pycache__、日志这种也弹 UAC 就太烦了。
|
||
needs_admin = any(x in un_mod.ADMIN_IDS for x in picked)
|
||
verb = "open" if (is_elevated() or not needs_admin) else "runas"
|
||
# ⚠ 用 host_pythonw()(.venv 之外的解释器),**不能用 sys.executable**:
|
||
# 本进程就跑在 .venv 的 pythonw.exe 上,用它启动清理脚本会锁住自己,
|
||
# 导致勾了「删除依赖(.venv)」也永远删不掉(详见 host_pythonw 注释)。
|
||
hostpy = host_pythonw()
|
||
try:
|
||
rc = ctypes.windll.shell32.ShellExecuteW(
|
||
None, verb, hostpy, params, HERE, 0)
|
||
except Exception as e:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
f"启动清理失败:{e!r}", f"Failed to start cleanup: {e!r}")})
|
||
if rc <= 32:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
f"启动清理被拒绝(返回码 {rc})",
|
||
f"Cleanup launch was refused (return code {rc})")})
|
||
names = i18n.L("、", ", ").join(
|
||
i18n.pick(un_mod.LABELS.get(x, x)) for x in picked)
|
||
BUS.log(i18n.L(f"开始清理({len(picked)} 项):{names}",
|
||
f"Starting cleanup ({len(picked)} items): {names}"))
|
||
BUS.log(i18n.L(f"清理进程用的解释器:{hostpy}",
|
||
f"Interpreter used by the cleanup process: {hostpy}"))
|
||
if "venv" in picked and os.path.normcase(os.path.abspath(hostpy)).startswith(
|
||
os.path.normcase(os.path.abspath(VENV))):
|
||
BUS.log(i18n.L("⚠ 本机只有 .venv 里的解释器可用,.venv 可能删不干净;"
|
||
"装一个系统 Python 后重试即可。",
|
||
"⚠ The only interpreter on this machine lives in .venv, "
|
||
"so .venv may not be deleted cleanly; install a system "
|
||
"Python and try again."))
|
||
BUS.log(i18n.L("提示:清理脚本最后会结束本客户端,届时这个页面会失去连接。",
|
||
"Note: the cleanup script stops this client at the end, so "
|
||
"this page will lose its connection."))
|
||
threading.Thread(target=follow_uninstall_log, daemon=True).start()
|
||
return self._json({"ok": True, "msg": i18n.L(
|
||
f"已开始清理 {len(picked)} 项,过程见日志页",
|
||
f"Cleanup of {len(picked)} items started; follow it on the Logs page")})
|
||
if act == "start":
|
||
ok, msg = ROLES.start()
|
||
return self._json({"ok": ok, "msg": msg})
|
||
if act == "stop":
|
||
ok, msg = ROLES.stop()
|
||
return self._json({"ok": ok, "msg": msg})
|
||
if act == "restart":
|
||
ok, msg = ROLES.restart()
|
||
return self._json({"ok": ok,
|
||
"msg": i18n.L("已重启", "Restarted") if ok else msg})
|
||
if act == "check":
|
||
lines = []
|
||
do_check(lines.append)
|
||
for ln in lines:
|
||
BUS.log(ln)
|
||
return self._json({"ok": True, "msg": i18n.L(
|
||
f"体检完成({len(lines)} 项)",
|
||
f"Environment check finished ({len(lines)} checks)"),
|
||
"data": lines})
|
||
if act == "scan":
|
||
items = []
|
||
do_scan(BUS.log, items.extend)
|
||
msg = (i18n.L(f"扫到 {len(items)} 个蓝牙设备",
|
||
f"Found {len(items)} Bluetooth device(s)") if items
|
||
else i18n.L("没扫到设备(遥控器空闲时不广播,按一下它再扫)",
|
||
"No devices found (the remote does not advertise while "
|
||
"idle — press a button on it and scan again)"))
|
||
return self._json({"ok": True, "msg": msg, "data": items})
|
||
if act == "install-deps":
|
||
return self._json({"ok": True, "job": background(i18n.L(
|
||
"安装基础依赖", "Install base dependencies"), install_deps)})
|
||
if act == "install-keys":
|
||
return self._json({"ok": True, "job": background(i18n.L(
|
||
"安装按键映射依赖(frida)", "Install key-mapping dependencies (frida)"),
|
||
install_keys_deps)})
|
||
if act == "install-all":
|
||
# 新用户在横幅上点「全部安装」走这条:一次把语音和按键依赖都装好,
|
||
# 省得他装完语音发现方向键没反应、再去设置页翻第二个按钮。
|
||
# ③ 虚拟声卡也算在里头:它是语音的硬性前提,不装的话"依赖齐全"是假的。
|
||
def both(on_line):
|
||
on_line(i18n.L("① 基础依赖(语音必需,约 10MB)",
|
||
"① Base dependencies (required for voice, ~10MB)"))
|
||
rc = install_deps(on_line)
|
||
if rc != 0:
|
||
on_line(i18n.L("基础依赖没装成,先不装按键映射了。",
|
||
"Base dependencies failed; skipping the "
|
||
"key-mapping ones."))
|
||
return rc
|
||
on_line("")
|
||
on_line(i18n.L("② 按键映射依赖 frida(可选,约 130MB)",
|
||
"② Key-mapping dependency frida (optional, ~130MB)"))
|
||
rc2 = install_keys_deps(on_line)
|
||
on_line("")
|
||
if cable_state()["installed"]:
|
||
on_line(i18n.L("③ 虚拟声卡:已经在用了,跳过 ✓",
|
||
"③ Virtual audio cable: already in use, skipping ✓"))
|
||
else:
|
||
on_line(i18n.L("③ 虚拟声卡 VB-CABLE(语音的物理前提)",
|
||
"③ VB-CABLE virtual audio cable (the physical prerequisite "
|
||
"for voice)"))
|
||
install_cable(on_line)
|
||
return rc2
|
||
return self._json({"ok": True,
|
||
"job": background(i18n.L(
|
||
"安装全部依赖(语音 + 按键映射 + 虚拟声卡)",
|
||
"Install everything (voice + key mapping + virtual audio "
|
||
"cable)"),
|
||
both)})
|
||
if act == "autostart":
|
||
ok, msg = set_autostart(bool(body.get("on")))
|
||
BUS.log(msg)
|
||
return self._json({"ok": ok, "msg": msg})
|
||
if act == "qwen-inject":
|
||
# 用户点这个按钮 = 明确"我要开",顺便把之前"别再自动开"的标记清掉。
|
||
try:
|
||
if os.path.exists(QWEN_OPTOUT):
|
||
os.remove(QWEN_OPTOUT)
|
||
BUS.log(i18n.L("已清除「不再自动打开千问开关」的标记",
|
||
"Cleared the “do not auto-enable the Qianwen "
|
||
"switch” marker"))
|
||
except OSError:
|
||
pass
|
||
return self._json({"ok": True, "job": background(i18n.L(
|
||
"打开千问允许注入开关",
|
||
"Turn on Qianwen's allow-injection switch"), enable_qwen_flag)})
|
||
if act == "set-lang":
|
||
# 界面切语言时调这里:写进 config.json(只动 lang 一项),然后**立刻推一份新状态** ——
|
||
# 界面靠这条 SSE 拿到"后端换语言后"的那批文案(依赖说明、预设备注、清单项…)。
|
||
lang = i18n.save_to_config((body or {}).get("lang"))
|
||
BUS.log(i18n.L("语言已切换:", "Language switched: ")
|
||
+ ("English" if lang == "en" else "中文"))
|
||
BUS.push({"ev": "state", "state": state_snapshot(load_config(), ROLES)})
|
||
return self._json({"ok": True, "lang": lang})
|
||
if act == "install-cable":
|
||
# 语音的物理前提。包内带安装器就直接跑,没带就从官网下一份再跑;
|
||
# 两种都要提权(VB-CABLE 是内核音频驱动,它自己的安装器要求管理员)。
|
||
if cable_state()["installed"]:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
"虚拟声卡已经在用了(体检里能看到设备名),不用再装",
|
||
"The virtual audio cable is already in use (the environment check "
|
||
"shows the device name); no need to install it again")})
|
||
return self._json({"ok": True, "job": background(i18n.L(
|
||
"安装虚拟声卡(VB-CABLE)",
|
||
"Install the virtual audio cable (VB-CABLE)"), install_cable)})
|
||
if act == "setup-elevated":
|
||
if not os.path.exists(ELEVATE_PY):
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
"缺少 elevate_setup.py", "elevate_setup.py is missing")})
|
||
if is_elevated():
|
||
return self._json({"ok": True, "job": background(
|
||
i18n.L("一次性优化", "One-click optimization"),
|
||
run_elevate_setup)})
|
||
try:
|
||
if os.path.exists(ELEVATE_LOG):
|
||
os.remove(ELEVATE_LOG)
|
||
except OSError:
|
||
pass
|
||
ok, msg = relaunch_elevated_script(ELEVATE_PY)
|
||
if ok:
|
||
BUS.log(i18n.L("已请求管理员权限做一次性优化(UAC 只会弹这最后一次)…",
|
||
"Requested administrator rights for one-click "
|
||
"optimization (this is the last UAC prompt)…"))
|
||
threading.Thread(target=follow_elevate_log, daemon=True).start()
|
||
return self._json({"ok": ok, "msg": msg})
|
||
if act == "mic-selfcheck":
|
||
# 「麦克风自检」:读一秒系统默认输入设备,报峰值。用来分清"系统就没声音"
|
||
# 和"程序没读到"(用户报"所有麦克风电平都是 0"时第一件要做的事)。
|
||
try:
|
||
import voice as voice_mod
|
||
msg = voice_mod.probe_mic(1.0)
|
||
except Exception as e:
|
||
msg = i18n.L(f"麦克风自检失败:{e!r}", f"Microphone self-check failed: {e!r}")
|
||
BUS.log(msg)
|
||
return self._json({"ok": True, "msg": msg})
|
||
if act == "keys-selfcheck":
|
||
# 「按键映射自检」:把钩子里那几个原始计数铺到日志里。给"界面显示就绪、
|
||
# 按键却没反应"这种情况定位用 —— 一眼分清是挂错了宿主,还是这支遥控器的
|
||
# 报文格式跟预期不同(公司电脑上则多半是杀软把注入拦了,见下面的提示)。
|
||
kt = ROLES.keytap if ROLES else None
|
||
if kt is None:
|
||
msg = i18n.L("按键映射没在运行(先确认「运行依赖」里 frida 齐全)",
|
||
"Key mapping is not running (first check that frida is installed)")
|
||
else:
|
||
try:
|
||
msg = kt.selfcheck()
|
||
except Exception as e:
|
||
msg = i18n.L(f"自检失败:{e!r}", f"Self-check failed: {e!r}")
|
||
BUS.log(msg)
|
||
return self._json({"ok": True, "msg": msg})
|
||
if act == "reset-bt":
|
||
# 重置蓝牙电台:BLE 外设只接受一个连接,系统 HID 有时会把那条唯一的连接
|
||
# 攥住不放(遥控器从此不广播 → 程序永远连不上)。拔电池能解开,
|
||
# 这个按钮等价于"用代码关开一次蓝牙电台"。要管理员,所以走提权脚本。
|
||
if not os.path.exists(ELEVATE_PY):
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
"缺少 elevate_setup.py", "elevate_setup.py is missing")})
|
||
if is_elevated():
|
||
return self._json({"ok": True, "job": background(
|
||
i18n.L("重置蓝牙电台", "Reset Bluetooth radio"), run_reset_bt)})
|
||
try:
|
||
if os.path.exists(ELEVATE_LOG):
|
||
os.remove(ELEVATE_LOG)
|
||
except OSError:
|
||
pass
|
||
ok, msg = reset_bluetooth_radio()
|
||
if ok:
|
||
BUS.log(i18n.L("已请求管理员权限重置蓝牙电台(几秒后会自动重连)…",
|
||
"Requested administrator rights to reset the Bluetooth "
|
||
"radio (it reconnects automatically in a few seconds)…"))
|
||
threading.Thread(target=follow_elevate_log, daemon=True).start()
|
||
return self._json({"ok": ok, "msg": msg})
|
||
if act == "restart-app":
|
||
# 「重启自己」:用**启动器那套优先级**挑解释器(优先 .venv),
|
||
# 拉起同样权限的新实例(--takeover 会结束旧的我),然后退出。
|
||
import ctypes
|
||
exe = best_pythonw()
|
||
params = f'"{os.path.join(HERE, "app.py")}" --silent --takeover'
|
||
try:
|
||
if is_elevated():
|
||
rc = ctypes.windll.shell32.ShellExecuteW(None, "open", exe,
|
||
params, HERE, 0)
|
||
else:
|
||
rc = ctypes.windll.shell32.ShellExecuteW(None, "open", exe,
|
||
params, HERE, 0)
|
||
except Exception as e:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
f"重启失败:{e!r}", f"Restart failed: {e!r}")})
|
||
if rc <= 32:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
f"重启失败(返回码 {rc})",
|
||
f"Restart failed (return code {rc})")})
|
||
BUS.log(i18n.L(
|
||
f"正在重启客户端(用 {os.path.basename(exe)} 拉起新实例)…",
|
||
f"Restarting the client (launching a new instance with "
|
||
f"{os.path.basename(exe)})…"))
|
||
threading.Thread(target=lambda: (time.sleep(2.5), os._exit(0)),
|
||
daemon=True).start()
|
||
return self._json({"ok": True, "msg": i18n.L(
|
||
"正在重启,约 5 秒后刷新本页面",
|
||
"Restarting — refresh this page in about 5 seconds")})
|
||
if act == "elevate":
|
||
if is_elevated():
|
||
return self._json({"ok": True, "msg": i18n.L(
|
||
"已经是管理员身份了,不用再提权",
|
||
"Already running as administrator; no need to elevate again")})
|
||
ok, msg = relaunch_elevated()
|
||
if ok:
|
||
BUS.log(i18n.L("已请求以管理员身份重启(UAC 只弹这一次,点「是」即可)",
|
||
"Requested a restart as administrator (this is the only "
|
||
"UAC prompt — just click “Yes”)"))
|
||
# 让位给新的管理员实例:等它起来再退出,避免端口冲突
|
||
threading.Thread(target=lambda: (time.sleep(3.0), os._exit(0)),
|
||
daemon=True).start()
|
||
return self._json({"ok": ok, "msg": msg})
|
||
if act == "test-voice":
|
||
try:
|
||
import voice as voice_mod
|
||
role = getattr(ROLES, "voice", None)
|
||
spec = cfg["voice"]
|
||
vk = voice_mod.VoiceKey(spec)
|
||
if not vk.ok:
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
f"认不出目标键 {spec}", f"Unrecognized target key {spec}")})
|
||
if vk.mode == "tap":
|
||
vk.tap()
|
||
msg = i18n.L(f"已按一下 {vk.label}", f"Tapped {vk.label}")
|
||
else:
|
||
vk.press()
|
||
time.sleep(0.4)
|
||
vk.release()
|
||
msg = i18n.L(f"已按住并松开 {vk.label}",
|
||
f"Held and released {vk.label}")
|
||
BUS.log(i18n.L("测试发键:", "Test key sent: ") + msg)
|
||
return self._json({"ok": True, "msg": msg})
|
||
except Exception as e:
|
||
return self._json({"ok": False, "msg": f"{type(e).__name__}: {e}"})
|
||
if act == "clear-log":
|
||
BUS.lines.clear()
|
||
return self._json({"ok": True,
|
||
"msg": i18n.L("已清空", "Cleared")})
|
||
return self._json({"ok": False, "msg": i18n.L(
|
||
f"不认识的动作 {act}", f"Unknown action {act}")})
|
||
|
||
|
||
# ============================================================ 单实例
|
||
def already_running(port):
|
||
"""端口上已经有一个实例在听 —— 那就只开浏览器,不再起第二个进程。"""
|
||
with socket.socket() as s:
|
||
s.settimeout(0.6)
|
||
return s.connect_ex(("127.0.0.1", port)) == 0
|
||
|
||
|
||
def port_owners(port):
|
||
"""谁在监听这个端口(解析 netstat,纯标准库)。"""
|
||
try:
|
||
p = subprocess.run(["netstat", "-ano"], capture_output=True,
|
||
creationflags=CREATE_NO_WINDOW, timeout=20)
|
||
out = p.stdout.decode("utf-8", "replace")
|
||
except Exception:
|
||
return []
|
||
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 python_processes():
|
||
"""[(pid, 命令行)] —— 进程名以 python 开头的那些。一次 WMI 查询,约 0.3~1 秒。
|
||
|
||
只在"启动/接管"这条路径上用,不值得更快。
|
||
"""
|
||
ps = ("Get-CimInstance Win32_Process -Filter \"Name like 'python%'\" | "
|
||
"Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress")
|
||
try:
|
||
p = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
||
capture_output=True, timeout=25, creationflags=CREATE_NO_WINDOW)
|
||
except Exception:
|
||
return []
|
||
raw = p.stdout or b""
|
||
text = None
|
||
for enc in ("utf-8", "gbk", "mbcs"):
|
||
try:
|
||
text = raw.decode(enc)
|
||
break
|
||
except (UnicodeDecodeError, LookupError):
|
||
continue
|
||
if text is None:
|
||
text = raw.decode("utf-8", "replace")
|
||
try:
|
||
data = json.loads(text.strip() or "[]")
|
||
except Exception:
|
||
return []
|
||
if isinstance(data, dict):
|
||
data = [data]
|
||
out = []
|
||
for it in data:
|
||
try:
|
||
out.append((int(it.get("ProcessId")), str(it.get("CommandLine") or "")))
|
||
except (TypeError, ValueError, AttributeError):
|
||
continue
|
||
return out
|
||
|
||
|
||
def our_instances(procs=None):
|
||
"""**这一份程序**(同一个目录里的 app.py)正在跑的进程 PID,排除自己。
|
||
|
||
为什么要有它:原来的接管只杀"占着界面端口"的那个进程 —— 可一个刚起来、还没绑上
|
||
端口的实例不在这个名单里,于是**两个实例同时活着**:抢蓝牙(日志刷"缺少 ATVV 特征")、
|
||
界面显示的还可能是旧配置。真机上就是这么被坑的。
|
||
这里按**命令行**认人:只认命令行里带本目录 app.py 的进程;别的 python 程序 ——
|
||
包括**别的目录里的另一份 vibe-mote** —— 一概不碰。
|
||
"""
|
||
if procs is None:
|
||
procs = python_processes()
|
||
mine = os.path.normcase(os.path.join(HERE, "app.py"))
|
||
me = os.getpid()
|
||
out = []
|
||
for pid, cmd in procs:
|
||
if pid == me:
|
||
continue
|
||
if mine in os.path.normcase(cmd):
|
||
out.append(pid)
|
||
return sorted(set(out))
|
||
|
||
|
||
def take_lock():
|
||
s = socket.socket()
|
||
try:
|
||
s.bind(("127.0.0.1", LOCK_PORT))
|
||
s.listen(1)
|
||
return s
|
||
except OSError:
|
||
s.close()
|
||
return None
|
||
|
||
|
||
def _selftest():
|
||
"""`--selftest`:只验证"本目录的模块能不能 import",不启动任何角色。
|
||
|
||
为什么需要它:离线全量包用的是官方 embeddable Python,它的 `._pth` 会让
|
||
sys.path 进入隔离模式、**不含脚本目录** —— 于是 `import voice` 直接炸,
|
||
而 pythonw 没控制台,用户看到的就是"双击没反应"。
|
||
光验证"界面能起来"(`--no-start`)是**抓不到**这个的:角色不启动就不会 import
|
||
voice/keys。所以构建离线包时必须跑一次这个自检。
|
||
"""
|
||
ok = True
|
||
print(f"python: {sys.version.split()[0]} ({sys.executable})")
|
||
print(f"程序目录: {HERE}")
|
||
print(f"sys.path: {sys.path}")
|
||
for mod in ("voice", "keys", "profiles", "uninstall", "elevate_setup", "i18n"):
|
||
try:
|
||
__import__(mod)
|
||
print(f" ✓ import {mod}")
|
||
except Exception as e:
|
||
ok = False
|
||
print(f" ✗ import {mod} 失败:{type(e).__name__}: {e}")
|
||
print("自检结果:" + ("全部通过 ✓" if ok else "有失败 ✗"))
|
||
return 0 if ok else 1
|
||
|
||
|
||
def main():
|
||
_utf8_console()
|
||
ap = argparse.ArgumentParser(description="遥控器客户端")
|
||
ap.add_argument("--silent", action="store_true", help="只起服务,不开浏览器")
|
||
ap.add_argument("--port", type=int, default=None)
|
||
ap.add_argument("--no-start", action="store_true", help="只开界面,不自动启动角色")
|
||
ap.add_argument("--selftest", action="store_true",
|
||
help="只检查本目录模块能否 import(构建离线包时用),不启动任何东西")
|
||
ap.add_argument("--takeover", action="store_true",
|
||
help="接管正在运行的实例(管理员重启时用)")
|
||
# --boot:这一份是**开机自启**拉起来的(不是用户双击启动器)。
|
||
# 它专门用来关掉"自动弹 UAC 装虚拟声卡"那条路 —— 开机时用户不在电脑前,
|
||
# 弹了也没人点,还会变成每次开机都弹一次(用户完全不知道是谁要权限)。
|
||
ap.add_argument("--boot", action="store_true",
|
||
help="由开机自启拉起(不主动弹任何提权框)")
|
||
args = ap.parse_args()
|
||
|
||
if args.selftest:
|
||
return _selftest()
|
||
|
||
global ROLES
|
||
cfg = load_config()
|
||
# 语言要在启动任何会说话的东西之前定好(角色备注、启动日志都按它出)
|
||
i18n.load_from_config()
|
||
port = args.port or int(cfg.get("ui_port") or DEFAULT_PORT)
|
||
url = f"http://127.0.0.1:{port}/"
|
||
|
||
if already_running(port) and not args.takeover:
|
||
if not args.silent:
|
||
try:
|
||
os.startfile(url)
|
||
except Exception:
|
||
webbrowser.open(url)
|
||
return 0
|
||
|
||
if args.takeover:
|
||
# 接管:把**本目录的所有**旧实例结束掉再启动。只在这个显式参数下做,平时不会。
|
||
# 客户端以最高权限运行时,普通权限杀不掉它 —— 所以这一步必须由
|
||
# 「自己拉起的、同样提权的新实例」来做(见 restart-app 动作)。
|
||
#
|
||
# ★ 两处改过(真机踩坑):
|
||
# ① 不能只杀"占着端口"的那个:刚起来还没绑端口的实例不在这名单里,
|
||
# 于是两个实例同时活着抢蓝牙 → 日志刷「缺少 ATVV 特征」、界面还可能显示旧配置。
|
||
# 现在按命令行把本目录的实例全找出来一起结束。
|
||
# ② 端口被**别的程序**占着时不能杀 —— 那不是我们的客户端。说清楚再退出。
|
||
procs = python_processes()
|
||
owners = set(port_owners(port))
|
||
ours = set(our_instances(procs))
|
||
foreign = owners - ours
|
||
if foreign:
|
||
detail = "、".join(f"PID {p}" for p in sorted(foreign))
|
||
BUS.log(i18n.L(
|
||
f"界面端口 {port} 被**另一个程序**占着({detail}),它不是本目录的客户端 —— "
|
||
f"我不会去杀它。先关掉它(或改 config.json 里的 ui_port)再启动。",
|
||
f"UI port {port} is held by **another program** ({detail}) that is not this "
|
||
f"folder's client — I will not kill it. Close it first (or change ui_port in "
|
||
f"config.json), then start again."))
|
||
return 3
|
||
old = sorted(ours | owners)
|
||
if old:
|
||
BUS.log(i18n.L(f"接管模式:结束 {len(old)} 个旧实例 {old}",
|
||
f"Takeover: stopping {len(old)} old instance(s) {old}"))
|
||
for pid in old:
|
||
try:
|
||
# 不加 /T:旧实例的子进程里可能有正在跑的卸载脚本,
|
||
# 连坐杀掉它会让"卸载到一半就没了"。
|
||
subprocess.run(["taskkill", "/PID", str(pid), "/F"],
|
||
creationflags=CREATE_NO_WINDOW, timeout=30)
|
||
except Exception:
|
||
pass
|
||
for _ in range(80): # 最多等 20 秒
|
||
time.sleep(0.25)
|
||
if not already_running(port) and not our_instances():
|
||
break
|
||
left = our_instances()
|
||
if left:
|
||
BUS.log(i18n.L(
|
||
f"还有 {len(left)} 个旧实例没能结束({left})—— 先继续启动,"
|
||
f"但它们可能抢蓝牙;必要时手动结束它们。",
|
||
f"{len(left)} old instance(s) survived ({left}) — starting anyway, but they "
|
||
f"may fight over Bluetooth; kill them by hand if needed."))
|
||
|
||
lock = take_lock()
|
||
if lock is None and args.takeover:
|
||
for _ in range(80): # 最多再等 20 秒
|
||
time.sleep(0.25)
|
||
lock = take_lock()
|
||
if lock:
|
||
BUS.log(i18n.L("已接管。", "Taken over."))
|
||
break
|
||
if lock is None:
|
||
# 锁被占:多半已经有实例。端口在听就只开浏览器,否则等它起来。
|
||
for _ in range(20):
|
||
if already_running(port):
|
||
if not args.silent:
|
||
try:
|
||
os.startfile(url)
|
||
except Exception:
|
||
webbrowser.open(url)
|
||
return 0
|
||
time.sleep(0.25)
|
||
BUS.log(i18n.L("已有另一个实例在跑,但界面端口没起来。先关掉旧实例再试。",
|
||
"Another instance is already running, but its UI port is not up. "
|
||
"Close the old instance and try again."))
|
||
return 1
|
||
|
||
BUS.log("=" * 58)
|
||
BUS.log(i18n.L(" 遥控器客户端 启动", " VibeMote client starting"))
|
||
BUS.log("=" * 58)
|
||
|
||
# 上一次「退出后再删」的结果。那条路是**分离进程**干的,当时没法回报成功没有 ——
|
||
# 它会留下一个结果文件,这里读出来如实说(否则"报成功其实没删掉"会重演:
|
||
# .venv 剩个壳 → 下次启动器优先选它 → 用户看到的是"双击没反应")。
|
||
try:
|
||
if os.path.exists(DEFERRED_RESULT):
|
||
_txt = open(DEFERRED_RESULT, encoding="utf-8", errors="replace").read().strip()
|
||
os.remove(DEFERRED_RESULT)
|
||
_what = _txt[3:].strip() if len(_txt) > 3 else "?"
|
||
if _txt.upper().startswith("FAIL"):
|
||
BUS.log(i18n.L(
|
||
f"⚠ 上次清理没能删掉「{_what}」(后台命令重试 90 次后放弃了)——"
|
||
f"手动删掉它即可",
|
||
f"⚠ The previous cleanup could not delete “{_what}” (the background "
|
||
f"command gave up after 90 retries) — just delete it by hand"))
|
||
elif _txt:
|
||
BUS.log(i18n.L(f"上次清理已完成:「{_what}」已删除",
|
||
f"The previous cleanup finished: “{_what}” was deleted"))
|
||
except OSError:
|
||
pass
|
||
d = deps_state()
|
||
if not d["ok"]:
|
||
BUS.log(i18n.L("缺依赖:", "Missing dependencies: ")
|
||
+ i18n.L("、", ", ").join(d["missing"])
|
||
+ i18n.L("(界面里点「安装基础依赖」即可,不影响先打开界面)",
|
||
" (click “Install base dependencies” in the UI — the UI opens "
|
||
"without them)"))
|
||
|
||
ROLES = Roles(cfg)
|
||
# ⚠ 「千问允许注入开关」不在这里弄了 —— 它跟着**启动角色**走(Roles._ensure_qwen_flag),
|
||
# 因为在这里无条件调用会让 `--no-start`(打包自检)也去改用户 HKCU 的全局环境变量
|
||
# 并重启一次千问输入法。顺序要求(必须在角色之前)由 Roles.start() 自己保证。
|
||
if not args.no_start and d["ok"]:
|
||
ROLES.start()
|
||
elif not args.no_start:
|
||
BUS.log(i18n.L("依赖不全,先不启动语音/按键。装完依赖点「启动」。",
|
||
"Dependencies are incomplete, so voice/key mapping will not start "
|
||
"yet. Install them, then click “Start”."))
|
||
|
||
# 首次启动:语音的物理前提(虚拟声卡)没装 → **自动帮你把官方安装器拉起来**。
|
||
# 能自动的只有这一步:VB-CABLE 的安装器没有任何命令行开关,它是个必须人点
|
||
# 「Install Driver」的向导,官方还要求装完重启。所以省掉的是"自己去找那个按钮",
|
||
# 不是那一次确认。
|
||
#
|
||
# ⚠ 四个前提,缺一不可:
|
||
# · `not args.no_start`:构建/自检时不要弹 UAC。
|
||
# · ★ `not args.boot`:**开机自启拉起的那一份绝对不能弹**。自启命令是
|
||
# `wscript 启动遥控器.vbs --silent --boot` —— 要是这里不看 --boot,
|
||
# 开机 6 秒后就会凭空冒出一个 UAC,用户根本不在电脑前,而且会变成**每次开机都弹**。
|
||
# · `d["ok"]`:依赖没装(轻包首启)时 sounddevice 都导不进来,根本判断不了声卡在不在 ——
|
||
# 那种情况走界面上的「全部安装」(它含虚拟声卡这一步)。
|
||
# · ★ `not cable_ok`(= **设备用不了、注册表里也没有**):注意**不是**只看
|
||
# `cable_state()["installed"]`。只看设备的话,有两种情况会误判成"没装"从而弹 UAC:
|
||
# a) 依赖装在 .venv、本进程是系统 Python → 枚举不到设备,但驱动明明装着;
|
||
# b) **刚卸完还没重启**:设备没了,但服务键/DriverStore 还在 ——
|
||
# 这时该说的是"先重启一次",而不是再弹一次 UAC 叫人重装。
|
||
# · 标记文件:只自动试一次,用户拒了 UAC 就不再烦他。
|
||
# ★ 而且**写不进标记就不许自动弹**:写不进(Program Files / 只读盘 / ACL 过的 U 盘)
|
||
# 意味着下次启动条件依然成立 → 退化成"每次开机都弹 UAC"。宁可这次也不弹。
|
||
cable_now = cable_state()
|
||
cable_ok = bool(cable_now["installed"] or cable_now["registered"])
|
||
auto_ok = (not args.no_start and not args.boot and d["ok"]
|
||
and not cable_ok
|
||
and not os.path.exists(CABLE_AUTOTRIED))
|
||
if auto_ok:
|
||
try:
|
||
with open(CABLE_AUTOTRIED, "w", encoding="utf-8") as f:
|
||
f.write("本程序在首次启动时自动尝试安装过一次虚拟声卡(VB-CABLE)。\n"
|
||
"删掉本文件后,下次启动会再自动试一次。\n")
|
||
except OSError as e:
|
||
auto_ok = False
|
||
BUS.log(i18n.L(
|
||
f"(写不下「已自动试过」的标记:{e})—— 这次不自动弹提权框,"
|
||
f"想装虚拟声卡请在界面上点一下",
|
||
f"(Could not write the “already auto-tried” marker: {e}) — not prompting "
|
||
f"for elevation this time; click the button in the UI to install the cable"))
|
||
|
||
if auto_ok:
|
||
def _auto_cable():
|
||
# 先等界面起来:不然用户只看到一个凭空冒出来的 UAC,不知道是谁要的权限。
|
||
time.sleep(6.0)
|
||
BUS.log(i18n.L(
|
||
"首次启动:虚拟声卡(语音的物理前提)还没装,自动帮你把官方安装器拉起来…",
|
||
"First run: the virtual audio cable (the prerequisite for voice) is not "
|
||
"installed — starting its official installer for you…"))
|
||
background(i18n.L("安装虚拟声卡(VB-CABLE)",
|
||
"Install the virtual audio cable (VB-CABLE)"), install_cable)
|
||
|
||
threading.Thread(target=_auto_cable, daemon=True, name="auto-cable").start()
|
||
|
||
httpd = Server(("127.0.0.1", port), Handler)
|
||
BUS.log(i18n.L(f"界面地址:{url}(只监听本机,外网进不来)",
|
||
f"UI address: {url} (listening on this machine only; not reachable "
|
||
f"from the network)"))
|
||
|
||
# 「上次是不是正常退出」诊断:这个文件正常退出时会被删掉,所以**启动时还看到它**
|
||
# = 上一次是崩溃 / 被强杀 / 系统休眠关机没走正常关闭流程。真机上出现过"客户端
|
||
# 不知什么时候就没了、日志里什么都没有"的情况,只能靠这个把话说清楚。
|
||
try:
|
||
if os.path.exists(RUNSTATE):
|
||
try:
|
||
old = open(RUNSTATE, encoding="utf-8").read().strip()
|
||
except OSError:
|
||
old = ""
|
||
try:
|
||
start_epoch = float([l for l in old.splitlines()
|
||
if l.startswith("start_epoch=")][0].split("=", 1)[1])
|
||
except Exception:
|
||
start_epoch = 0.0
|
||
# ★ 先排除"这次是被重启/关机结束的"—— 那属于**正常**,不该吓人。
|
||
# 判据:本次开机时长 < 我们那次运行的时长 → 说明开机发生在我们启动之后。
|
||
try:
|
||
up = ctypes.windll.kernel32.GetTickCount64() / 1000.0
|
||
except Exception:
|
||
up = -1.0
|
||
rebooted = start_epoch and up >= 0 and up < (time.time() - start_epoch)
|
||
if rebooted:
|
||
BUS.log(i18n.L("上次运行是被系统重启/关机结束的(正常)",
|
||
"The previous run ended with a system restart/shutdown (normal)"))
|
||
else:
|
||
BUS.log(i18n.L(
|
||
f"上次运行没有正常退出(记录:{old.replace(chr(10), ' / ')})—— 可能是崩溃、"
|
||
f"被强杀,或系统休眠没走正常关闭流程。",
|
||
f"The previous run did not exit cleanly (record: "
|
||
f"{old.replace(chr(10), ' / ')}) — it may have crashed, been force-killed, or the "
|
||
f"system slept without a clean shutdown."))
|
||
with open(RUNSTATE, "w", encoding="utf-8") as f:
|
||
f.write(f"pid={os.getpid()}\nstart={time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||
f"start_epoch={time.time():.0f}\n")
|
||
except OSError:
|
||
pass
|
||
|
||
# 「我起来了」标记:启动器(vbs)会先删掉它、启动后再等它出现。
|
||
# 为什么用文件而不是让 vbs 去 HTTP 探测:vbs 里 CreateObject("MSXML2.XMLHTTP")
|
||
# 在某些策略/精简系统上会被禁,那样"能跑"也会被判成"启动失败"。
|
||
# 文件是 FileSystemObject 的事,vbs 本来就在用,没有额外依赖。
|
||
try:
|
||
with open(READY, "w", encoding="utf-8") as f:
|
||
f.write(f"{port}\n{os.getpid()}\n")
|
||
except OSError as e:
|
||
BUS.log(i18n.L(f"(写就绪标记失败,不影响使用:{e})",
|
||
f"(Failed to write the ready marker; not fatal: {e})"))
|
||
|
||
if not args.silent:
|
||
try:
|
||
os.startfile(url)
|
||
except Exception:
|
||
webbrowser.open(url)
|
||
|
||
try:
|
||
httpd.serve_forever()
|
||
except KeyboardInterrupt:
|
||
BUS.log(i18n.L("收到 Ctrl+C", "Received Ctrl+C"))
|
||
finally:
|
||
if ROLES:
|
||
ROLES.stop(quiet=True)
|
||
try:
|
||
os.remove(READY) # 别留着一个"我活着"的假标记
|
||
except OSError:
|
||
pass
|
||
try:
|
||
os.remove(RUNSTATE) # 正常退出 → 下次启动就不会再报"上次没正常退出"
|
||
except OSError:
|
||
pass
|
||
try:
|
||
lock.close()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
class Server(ThreadingHTTPServer):
|
||
"""浏览器关页面 / SSE 断线是常态,别把 ConnectionReset 打成一大片红字。"""
|
||
|
||
daemon_threads = True
|
||
allow_reuse_address = True
|
||
# ★ 并发上限。ThreadingHTTPServer 是**每连接一个线程、且没有上限**:本地这个服务
|
||
# 只要有人(或几个被塞进网页的 <img>/iframe/EventSource)不停开连接,
|
||
# 线程就被耗光,用户看到的界面就"卡死了"。64 条对"一个浏览器 + 偶尔 curl"
|
||
# 绰绰有余(SSE 长连接也占名额,但订阅数另有 MAX_SUBS=8 卡着)。
|
||
MAX_WORKERS = 64
|
||
|
||
def __init__(self, *a, **kw):
|
||
self._slots = threading.Semaphore(self.MAX_WORKERS)
|
||
super().__init__(*a, **kw)
|
||
|
||
def process_request(self, request, client_address):
|
||
if not self._slots.acquire(blocking=False):
|
||
# 满了就**直接关掉**这条连接,不排队、不新开线程
|
||
try:
|
||
request.close()
|
||
except Exception:
|
||
pass
|
||
return
|
||
try:
|
||
super().process_request(request, client_address)
|
||
except Exception:
|
||
self._slots.release()
|
||
raise
|
||
|
||
def shutdown_request(self, request):
|
||
try:
|
||
super().shutdown_request(request)
|
||
finally:
|
||
self._slots.release()
|
||
|
||
def handle_error(self, request, client_address):
|
||
exc = sys.exc_info()[1]
|
||
if isinstance(exc, (ConnectionResetError, ConnectionAbortedError, BrokenPipeError)):
|
||
return
|
||
BUS.trace("HTTP 处理线程", exc)
|
||
super().handle_error(request, client_address)
|
||
|
||
|
||
def _install_crash_hooks():
|
||
"""把未捕获异常写进 client.log。
|
||
|
||
为什么必须有:程序跑在 pythonw 下(没有控制台),任何异常都只会
|
||
"进程忽然没了",用户看到的就是"双击没反应"。有了这两个钩子,
|
||
至少日志里有一份完整 traceback 可以对着看。
|
||
"""
|
||
def hook(exc_type, exc, tb):
|
||
BUS.trace("主线程", exc if exc else exc_type)
|
||
sys.excepthook = hook
|
||
try:
|
||
def thook(args):
|
||
BUS.trace(f"线程 {getattr(args.thread, 'name', '?')}", args.exc_value)
|
||
threading.excepthook = thook
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
_install_crash_hooks()
|
||
sys.exit(main())
|
||
except SystemExit:
|
||
raise
|
||
except BaseException as e: # noqa: BLE001
|
||
# 连 main() 都没跑起来(比如依赖/环境问题)—— 必须留下痕迹
|
||
try:
|
||
BUS.trace("启动", e)
|
||
BUS.log(i18n.L(f"启动失败,日志在上面。日志文件:{Bus.LOG}",
|
||
f"Startup failed; the log is above. Log file: {Bus.LOG}"))
|
||
except Exception:
|
||
pass
|
||
raise
|