Files

1592 lines
84 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""卸载清理:**逐项勾选**,默认什么都不勾。
设计约定(跟界面上的清单一一对应):
· ITEMS 是唯一真相来源,界面通过 /api/uninstall-items 读它,两边不会走偏。
· **默认全部不勾** —— 没勾 = 不删。想删哪项自己勾。
· 执行顺序固定(不是按勾选顺序),因为顺序错了会失败:
先断自启 → 再杀进程 → 再删服务/文件 → 最后结束自己
(不先杀进程就删不掉 .venv:Windows 上被加载的 DLL 是锁住的)
用法:
python uninstall.py --items=venv,pycache # 只删这两项
python uninstall.py --items=all # 全删(含 config.json 和千问开关)
python uninstall.py --items=task,venv --dry-run # 只列清单,什么都不做
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import time
import winreg
HERE = os.path.dirname(os.path.abspath(__file__))
# ★ 跟 app.py 顶部同一件事:离线包用的是官方 embeddable Python,它的 `._pth` 会让
# sys.path 进入隔离模式、**不含脚本目录** —— 不自己塞一次,`import i18n` 当场炸。
if HERE not in sys.path:
sys.path.insert(0, HERE)
import i18n # noqa: E402 (必须在 sys.path 之后就位)
LOG = os.path.join(HERE, "uninstall.log")
TASK_NAME = "VibeMote"
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
QWEN_VAR = "QIANWEN_IME_UTILITY_VOICE_HOOK_ALLOW_INJECTED"
# 删掉千问开关时留的"别再自动打开"标记(app.py 的 auto_qwen_flag 认它)
QWEN_OPTOUT = os.path.join(HERE, "qwen_optout")
UI_PORT = 8787
LOCK_PORT = 49741
CREATE_NO_WINDOW = 0x08000000
# 虚拟声卡(VB-CABLE):包内带的官方安装器 + 官方注册的卸载入口
CABLE_DIR = os.path.join(HERE, "vbcable")
CABLE_SETUP_NAME = "VBCABLE_Setup_x64.exe"
CABLE_UNINST_ROOT = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
# 官方安装会留下的两处痕迹(探测用;**不看 sounddevice**,理由见 do_cable 上面那段)
CABLE_SVC_NAMES = ("VB-Cable", "VBAudioVACMME")
# 已经"安排到退出后再删"的目录(规范化绝对路径)。核实环节要认它,
# 否则会把"已安排,马上删"误报成"没删掉"。
DEFERRED = set()
# id, 界面上的名字, 说明, 是否需要管理员, 是否属于"推荐清理"
ITEMS = [
("task", i18n.pair("取消开机自启(计划任务)", "Disable autostart (scheduled task)"),
i18n.pair("删除最高权限计划任务 VibeMote",
"Delete the highest-privilege scheduled task VibeMote"), True, True),
("run", i18n.pair("取消开机自启(启动项)", "Disable autostart (Run key)"),
i18n.pair("删除 HKCU Run 里的 VibeMote",
"Delete VibeMote from the HKCU Run key"), False, True),
("kill", i18n.pair("结束客户端进程", "Stop the client process"),
i18n.pair("语音桥接 + 按键映射进程",
"Voice bridge + key-mapping process"), False, True),
("frida_helper", i18n.pair("结束 frida 助手进程", "Stop frida helper processes"),
i18n.pair("frida-helper-x86 / x86_64",
"frida-helper-x86 / x86_64"), False, True),
("frida_svc", i18n.pair("清理 frida 残留服务", "Remove leftover frida services"),
i18n.pair("HKLM 里的 frida-<pid>-x86* 服务",
"frida-<pid>-x86* services in HKLM"), True, True),
("venv", i18n.pair("删除依赖(.venv)", "Delete dependencies (.venv)"),
i18n.pair("装过的 bleak / sounddevice / cffi / frida",
"bleak / sounddevice / cffi / frida as installed"), False, True),
# ⚠ 这里**故意没有**「删除内置 Python 运行时(runtime)」这一项,别再把它加回来。
# 原因:runtime 是离线包自带的 Python 发行版(解压后 154MB),
# · 它属于**程序本体**,跟 app.py 同级,不是"装出来的东西";
# · 界面上那个「全部安装」装的是 pip 包(依赖),**装不回**一个 Python 发行版 ——
# 用户一旦手滑勾了它,这台机器就再也不是"免安装"的了,而且没法一键恢复;
# · 不删它,包的免安装能力就一直在(这正是离线包存在的意义)。
# 想彻底删干净:直接删整个程序目录即可 —— 纯绿色,系统里只有自启项和 frida 服务,
# 那两项清理工具会处理。
("pycache", i18n.pair("删除 __pycache__", "Delete __pycache__"),
i18n.pair("Python 字节码缓存", "Python bytecode cache"), False, True),
("pythontxt", i18n.pair("删除 python.txt", "Delete python.txt"),
i18n.pair("指定解释器的指向文件(换机后本来就该重建)",
"Interpreter pointer file (meant to be rebuilt on a new machine)"), False, True),
("logs", i18n.pair("删除日志", "Delete logs"),
i18n.pair("elevate_setup.log / uninstall.log",
"elevate_setup.log / uninstall.log"), False, True),
("config", i18n.pair("删除 config.json", "Delete config.json"),
i18n.pair("你的按键映射与语音设置会丢,回到默认方案",
"Your keymap and voice settings are lost, and the default preset comes back"), False, False),
("qwen", i18n.pair("删除千问「允许注入」开关", "Remove Qianwen’s “allow injection” switch"),
i18n.pair("删了千问会重新丢弃程序发的按键。程序会记住这次选择,之后不再自动打开",
"Qianwen will discard injected keystrokes again. The program remembers "
"this choice and will not turn the switch back on automatically"), False, False),
# ⚠ 这一项跟别的**不是一类**,所以默认不勾、也**不进「推荐清理」**:
# · VB-CABLE 不是本程序装的(用户点按钮装的官方驱动),而且是**系统级**的 ——
# OBS / 变声器 / 直播工具可能也在用它,删了它们就没声了;
# · 官方**没有静默卸载**:Windows「应用和功能」里那条 UninstallString 就是
# VBCABLE_Setup_x64.exe 本身、**不带任何参数**(也没有 QuietUninstallString),
# 只能点它窗口里的「Remove Driver」;
# · 官方 readme:卸载**必须重启**才算卸完。
("cable", i18n.pair("卸载虚拟声卡(VB-CABLE)",
"Uninstall the virtual audio cable (VB-CABLE)"),
i18n.pair("系统级音频驱动,OBS / 变声器可能也在用它;会弹一次 UAC,"
"还要点它窗口里的「Remove Driver」,卸完必须重启。默认不勾",
"A system-wide audio driver that OBS / voice changers may also be "
"using. One UAC prompt, then click “Remove Driver” in its window; a "
"reboot is required afterwards. Off by default"), True, False),
# ⚠ 这一项是给"装/卸卡住了"准备的**修复**动作,也默认不勾:
# · 现象:虚拟声卡"注册表里有、设备却枚举不到",装也装不上(点 Install Driver 没反应);
# · 原因:官方卸载器**永远不碰**这几处 —— 两个服务键、HKLM\SOFTWARE\VB-Audio、
# DriverStore 里的驱动包、C:\Windows\inf\oem*.inf。残留会把新一次安装挡住;
# · 用法:勾它清干净 → **重启一次** → 再点「一键安装虚拟声卡」。
# 它不弹 GUI、不需要人点,但删驱动包要管理员,所以标了 admin。
("cable_residue", i18n.pair("清除虚拟声卡残留(注册表 / 驱动包)",
"Clear virtual-audio-cable leftovers (registry / driver store)"),
i18n.pair("官方卸载器不会清的那几处:两个服务键、HKLM\\SOFTWARE\\VB-Audio、"
"DriverStore 驱动包、oem*.inf。装/卸卡住时用它;清完要重启,再重装一次",
"What the official uninstaller never removes: the two service keys, "
"HKLM\\SOFTWARE\\VB-Audio, the DriverStore package and oem*.inf. Use it "
"when installing/uninstalling gets stuck; reboot, then reinstall"), True, False),
]
ITEM_IDS = [i[0] for i in ITEMS]
ADMIN_IDS = [i[0] for i in ITEMS if i[3]]
LABELS = {i[0]: i[1] for i in ITEMS}
def _utf8_console():
"""让 print 不因为 ✓ / ✗ / 🔒 崩掉 —— **这是必须的,不是美化**。
⚠ 注意:清理脚本是被 ShellExecuteW 拉起来的独立进程,它的 stdout 不保证
是控制台、更不保证是 UTF-8。当 stdout 被重定向(管道 / 文件 / 某些宿主)时,
Python 按 locale 的 ACP 编码 —— 中文 Windows 上是 **936(GBK)** ——
而 `✓`(U+2713)、`✗`(U+2717)、`🔒` 都**不在 GBK 里**,
于是 `print` 抛 UnicodeEncodeError,脚本在打印第一行状态("管理员权限:✓")
的时候就崩了。用户看到的是"点了清理没反应 / 没删干净"。
我这台机器的控制台代码页恰好是 65001,所以本地一直没暴露 ——
是自动化测试把 stdout 接成管道之后才炸出来的。
`app.py` 里早就有同样的 `_utf8_console()`,这里补上。
"""
for s in (getattr(sys, "stdout", None), getattr(sys, "stderr", None)):
try:
s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
_utf8_console() # 模块一导入就设好,别等到 main()
def log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
try:
print(line, flush=True)
except Exception:
# 实在打不出去(被换成不支持的流)也不能让整个清理挂掉
pass
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
def _dec(b: bytes) -> str:
for enc in ("utf-8", "gbk", "mbcs"):
try:
return b.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return b.decode("utf-8", "replace")
def run(cmd, timeout=60):
try:
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
creationflags=CREATE_NO_WINDOW)
return p.returncode, _dec(p.stdout or b"") + _dec(p.stderr or b"")
except Exception as e:
return -1, repr(e)
def is_admin():
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def port_owners(port):
rc, out = run(["netstat", "-ano"])
pids = []
for line in out.splitlines():
parts = line.split()
if len(parts) >= 5 and parts[1].endswith(f":{port}") \
and parts[3].upper() == "LISTENING":
try:
pids.append(int(parts[4]))
except ValueError:
pass
return sorted(set(pids))
def rm_path(path, dry):
"""手工删除(不用 shutil.rmtree —— 它会被某些宿主劫持成"移到回收站")。"""
if not os.path.exists(path):
return False
if dry:
return True
if os.path.isdir(path):
for root, dirs, files in os.walk(path, topdown=False):
for fn in files:
try:
os.unlink(os.path.join(root, fn))
except OSError:
pass
for dn in dirs:
try:
os.rmdir(os.path.join(root, dn))
except OSError:
pass
try:
os.rmdir(path)
except OSError as e:
log(i18n.L(f" ✗ 删不掉 {os.path.basename(path)}(可能还有进程占着):{e}",
f" ✗ Cannot delete {os.path.basename(path)} (still in use?): {e}"))
return False
else:
try:
os.remove(path)
except OSError as e:
log(i18n.L(f" ✗ 删不掉 {os.path.basename(path)}:{e}",
f" ✗ Cannot delete {os.path.basename(path)}: {e}"))
return False
return True
# ---------------------------------------------------------------- 各项动作
#
# ⚠⚠ 预览(--dry-run)必须**真的什么都不做** —— 这里每个动作都要自己判断 dry。
# 反例:do_kill / do_run / do_frida_* / do_qwen 如果忘了看 dry,
# `uninstall.py --items=all --dry-run` 就会**把正在运行的客户端真的杀掉**、
# 顺手删掉 HKCU 自启项和千问开关,然后在日志里写「【预览,不会真删】」——
# 看着只是列了个清单,结果界面直接没了。
# 所以:dry 时只用**只读**手段探一下(注册表读、netstat、tasklist),然后说"会做什么"。
def do_task(dry):
if dry:
p = os.path.join(os.environ.get("WINDIR", r"C:\Windows"),
"System32", "Tasks", TASK_NAME)
exists = os.path.exists(p)
log(i18n.L(f" (预览)会删除计划任务 {TASK_NAME}:"
+ ("存在,会删掉" if exists else "本来就不存在"),
f" (preview) would delete the scheduled task {TASK_NAME}: "
+ ("it exists and would be removed" if exists
else "it does not exist anyway")))
return
rc, out = run(["schtasks", "/delete", "/tn", TASK_NAME, "/f"])
if rc == 0:
log(i18n.L(f" ✓ 已删除计划任务 {TASK_NAME}",
f" ✓ Scheduled task {TASK_NAME} deleted"))
elif any(k in out for k in ("找不到", "不存在")) or "cannot find" in out.lower():
log(i18n.L(f" - 计划任务 {TASK_NAME} 本来就不存在",
f" - Scheduled task {TASK_NAME} did not exist anyway"))
else:
log(i18n.L(f" ✗ 删计划任务失败(需要管理员):{out.strip().splitlines()[:1]}",
f" ✗ Could not delete the scheduled task (needs admin): "
f"{out.strip().splitlines()[:1]}"))
def do_run(dry):
if dry:
log(i18n.L(" (预览)会删除 HKCU Run 自启项:"
+ ("存在,会删掉" if _run_value_exists() else "本来就没有"),
" (preview) would delete the HKCU Run autostart entry: "
+ ("it exists and would be removed" if _run_value_exists()
else "it was not there anyway")))
return
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0,
winreg.KEY_SET_VALUE) as k:
winreg.DeleteValue(k, TASK_NAME)
log(i18n.L(" ✓ 已删除 HKCU Run 自启项",
" ✓ HKCU Run autostart entry deleted"))
except FileNotFoundError:
log(i18n.L(" - HKCU Run 里本来就没有自启项",
" - There was no autostart entry in HKCU Run"))
except OSError as e:
log(i18n.L(f" ✗ 删 Run 项失败:{e}", f" ✗ Could not delete the Run entry: {e}"))
def do_kill(dry):
pids = sorted(set(port_owners(UI_PORT) + port_owners(LOCK_PORT)))
me = os.getpid()
targets = [p for p in pids if p != me]
if not targets:
log(i18n.L(" - 没有别的客户端进程在跑",
" - No other client process is running"))
return
if dry:
log(i18n.L(f" (预览)会结束客户端进程:PID {'、'.join(map(str, targets))}(不会真杀)",
f" (preview) would end the client process(es): PID "
f"{', '.join(map(str, targets))} (nothing is actually killed)"))
return
for pid in targets:
# ⚠ 绝对不要加 /T!
# 本脚本是**客户端拉起来的子进程**(ShellExecuteW),而 /T 会连同整棵子进程树
# 一起杀 —— 那就把脚本自己也杀了,后面的删文件/删配置全都不会执行。
# 加了 /T 会让日志停在"结束客户端进程"这一步,
# .venv 和 config.json 都还在,
# 用户看到的就是"卸了但没卸干净、回去还不提醒装依赖"。
# 客户端没有需要连带杀的子进程(角色都在线程里),所以 /T 本来也没必要。
rc, _ = run(["taskkill", "/PID", str(pid), "/F"])
log(i18n.L(f" {'✓ 已结束' if rc == 0 else '✗ 结束失败'} PID {pid}",
f" {'✓ Ended' if rc == 0 else '✗ Failed to end'} PID {pid}"))
# ★ 顺手删掉「我活着」的就绪标记(_ready.txt)。
# 它是被杀掉的那个客户端留下的:正常退出时客户端自己会在 finally 里删它,
# 但 taskkill /F 不会给它这个机会。留着虽然无害(启动器比的是修改时间),
# 但"干净得像没装过"就谈不上了。
if targets:
try:
rp = os.path.join(HERE, "_ready.txt")
if os.path.exists(rp):
os.remove(rp)
log(i18n.L(" ✓ 已删掉残留的就绪标记 _ready.txt",
" ✓ Removed the leftover readiness marker _ready.txt"))
except OSError:
pass
def do_frida_helper(dry):
n = 0
for exe in ("frida-helper-x86.exe", "frida-helper-x86_64.exe"):
if dry:
rc, out = run(["tasklist", "/FI", f"IMAGENAME eq {exe}", "/NH"])
if exe.lower() in out.lower():
n += 1
continue
rc, _ = run(["taskkill", "/IM", exe, "/F"])
if rc == 0:
n += 1
if dry:
log(i18n.L(f" (预览)会结束 {n} 个 frida 助手进程(不会真杀)" if n
else " - 没有 frida 助手进程在跑",
f" (preview) would end {n} frida helper process(es) "
f"(nothing is actually killed)" if n
else " - No frida helper process is running"))
return
log(i18n.L(f" {'✓ 已结束 frida 助手进程' if n else '- 没有 frida 助手进程在跑'}",
f" {'✓ frida helper process(es) ended' if n
else '- No frida helper process is running'}"))
def do_frida_svc(dry):
targets = _frida_services()
if not targets:
log(i18n.L(" - 没有 frida 残留服务", " - No leftover frida service"))
return
if dry:
log(i18n.L(f" (预览)会删除 {len(targets)} 条 frida-* 服务:"
+ "、".join(targets[:5]) + ("…" if len(targets) > 5 else "")
+ "(不会真删)",
f" (preview) would delete {len(targets)} frida-* service(s): "
+ ", ".join(targets[:5]) + ("…" if len(targets) > 5 else "")
+ " (nothing is actually deleted)"))
return
ok = 0
for n in targets:
rc, _ = run(["sc.exe", "delete", n])
if rc == 0:
ok += 1
if ok:
log(i18n.L(f" ✓ 已删除 {ok}/{len(targets)} 条 frida-* 服务",
f" ✓ Deleted {ok}/{len(targets)} frida-* service(s)"))
elif not is_admin():
log(i18n.L(f" ⚠ 有 {len(targets)} 条 frida-* 服务但**需要管理员才能删** —— "
f"从界面点「清理」(会自动提权)再来一次就干净了",
f" ⚠ {len(targets)} frida-* service(s) exist but **deleting them needs "
f"admin** — click “Clean up” in the UI once more (it elevates for you) "
f"and they will be gone"))
else:
log(i18n.L(f" ✗ 已删除 0/{len(targets)} 条(可能正被占用)",
f" ✗ Deleted 0/{len(targets)} (they may be in use)"))
def _file_step(name, dry):
"""删文件/目录。对 .venv 这种可能被进程占着的,多试几次再放弃。
⚠ 光靠"多试几次"是不够的:真正让 .venv 删不掉的是**清理脚本自己就跑在
.venv\\Scripts\\pythonw.exe 上**(正在运行的镜像被 Windows 锁住)。这个根源在
app.py 那边解决 —— 它现在会用 .venv 之外的 Python 来启动本脚本。
"""
path = os.path.join(HERE, name)
if not os.path.exists(path):
log(i18n.L(f" - {name} 不存在", f" - {name} does not exist"))
return False
for attempt in range(6):
if rm_path(path, dry):
# ⚠ 预览时**不能**说"已删除" —— 整个日志别的行都写着"(预览)会…",
# 只有这里说"已删除 X",用户会以为预览真的删了东西(config.json 尤其吓人)。
log(i18n.L(f" (预览)会删除 {name}" if dry else f" ✓ 已删除 {name}",
f" (preview) would delete {name}" if dry else f" ✓ Deleted {name}"))
return True
if not os.path.exists(path):
log(i18n.L(f" ✓ 已删除 {name}", f" ✓ Deleted {name}"))
return True
if attempt < 5:
time.sleep(1.0) # 等刚被杀掉的客户端彻底释放 DLL
log(i18n.L(f" ✗ {name} 没删掉(可能有进程还占着;先关掉客户端再试一次)",
f" ✗ Could not delete {name} (something may still hold it; "
f"close the client and try again)"))
return False
def _dir_size_mb(path):
try:
n = sum(os.path.getsize(os.path.join(r, f))
for r, _, fs in os.walk(path) for f in fs)
return n / 1024 / 1024
except OSError:
return 0.0
def _dir_holds_my_interpreter(path):
"""本进程是不是就跑在这个目录里的解释器上?
Windows 不允许删除正在运行的映像文件(pythonw.exe),所以"删自己脚下的地板"
必须换个做法 —— 否则就会重演 `.venv` 那种失败形态(报成功、其实剩下个空壳)。
"""
try:
exe = os.path.normcase(os.path.abspath(sys.executable or ""))
d = os.path.normcase(os.path.abspath(path)) + os.sep
return exe.startswith(d)
except Exception:
return False
# 退出后再删:写个 bat,让它反复重试 rmdir,直到那个目录真的消失。
# 为什么借 cmd.exe:这时候唯一可用的解释器就在待删目录里,只能用系统自带的东西;
# cmd.exe 必然存在(System32),不需要管理员,也不需要重启。
#
# ⚠ 四个必须避开的坑,别再改回去:
# 1. **别用 `tasklist | find "<pid>"` 判进程死活**。装了 Git for Windows 的机器上
# PATH 里的 `find.exe` 是 GNU find,会报 "FIND: Parameter format not correct",
# 于是"等进程退出"的循环被整个跳过 → rmdir 在进程还没退出时白跑一次 → 直接放弃
# (60MB 的 runtime 就是这样没删掉的)。改成"反复重试 rmdir"最稳,
# 不依赖任何外部命令,也不用关心 PID。
# 2. **sleep 要用绝对路径的 ping**(`timeout` 需要有控制台,而我们是没有控制台的
# 分离进程;PATH 里的 ping 也可能是 Git/MSYS 的另一个版本)。
# 3. ★★ **bat 里绝对不能出现非 ASCII 的路径字面量。** cmd.exe 按"当前控制台代码页"
# 解析 .bat,而那个代码页跟 Python 的 mbcs(ACP) **不是一回事**,也跟写文件时用的
# 编码不一定一致。例如 ACP=936、OEMCP=936,但控制台代码页可能是 **65001** ——
# 于是 `encoding="mbcs"` 写出来的中文路径被 cmd 解成乱码,`rmdir` 删了个不存在的
# 路径、`if not exist 乱码` 为真 → **立刻打印成功然后退出**,真正的目录一动不动。
# 这不是"删不掉",是"删错了还报成功",比报错恶劣得多。
# 解决办法不是换编码(换哪种都可能踩另一种环境),而是**根本不放路径字面量**:
# 把 bat 放进程序目录,用 cmd 自己展开的 `%~dp0` 拼相对路径 —— 全是 ASCII。
# 4. ★ 删完要**留下结果**。上面第 3 条那种失败原来是完全静默的(日志里写着
# "已安排删除…约 2 秒",用户以为删干净了)。所以 bat 最后把结果写进
# `_deferred_result.txt`,下次启动 app.py 会读出来如实报告。
_DELAYED_RESULT = os.path.join(HERE, "_deferred_result.txt")
_AFTER_EXIT_BAT = """set /a n=0
:retry
rmdir /s /q "{target}"
if not exist "{target}" goto ok
set /a n+=1
if %n% GEQ 90 goto fail
"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul
goto retry
:ok
echo OK {label}>"{result}"
goto end
:fail
echo FAIL {label}>"{result}"
:end
(goto) 2>nul & del "%~f0"
"""
def _delete_after_exit(path):
"""安排"本进程退出后再删这个目录"。返回是否安排成功。
见上面第 3 条注释:bat 一律用 `%~dp0` + **相对**路径,不写非 ASCII 字面量。
只有目标不在程序目录下(相对路径带 `..`)时才退回"UTF-8 写 + chcp 65001"。
"""
pid = str(os.getpid())
rel = None
try:
r = os.path.relpath(path, HERE)
if not r.startswith("..") and all(ord(c) < 128 for c in r):
rel = r.replace("/", "\\")
except Exception:
rel = None
if rel:
# ★ 放在程序目录里:这样 `%~dp0` 就是程序目录(cmd 运行时展开,跟编码无关),
# 目标和结果文件都用它拼 —— bat 全文只剩 ASCII。
bat = os.path.join(HERE, f"_cleanup_after_exit_{pid}.bat")
target = f"%~dp0{rel}"
result = "%~dp0_deferred_result.txt"
head = "@echo off\n"
label = rel
else:
# 退路:目标不在程序目录下。加 chcp 65001 再用 UTF-8 写(cmd 逐行读,
# 第一行是纯 ASCII 的 chcp,后面的行就按新代码页解了)。
bat = os.path.join(os.environ.get("TEMP", HERE), f"vibe-mote-cleanup-{pid}.bat")
target = path
result = _DELAYED_RESULT
head = "@echo off\nchcp 65001 >nul\n"
label = os.path.basename(path)
cmd = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "cmd.exe")
if not os.path.exists(cmd):
cmd = "cmd"
try:
with open(bat, "w", encoding="utf-8", newline="\r\n") as f:
f.write(head + _AFTER_EXIT_BAT.format(target=target, label=label,
result=result))
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP:彻底脱离本进程,免得被一起结束
subprocess.Popen([cmd, "/c", bat], close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=0x00000008 | 0x00000200)
DEFERRED.add(os.path.normcase(os.path.abspath(path)))
return True
except Exception as e:
log(i18n.L(f" ✗ 安排后台删除失败:{e!r}",
f" ✗ Could not schedule the background delete: {e!r}"))
log(i18n.L(f" 手动删掉这个目录即可:{path}",
f" You can just delete this folder by hand: {path}"))
return False
def do_venv(dry):
"""删掉依赖目录 .venv。
⚠ 这里有"删自己脚下的地板"的可能:正常情况下清理脚本是用**系统 Python** 跑的
(`app.host_pythonw()` 排除了 .venv / runtime),但如果这台机器上**一个外部解释器
都没有**,脚本就只能跑在 .venv 里那个 pythonw.exe 上 —— 而 Windows 锁着正在运行的
镜像,谁都删不掉自己。所以:发现脚本自身的解释器就在待删目录里,就改成退出后再删。
(这个兜底机制原本是为 runtime 写的;runtime 已不再列入清理项,机制留着给 .venv 用。)
"""
path = os.path.join(HERE, ".venv")
if not os.path.exists(path):
log(i18n.L(" - .venv 不存在", " - .venv does not exist"))
return False
if _dir_holds_my_interpreter(path):
if dry:
log(i18n.L(f" (预览)会删除 .venv({_dir_size_mb(path):.0f}MB);"
f"当前脚本就跑在它上面,会安排到本进程退出后再删",
f" (preview) would delete .venv ({_dir_size_mb(path):.0f}MB); "
f"this script is running from it, so the delete would be scheduled "
f"for after this process exits"))
return True
if _delete_after_exit(path):
log(i18n.L(f" ✓ 已安排删除 .venv({_dir_size_mb(path):.0f}MB)—— "
f"本进程退出后由后台命令自动删完,约 2 秒",
f" ✓ Scheduled .venv for deletion ({_dir_size_mb(path):.0f}MB) — "
f"a background command finishes it after this process exits, ~2s"))
return False # 现在还在,刻意返回 False:别让核实环节误报"没删掉"
return _file_step(".venv", dry)
def do_pycache(dry):
_file_step("__pycache__", dry)
def do_pythontxt(dry):
_file_step("python.txt", dry)
def do_logs(dry):
for n in ("elevate_setup.log", "client.log"):
if os.path.exists(os.path.join(HERE, n)):
_file_step(n, dry)
# 自己的日志留到写完再删(这里不删 uninstall.log)
def do_config(dry):
_file_step("config.json", dry)
# ★ 顺手清掉写 config.json 时可能留下的临时文件。它是**用户配置的一份完整拷贝**
# (蓝牙地址 + 整套键位),"清理干净"却把它留在程序目录里说不过去。
# 它是配置的一部分,所以跟着 config 这一项走,而不是 logs。
for n in os.listdir(HERE) if os.path.isdir(HERE) else []:
if n.startswith("config.json.") and n.endswith(".tmp"):
_file_step(n, dry)
def do_qwen(dry):
if dry:
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as k:
winreg.QueryValueEx(k, QWEN_VAR)
log(i18n.L(f" (预览)会删除环境变量 {QWEN_VAR}:存在,会删掉"
f"(并记下「以后别再自动打开」)",
f" (preview) would delete the environment variable {QWEN_VAR}: "
f"it exists and would be removed (and “do not auto-enable again” "
f"would be recorded)"))
except OSError:
log(i18n.L(" - 千问开关本来就没设",
" - The Qianwen switch was not set anyway"))
return
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0,
winreg.KEY_SET_VALUE) as k:
winreg.DeleteValue(k, QWEN_VAR)
log(i18n.L(f" ✓ 已删除环境变量 {QWEN_VAR}",
f" ✓ Deleted the environment variable {QWEN_VAR}"))
# 留个标记:程序默认会在启动时**自动**打开这个开关(不然用户按了语音键没反应
# 还不知道为什么)。用户既然明确勾了删除,就得尊重这个选择 ——
# 否则下次启动它又冒出来,在用户眼里就是个"删不掉的 bug"。
try:
open(QWEN_OPTOUT, "w", encoding="utf-8").write(
i18n.L("用户明确删除了千问「允许注入」开关,程序不再自动打开。\n"
"想恢复:界面语音页点「打开千问『允许注入』」,或删掉本文件。\n",
"The user explicitly deleted Qianwen's “allow injection” switch; "
"the program will not auto-enable it again.\n"
"To restore: click “Enable Qianwen allow-injection” on the Voice "
"page, or delete this file.\n"))
log(i18n.L(" ✓ 已记住这次选择(以后不再自动打开;界面按钮可以随时恢复)",
" ✓ Your choice is remembered (it will not auto-enable again; "
"the UI button can turn it back on at any time)"))
except OSError as e:
log(i18n.L(f" ⚠ 没能写下「不再自动打开」的标记:{e}",
f" ⚠ Could not write the “do not auto-enable” marker: {e}"))
except FileNotFoundError:
log(i18n.L(" - 千问开关本来就没设",
" - The Qianwen switch was not set anyway"))
except OSError as e:
log(i18n.L(f" ✗ 删环境变量失败:{e}",
f" ✗ Could not delete the environment variable: {e}"))
# ---------------------------------------------------------------- 虚拟声卡(VB-CABLE)
# 语音的物理前提,但**它不是本程序装的**(用户点界面按钮装的官方驱动),
# 而且是**系统级**的:OBS / 变声器 / 直播工具可能也在用它。
#
# ⚠ 探测**不依赖 sounddevice**:清理脚本经常跑在一个没装依赖的解释器上(系统 Python),
# 那时 `import sounddevice` 直接失败,用"找不到设备"判断就会得出"没装"的**错结论** ——
# 用户会对着一台明明装了驱动的机器看到"不存在"。所以只读注册表,跟音频依赖无关。
def _cable_uninstall_exe():
"""官方注册的卸载程序路径(就是 Windows「应用和功能」里那条 UninstallString)。
官方 Pack45 在注册表里留下的那条:
HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\VB:VBCABLE {87459874-1236-4469}
DisplayName = "VBCABLE, The Virtual Audio Cable"
Publisher = VB-Audio Software
UninstallString = C:\\Program Files\\VB\\CABLE\\VBCABLE_Setup_x64.exe ← **不带参数**
(**没有** QuietUninstallString → 官方确实不提供静默卸载,只能点 GUI 按钮)
"""
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, CABLE_UNINST_ROOT) as k:
i = 0
while True:
try:
sub = winreg.EnumKey(k, i)
except OSError:
break
i += 1
if "vbcable" not in sub.lower().replace(" ", ""):
continue
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
CABLE_UNINST_ROOT + "\\" + sub) as sk:
raw = str(winreg.QueryValueEx(sk, "UninstallString")[0]).strip()
# ★ 顺便核对"这一条确实是 VB-Audio 的"。光看子键名里有没有
# "vbcable" 就提权执行它的 UninstallString 太宽了 —— 任何
# 往 HKLM\...\Uninstall 写过这种子键的软件都会被我们提权跑。
# 本机那条的 Publisher 是 "VB-Audio Software"、DisplayName 是
# "VBCABLE, The Virtual Audio Cable",以前这两个字段根本没读。
try:
pub = str(winreg.QueryValueEx(sk, "Publisher")[0])
except OSError:
pub = ""
try:
disp = str(winreg.QueryValueEx(sk, "DisplayName")[0])
except OSError:
disp = ""
blob = (pub + " " + disp).lower()
if not (("vb-audio" in blob) or ("vb-audio" in blob.replace(" ", ""))
or ("vbcable" in blob)):
continue
except OSError:
continue
# 可能是 "C:\...\setup.exe" /u 这种带参数的写法,把参数切掉。
# 也展开 %ProgramFiles% 这类变量(官方那条是绝对路径,但别人写的
# 可能是变量形式;不展开就会 os.path.exists 失败、静默退回包内那份)。
raw = os.path.expandvars(raw)
if raw.startswith('"'):
exe = raw[1:].split('"', 1)[0]
else:
for sep in (" /", " -"):
if sep in raw:
raw = raw.split(sep, 1)[0]
exe = raw.strip()
if exe and os.path.exists(exe):
return exe
except OSError:
pass
return None
def cable_installed_probe():
"""装了没有 —— 只看注册表,不 import sounddevice。"""
if _cable_uninstall_exe():
return True
for svc in CABLE_SVC_NAMES:
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Services" + "\\" + svc):
return True
except OSError:
continue
return False
# 官方 Pack45 里那个安装器的 SHA256 —— 提权运行**包内那份**之前必须核对。
# ⚠ 这个常量在 app.py 里也有一份,**是刻意重复的**:清理脚本是独立进程,
# 不能 import app.py(那会把 client.log 又打开一遍,而清理流程可能刚删掉它)。
# 这个常量在 app.py 里也有一份,**是刻意重复的**:清理脚本是独立进程,
# 不能 import app.py(那会把 client.log 又打开一遍,而清理流程可能刚删掉它)。
# ⚠ 两份必须保持同步,缺一个都会让"退回包内那份安装器"的分支直接 NameError。
CABLE_SETUP_SHA256 = "734C35DFA6D98F48782A451633CEB471166EC70D60482FD89A1123D0EE3C4F41"
def _sha256_file(path):
"""算文件的 SHA256(大写十六进制)。读不出来返回空串。
⚠ 这个函数**必须在这里也有一份**。app.py 里有一个同名的,但那是**另一个进程** ——
清理脚本是独立跑的,不能去 import app.py(那会连带把 client.log 打开,
而清理流程可能刚刚删掉它)。
"""
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 do_cable(dry):
"""卸载虚拟声卡(VB-CABLE)。
⚠ 跟别的项**不是一类**,日志里必须讲清楚,否则用户会以为"点一下就好了":
1. 它是**系统级音频驱动**,OBS / 变声器 / 直播工具可能也在用;
2. 官方**没有静默卸载** —— Windows 那条 UninstallString 就是安装器本身、不带参数,
人必须在它窗口里点一下「Remove Driver」;
3. 官方 readme:卸载**必须重启**系统才算卸完。
所以本函数只做两件事:把官方卸载器提权拉起来 + 把上面三条讲明白。
★ 它**不进 VERIFY**:用户还没点完 GUI,核实环节就会喊"没删掉" —— 那是误报。
"""
if not cable_installed_probe():
log(i18n.L(" - 没装 VB-CABLE,不用卸",
" - VB-CABLE is not installed; nothing to uninstall"))
return
if dry:
log(i18n.L(" (预览)会启动 VB-CABLE 卸载器(需要管理员;要在它窗口里点"
"「Remove Driver」,卸完必须重启)",
" (preview) would launch the VB-CABLE uninstaller (needs admin; click "
"“Remove Driver” in its window; a reboot is required afterwards)"))
return
exe = _cable_uninstall_exe() or os.path.join(CABLE_DIR, CABLE_SETUP_NAME)
if not os.path.exists(exe):
log(i18n.L(f" ✗ 找不到卸载器(官方注册的路径和包内 {CABLE_SETUP_NAME} 都不在)。"
f"可以从 Windows「应用和功能」里卸载 VBCABLE,或从官网下一份放回 vbcable\\",
f" ✗ No uninstaller found (neither the officially registered path nor the "
f"bundled {CABLE_SETUP_NAME}). Uninstall VBCABLE from Windows “Apps & "
f"features”, or download the package from the official site into vbcable\\"))
return
log(i18n.L(f" 用:{exe}", f" Using: {exe}"))
# ★ 提权之前先确认"这个 exe 值不值得提权"。两类来源,判定不同:
# · 包内的 vbcable\ —— **普通用户就能写**。低权限的恶意程序把它换成自己的 exe,
# 再等用户点这里,我们就会把它提升到管理员。所以必须核对 SHA256。
# · HKLM 注册表里那条 UninstallString 指的路径(通常是 C:\Program Files\VB\CABLE\)
# —— 那个键**只有管理员能写**,而且 Program Files 也不许普通用户改,
# 来源本身就是可信的;而且不核哈希才能容忍官方以后升级版本。
inside_pkg = os.path.normcase(os.path.abspath(exe)).startswith(
os.path.normcase(os.path.abspath(HERE)) + os.sep)
if inside_pkg:
digest = _sha256_file(exe)
if digest != CABLE_SETUP_SHA256:
log(i18n.L(f" ✗ 拒绝提权运行包内的 {CABLE_SETUP_NAME}:SHA256 与官方文件不符"
f"({digest[:16] or '读取失败'}…)。它可能被换过 —— "
f"从官网重新解压一份到 vbcable\\,或用 Windows「应用和功能」卸载 VBCABLE",
f" ✗ Refusing to run the bundled {CABLE_SETUP_NAME} elevated: its "
f"SHA256 does not match the official file "
f"({digest[:16] or 'unreadable'}…). It may have been swapped — unzip a "
f"fresh copy into vbcable\\, or uninstall VBCABLE from Windows "
f"“Apps & features”"))
return
log(i18n.L(f" ✓ 包内卸载器 SHA256 核对通过({digest[:16]}…)",
f" ✓ Bundled uninstaller SHA256 verified ({digest[:16]}…)"))
else:
log(i18n.L(" (这个路径来自 HKLM 官方卸载项,只有管理员能写,直接用它)",
" (This path comes from the official HKLM uninstall entry, which only an "
"administrator can write — using it as-is)"))
log(i18n.L(" ⚠ 虚拟声卡是**系统级驱动**:OBS / 变声器 / 直播工具可能也在用它,"
"删了它们就没声了。",
" ⚠ The virtual audio cable is a **system-wide driver**: OBS, voice changers "
"and streaming tools may be using it too — removing it silences them."))
log(i18n.L(" 用管理员权限启动官方卸载器(会弹一次 UAC,点「是」)…",
" Launching the official uninstaller elevated (one UAC prompt — click Yes)…"))
try:
import ctypes
rc = ctypes.windll.shell32.ShellExecuteW(
None, "runas", exe, "", os.path.dirname(exe), 1)
except Exception as e:
log(i18n.L(f" ✗ 启动卸载器失败:{e!r}",
f" ✗ Could not start the uninstaller: {e!r}"))
return
if rc <= 32:
log(i18n.L(f" ✗ 提权被拒绝或失败(ShellExecute 返回 {rc})",
f" ✗ Elevation denied or failed (ShellExecute returned {rc})"))
return
log(i18n.L(" ✓ 卸载器已启动:在它窗口里点「Remove Driver」,等它说驱动已移除。",
" ✓ The uninstaller has started: click “Remove Driver” in its window and wait "
"for it to report the driver has been removed."))
log(i18n.L(" ⚠ 卸完**必须重启一次**系统才算卸完(VB-Audio 官方 readme 的要求)。",
" ⚠ You **must reboot** afterwards to finalize it (VB-Audio's own readme)."))
# ★ 等用户把那个窗口关掉再往下走。为什么要等:
# cable 排在最前面(见 ORDER 的注释),后面紧跟的就是"结束客户端进程" ——
# 不等的话,用户还在向导弹窗里点,这边的控制台就已经断线了;而且"卸载"这件事
# 本来就没完成,清理流程却已经宣称走完了。等它关掉,"卸载完成"和"继续收尾"
# 在时间上才对得上。
log(i18n.L(" ⏳ 等你把卸载器那个窗口关掉再继续(最多等 15 分钟)——"
"在这之前我不会去结束客户端,免得你看着看着控制台就没了",
" ⏳ Waiting for you to close the uninstaller window before continuing "
"(up to 15 minutes) — the client will not be stopped until then, so the "
"console does not vanish under your hands"))
if _wait_for_gui(CABLE_SETUP_NAME, 15 * 60):
log(i18n.L(" ✓ 卸载器窗口已关闭,继续后面的清理",
" ✓ The uninstaller window is closed; continuing with the rest"))
else:
log(i18n.L(" (没等到卸载器窗口,或者超过 15 分钟没关 —— 不再等了,继续后面的清理。"
"驱动如果没卸干净,重新勾这一项再来一次即可)",
" (Did not see the uninstaller window, or it stayed open for over 15 "
"minutes — not waiting any longer; continuing. If the driver was not "
"fully removed, tick this item again)"))
CABLE_PROGRAM_DIR = r"C:\Program Files\VB"
CABLE_FILES_DIR = os.path.join(CABLE_PROGRAM_DIR, "CABLE")
# C:\Program Files\VB\CABLE 里**官方就这三个文件**。只有"一个不多一个不少"时才删这个目录 ——
# 多出任何东西就说明那不是我们认识的 VB-CABLE 安装,宁可不动。
CABLE_KNOWN_FILES = {"VBCABLE_Setup_x64.exe", "VBCABLE_ControlPanel.exe",
"vbMmeCable64_win10.inf"}
# ============================================================================
# ⚠⚠ 允许删的注册表路径:**写死的白名单**。
# 绝不从命令行/界面/配置里拼一个键路径进来删 —— "清注册表"最怕的就是多删一位。
# 路径只从这里来,删之前再 assert 一次在名单里。
#
# 特别注意 `HKLM\SOFTWARE\VB-Audio` 是**厂商级**键:Voicemeeter 也挂在它下面
# (Banana / Potato 会写 VB-Audio\Voicemeeter 之类)。所以这里删的是
# **`VB-Audio\Cable` 这一支**(里面只有 VBAudioCableWDM_* 这些 VB-CABLE 自己的设置),
# 且只有"删完之后厂商键空了"才顺手删那个空壳;只要还有别的子键,**一律不碰**。
# ============================================================================
CABLE_SVC_KEY_FMT = r"HKLM\SYSTEM\CurrentControlSet\Services" + "\\" + "%s"
CABLE_VENDOR_KEY = r"HKLM\SOFTWARE\VB-Audio"
CABLE_VENDOR_SUBKEY = r"Cable"
CABLE_UNINST_ROOT = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
CABLE_REG_KEYS = [CABLE_SVC_KEY_FMT % s for s in CABLE_SVC_NAMES] + \
[CABLE_VENDOR_KEY + "\\" + CABLE_VENDOR_SUBKEY]
def _reg_enum(root, path, kind="subkey"):
"""枚举 HKLM 下某个键的子键名 / 值名(打不开就返回空列表)。"""
out = []
try:
with winreg.OpenKey(root, path) as k:
n = winreg.QueryInfoKey(k)[0 if kind == "subkey" else 1]
for i in range(n):
out.append(winreg.EnumKey(k, i) if kind == "subkey"
else winreg.EnumValue(k, i)[0])
except OSError:
pass
return out
def _sc_state(name):
"""向 SCM 问这个服务的状态,形如 "4 RUNNING" / "3 STOP_PENDING";问不到返回 ""。
用来发现那种"注册表键已经删了、SCM 里还挂着"的**待删除**影子 —— 它就是
安装时报 1072 的根因(见 do_cable_residue 里那段注释)。
"""
rc, out = run(["sc", "query", name], timeout=60)
m = re.search(r"STATE\s*:\s*(\d+)\s+(\S+)", out or "")
return ("%s %s" % (m.group(1), m.group(2))) if m else ""
def _cable_uninstall_entries():
"""`HKLM\\...\\Uninstall` 下属于 VB-CABLE 的那几条。
⚠ 不能按子键名里有 "vbcable" 就删:任何软件都可能往这个根下写这种名字。
这里跟 `_cable_uninstall_exe()` 用**同一套确认**(Publisher / DisplayName 里
必须有 vb-audio / vbcable),确认不过的一律不删。返回完整 HKLM 路径。
(漏了这一处是个真缺口:清完残留、设备也删了,可 app 的 `cable_registry_present()`
仍然看到这条 → 界面永远停在"虚拟声卡现在用不了"那个横幅上,用户出不来。)
"""
out = []
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, CABLE_UNINST_ROOT) as root:
n = winreg.QueryInfoKey(root)[0]
for i in range(n):
try:
sub = winreg.EnumKey(root, i)
except OSError:
break
if "vbcable" not in sub.lower().replace(" ", ""):
continue
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
CABLE_UNINST_ROOT + "\\" + sub) as sk:
blob = ""
for v in ("Publisher", "DisplayName"):
try:
blob += " " + str(winreg.QueryValueEx(sk, v)[0])
except OSError:
pass
except OSError:
continue
# ★ 必须看到 **vb-audio**(厂商名),不能只看 "vbcable":
# DisplayName 里带 vbcable 字样的第三方条目完全可能存在,
# 只按它判断就会去删别人的卸载项。官方那条的 Publisher 是
# "VB-Audio Software",所以这个判据既准又够。
# (名字过滤只是廉价的预筛,真正的判据在这一行。)
low = blob.lower().replace(" ", "")
if "vb-audio" in low:
out.append("HKLM\\" + CABLE_UNINST_ROOT + "\\" + sub)
except OSError:
pass
return out
def _cable_svc_key_exists(name):
"""服务键在不在(单独抽出来:测试要能替换掉"真实注册表"这一层)。"""
try:
winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Services" + "\\" + name).Close()
return True
except OSError:
return False
def _cable_svc_guard(name):
"""删服务键之前问一句"这真的是 VB-CABLE 的服务吗"。返回 (能不能删, 理由)。
· 名字必须在 CABLE_SVC_NAMES 白名单里(调用方已保证,这里再兜一次);
· ImagePath / DisplayName / Description 里出现 **voicemeeter** → 绝不删(那是别人的声卡);
· ImagePath 有内容 → 必须指向 VB-CABLE 的驱动(vbaudio_cable64 / vbmmecable64);
· ImagePath 为空(半卸后 SCM 都不认的孤儿键)→ 允许删,日志里说明白。
"""
if name not in CABLE_SVC_NAMES:
return False, "不在白名单里"
path = r"SYSTEM\CurrentControlSet\Services" + "\\" + name
vals = {}
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as k:
for v in ("ImagePath", "DisplayName", "Description"):
try:
vals[v] = str(winreg.QueryValueEx(k, v)[0])
except OSError:
vals[v] = ""
except OSError:
return False, "键不存在"
if "voicemeeter" in " ".join(vals.values()).lower():
return False, "里面有 Voicemeeter 字样,是别人家的声卡,不删"
ip = vals.get("ImagePath", "").strip()
if not ip:
return True, "孤儿服务键(ImagePath 为空、SCM 已经不认它)"
low = ip.lower()
if "vbaudio_cable64" in low or "vbmmecable64" in low:
return True, "ImagePath 指向 VB-CABLE 驱动"
return False, "ImagePath 不指向 VB-CABLE 驱动(%s…),不删" % ip[:40]
def _cable_driver_packages():
r"""从 `pnputil /enum-drivers` 里挑出 VB-CABLE 的驱动包名。
⚠ 不能写死 `oem634.inf`:那只在"已发布"时存在,而我们要处理的恰恰是
"只剩 staged、发布名已经没了"的半卸状态 —— 写死的话命令会报
"没有匹配的驱动包",而 DriverStore 那份**原地留着**,脚本看上去却跑完了。
返回 Published Name 和 Original Name 两种写法,逐个试(pnputil 两种都收)。
⚠ 判据是 **vbmmecable / vbaudio_cable** 这些 VB-CABLE 专属字样,
不能只看 "Provider 是 VB-Audio" —— Voicemeeter 的 Provider 也是 VB-Audio,
按厂商匹配会把人家的驱动包删掉。
⚠ 名字**不能按英文标签解析**:pnputil 的输出是**本地化**的,中文系统上打的是
「发布名称 / 原始名称」而不是 "Published Name / Original Name"。所以在块里抓
`.inf` 结尾的 token —— 跟语言无关。
⚠⚠ 分块必须容忍 CRLF:块之间是 `\r\n\r\n`,用 `split("\n\n")` **切不开**
(两个 \n 中间夹着 \r),于是整份清单被当成一个块,里面"恰好"含 vbmmecable
就通过筛选 —— 结果把**全机器几百个驱动包**都收了进来。干跑预览一眼看出来
(列了几百个 .inf),但真跑就是灾难。现在用 `re.split(r"\r?\n\s*\r?\n")`,
并且**加了一条数量上限**:VB-CABLE 只有 1 个驱动包,匹配到超过 4 个
一律视为解析出错、直接返回空(宁可不删,也不乱删)。
"""
rc, out = run(["pnputil", "/enum-drivers"], timeout=90)
names = []
for blk in re.split(r"\r?\n\s*\r?\n", out):
if "vbmmecable" not in blk.lower() and "vbaudio_cable" not in blk.lower():
continue
for m in re.finditer(r"[^\s\\/:]+\.inf", blk, re.I):
names.append(m.group(0).strip())
seen, uniq = set(), []
for n in names:
if n.lower() not in seen:
seen.add(n.lower())
uniq.append(n)
if len(uniq) > 4:
log(i18n.L(f" ✗ 解析 pnputil 输出时匹配到 {len(uniq)} 个驱动包(正常只有 1 个),"
f"判定为解析出错 —— **一个都不删**,请把这个情况反馈给作者",
f" ✗ Parsing pnputil matched {len(uniq)} driver packages (normally just 1); "
f"treating it as a parse error — **deleting nothing**. Please report this"))
return []
return uniq
def _cable_residue_state():
"""官方卸载器**永远不碰**的那几处,逐项探一遍(给日志和"清完没清完"用)。"""
out = []
for svc in CABLE_SVC_NAMES:
if not _cable_svc_key_exists(svc):
# ★ 注册表键没了、SCM 里还挂着 = "待删除"影子。它**不会**自己消失,
# 而且会让下一次安装报 1072 —— 必须列进残留清单,不能当"干净"。
st = _sc_state(svc)
if st:
out.append("SCM 待删除 %s(%s)" % (svc, st))
continue
ok, why = _cable_svc_guard(svc)
out.append("service %s%s" % (svc, "" if ok else "(不删:%s)" % why))
subs = _reg_enum(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VB-Audio", "subkey")
if CABLE_VENDOR_SUBKEY in subs:
out.append(r"HKLM\SOFTWARE\VB-Audio\Cable")
others = [s for s in subs if s != CABLE_VENDOR_SUBKEY]
if others:
out.append(r"HKLM\SOFTWARE\VB-Audio 下还有别的(" + ",".join(others) + ")不动")
out += ["driver " + n for n in _cable_driver_packages()]
for k in _cable_uninstall_entries():
out.append(k + "(应用和功能里那条)")
if os.path.isdir(CABLE_FILES_DIR):
try:
files = sorted(os.listdir(CABLE_FILES_DIR))
except OSError:
files = []
if files and set(files) <= CABLE_KNOWN_FILES:
out.append(CABLE_FILES_DIR + "(官方那三个文件)")
else:
out.append(CABLE_FILES_DIR + "(内容不是官方那三个,不动)")
return out
def do_cable_residue(dry):
"""清除虚拟声卡残留(`cable_residue`)。
为什么需要它:官方卸载器只会删掉**设备**和「应用和功能」里那条,
服务键、`HKLM\\SOFTWARE\\VB-Audio\\Cable`、DriverStore 驱动包、`oem*.inf` 全都留着。
这些残留会把**下一次安装**挡住(现象:注册表里有、设备枚举不出来,
在官方安装器里点 Install Driver 没反应)。清干净 → 重启 → 再装一次就好。
★★ 安全底线(用户专门叮嘱过"清注册表一定注意,别清错了")★★
· 能删的注册表路径是**写死的白名单** `CABLE_REG_KEYS`,删之前 assert 一次,
路径**绝不**从命令行/界面/配置里来;
· `HKLM\\SOFTWARE\\VB-Audio` 是**厂商级**键,Voicemeeter 也挂在下面 ——
只删 `VB-Audio\\Cable` 这一支;只有"删完之后厂商键空了"才顺手删空壳,
底下还有别的子键就**一律不碰**;
· 服务键删之前用 `_cable_svc_guard()` 验一遍(有 Voicemeeter 字样、
或 ImagePath 不指向 VB-CABLE 驱动 → 拒绝删);
· 驱动包按 **vbmmecable / vbaudio_cable** 字样匹配,不看"Provider 是 VB-Audio"
(Voicemeeter 的 Provider 也是 VB-Audio);
· `C:\\Program Files\\VB\\CABLE` 只在"文件恰好是官方那三个"时才删,
`VB\\Voicemeeter` 之类绝不碰。
它**不进 VERIFY**(不是"勾了就该消失"的东西),也不需要人点 GUI。
"""
before = _cable_residue_state()
if not before:
log(i18n.L(" - 没有 VB-CABLE 残留,不用清",
" - No VB-CABLE leftovers found; nothing to clear"))
return
log(i18n.L(" 发现残留:" + "、".join(before),
" Leftovers found: " + ", ".join(before)))
if dry:
log(i18n.L(" (预览)只会删这几处(白名单写死,别处一律不动):"
"服务键 VB-Cable / VBAudioVACMME、HKLM\\SOFTWARE\\VB-Audio\\Cable、"
"DriverStore 里的 VB-CABLE 驱动包、C:\\Program Files\\VB\\CABLE"
"(仅当里面恰好是官方那三个文件)。"
"**Voicemeeter 的键和目录一个都不会碰。** 清完要重启再重装",
" (preview) would only delete these (hard-coded allowlist, nothing else): the "
"service keys VB-Cable / VBAudioVACMME, HKLM\\SOFTWARE\\VB-Audio\\Cable, the "
"VB-CABLE driver packages in the DriverStore, and C:\\Program Files\\VB\\CABLE "
"(only when it holds exactly the three official files). **Voicemeeter keys and "
"folders are never touched.** Reboot and reinstall afterwards"))
return
# ---------------------------------------------------------------- 1) 服务键
# ★★ 顺序必须是 **先 stop、再 delete**。为什么(实测踩到,代价是用户录视频时卡住):
# 一个**正在运行的内核驱动**被 `sc delete` 之后,SCM 会把它标成"待删除"
# (ERROR_SERVICE_MARKED_FOR_DELETE = 1072),而且这个状态**在驱动卸载前一直不消**。
# 用户随后去跑官方安装器,安装器要创建同名服务 → 直接弹
# 「Unknown Error 1072 … Be sure to run this program in Administrator mode」——
# 那句提示是**误导**(他本来就是管理员跑的),真因是我们留下的"待删除"服务。
# 先 stop 能让驱动尽快卸载;卸不掉(STOP_PENDING 卡住)就只剩重启一条路,
# 所以这里要**明确告诉用户"必须重启"**,而不是让他对着 1072 干瞪眼。
session_restart_needed = False
for svc in CABLE_SVC_NAMES:
if not _cable_svc_key_exists(svc):
# 注册表键没了,但 SCM 里可能还留着"待删除"的影子 —— 探测一下。
# 这个影子**删不掉**(sc delete 会回 1072),只能靠重启清掉,
# 所以这里只报状态并让用户知道"必须重启",别让他反复点安装。
st = _sc_state(svc)
if st:
log(i18n.L(f" ⚠ SCM 里还留着 {svc}(状态 {st},注册表键已删)—— "
f"这就是「待删除」状态,**必须重启一次**才能创建同名服务",
f" ⚠ {svc} is still known to the SCM (state {st}, registry key "
f"already gone) — that is the “marked for deletion” state; "
f"**a reboot is required** before the same service can be created"))
session_restart_needed = True
continue
ok, why = _cable_svc_guard(svc)
if not ok:
log(i18n.L(f" · 跳过服务 {svc}:{why}", f" · Skipping service {svc}: {why}"))
continue
run(["sc", "stop", svc], timeout=90) # ★ 先停,别直接 delete
time.sleep(1.0)
st = _sc_state(svc)
if st and "STOP" in st.upper() and "STOPPED" not in st.upper():
log(i18n.L(f" ⚠ {svc} 停不下来({st})—— 重启一次系统后它就自然没了",
f" ⚠ {svc} will not stop ({st}) — a reboot will clear it"))
session_restart_needed = True
run(["sc", "delete", svc], timeout=60)
if _cable_svc_key_exists(svc): # SCM 不认的孤儿键 sc delete 删不掉,得直接删键
hklm_path = CABLE_SVC_KEY_FMT % svc
assert hklm_path in CABLE_REG_KEYS, hklm_path # ★ 白名单守卫
run(["reg", "delete", hklm_path, "/f"], timeout=60)
log(i18n.L(f" ✓ 服务键 {svc} 已删({why})",
f" ✓ Service key {svc} removed ({why})"))
# ---------------------------------------------------------------- 2) 厂商键(只删 Cable 那一支)
subs = _reg_enum(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VB-Audio", "subkey")
if CABLE_VENDOR_SUBKEY in subs:
key = CABLE_VENDOR_KEY + "\\" + CABLE_VENDOR_SUBKEY
assert key in CABLE_REG_KEYS, key # ★ 白名单守卫
run(["reg", "delete", key, "/f"], timeout=60)
log(i18n.L(r" ✓ HKLM\SOFTWARE\VB-Audio\Cable 已删(只有 VB-CABLE 自己的设置)",
r" ✓ HKLM\SOFTWARE\VB-Audio\Cable removed (only VB-CABLE's own settings)"))
others = [s for s in subs if s != CABLE_VENDOR_SUBKEY]
if others:
log(i18n.L(r" · HKLM\SOFTWARE\VB-Audio 下还有 " + "、".join(others)
+ "(Voicemeeter 之类),**一个都没动**",
r" · HKLM\SOFTWARE\VB-Audio still has " + ", ".join(others)
+ " (Voicemeeter etc.) — **left untouched**"))
elif _reg_enum(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VB-Audio", "subkey") == [] \
and _reg_enum(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VB-Audio", "value") == []:
run(["reg", "delete", CABLE_VENDOR_KEY, "/f"], timeout=60)
log(i18n.L(r" ✓ HKLM\SOFTWARE\VB-Audio 已经空了,顺手删掉这个空壳键",
r" ✓ HKLM\SOFTWARE\VB-Audio is now empty; removed the empty shell key"))
# ------------------------------------------------- 2.5) 「应用和功能」里那条卸载项
# ⚠ 必须删:app 的 cable_registry_present() 会看到它 → 界面一直停在
# "虚拟声卡现在用不了"那个横幅上,用户怎么清都出不来。
# 只删**验明正身过**的那几条(名字含 vbcable + Publisher/DisplayName 是 VB-Audio)。
for key in _cable_uninstall_entries():
assert key.startswith("HKLM\\" + CABLE_UNINST_ROOT + "\\"), key # ★ 只许在这个根下
run(["reg", "delete", key, "/f"], timeout=60)
log(i18n.L(" ✓ 删掉「应用和功能」里那条:" + key.rsplit("\\", 1)[-1],
" ✓ Removed the “Apps & features” entry: " + key.rsplit("\\", 1)[-1]))
# ---------------------------------------------------------------- 3) 驱动包 # 一个包会有两个名字(发布的 oemNN.inf + 原始的 vbmmecable64_win10.inf),
# pnputil 只认**发布名**。所以先试发布名,成功就停;两个都失败才报错 ——
# 不然日志里会出现一行没意义的"✗ 指定的文件不是安装的 OEM INF"。
names = _cable_driver_packages()
names.sort(key=lambda n: (not n.lower().startswith("oem"), n.lower()))
for name in names:
rc, out = run(["pnputil", "/delete-driver", name, "/uninstall", "/force"], timeout=180)
tail = (out or "").strip().splitlines()
if rc == 0:
log(i18n.L(f" ✓ 驱动包已删({name})",
f" ✓ Driver package removed ({name})"))
break
log(i18n.L(f" · 驱动包 {name} 这个写法删不掉({tail[-1][:60] if tail else '?'}),换下一个名字",
f" · Driver package {name} could not be removed this way "
f"({tail[-1][:60] if tail else '?'}); trying the other name"))
else:
if names:
log(i18n.L(" ✗ 驱动包两种写法都没删掉(多半是正被占用或权限不足)",
" ✗ Neither name worked (usually in use, or not enough rights)"))
# ---------------------------------------------------------------- 4) 程序目录(只认官方那三个文件)
if os.path.isdir(CABLE_FILES_DIR):
try:
files = sorted(os.listdir(CABLE_FILES_DIR))
except OSError:
files = []
if files and set(files) <= CABLE_KNOWN_FILES:
for fn in files:
try:
os.remove(os.path.join(CABLE_FILES_DIR, fn))
except OSError as e:
log(i18n.L(f" ✗ 删不掉 {fn}:{e}", f" ✗ Could not delete {fn}: {e}"))
try:
os.rmdir(CABLE_FILES_DIR)
log(i18n.L(r" ✓ 删掉 C:\Program Files\VB\CABLE(里面恰好是官方那三个文件)",
r" ✓ Removed C:\Program Files\VB\CABLE (exactly the three official files)"))
except OSError:
pass
elif files:
log(i18n.L(r" · C:\Program Files\VB\CABLE 里还有别的东西("
+ "、".join(files[:5]) + "),**没动它**",
r" · C:\Program Files\VB\CABLE holds other files ("
+ ", ".join(files[:5]) + "); **left alone**"))
if os.path.isdir(CABLE_PROGRAM_DIR):
try:
left = os.listdir(CABLE_PROGRAM_DIR)
except OSError:
left = []
if not left:
try:
os.rmdir(CABLE_PROGRAM_DIR)
log(i18n.L(r" ✓ C:\Program Files\VB 空了,删掉",
r" ✓ C:\Program Files\VB is empty; removed"))
except OSError:
pass
else:
log(i18n.L(r" · C:\Program Files\VB 下还有 " + "、".join(left)
+ "(Voicemeeter 之类),**没动**",
r" · C:\Program Files\VB still has " + ", ".join(left)
+ " (Voicemeeter etc.); **left alone**"))
after = _cable_residue_state()
if after:
log(i18n.L(" ⚠ 还剩:" + "、".join(after) + "(多半是权限不足或驱动包正被占用)",
" ⚠ Still present: " + ", ".join(after)
+ " (usually a permissions or in-use problem)"))
else:
log(i18n.L(" ✓ 残留已清干净。**现在重启一次系统**,然后点「一键安装虚拟声卡」重新装",
" ✓ Leftovers cleared. **Reboot now**, then click “install the virtual audio "
"cable” again"))
if session_restart_needed:
log(i18n.L(" ⚠⚠ 这次**必须重启**:还有个服务停在「待删除 / 停不下来」的状态,"
"不重启的话官方安装器会报 `Unknown Error 1072`(它提示「请用管理员运行」是误导,"
"你本来就是管理员)。重启后再装即可。",
" ⚠⚠ **A reboot is required this time**: a service is stuck in the "
"“marked for deletion / will not stop” state. Without a reboot the official "
"installer fails with `Unknown Error 1072` (its “run as administrator” hint is "
"misleading — you already are). Reboot, then install again."))
log(i18n.L(" (重启这一步省不掉:Windows 要重启后才会重新枚举音频设备)",
" (The reboot cannot be skipped: Windows only re-enumerates audio devices "
"after a restart)"))
def _wait_for_gui(image_name, seconds=900):
"""等一个 GUI 进程消失(用户把窗口关了)。返回 True = 等到了。
用 `_proc_seen()`(tasklist /FI,不带管道、不碰 GNU find —— 那个坑见文件头)。
先确认它真的起来了再等:用户拒了 UAC 的话进程根本不存在,那就别在这儿空等 15 分钟。
"""
try:
time.sleep(1.5) # 给它一点时间起来
if not _proc_seen(image_name):
return False
deadline = time.time() + seconds
while time.time() < deadline:
time.sleep(2.0)
if not _proc_seen(image_name):
return True
except Exception:
return False
return False
ACTIONS = {
"task": do_task,
"run": do_run,
"kill": do_kill,
"frida_helper": do_frida_helper,
"frida_svc": do_frida_svc,
"venv": do_venv,
"pycache": do_pycache,
"pythontxt": do_pythontxt,
"logs": do_logs,
"config": do_config,
"qwen": do_qwen,
"cable": do_cable,
"cable_residue": do_cable_residue,
}
# 执行顺序(固定)。★ cable **必须排在最前面**,别改回最后:
# · `kill` / `venv` 会把客户端结束掉 → 控制台当场断线;
# · cable 是唯一一个"要人在窗口里点一下、再重启"的项,说明必须让用户看得见;
# · 所以顺序是:先把交互式的声卡卸载做完(并且**等它窗口关掉**才继续),
# 再去断自启、杀进程、删文件。
# (改之前是反的:kill 在第 3 步,cable 在第 12 步 —— 用户勾「推荐清理」+ 声卡时,
# 控制台早没了,等向导弹出来他根本看不到"要点 Remove Driver、卸完要重启"那几行。)
ORDER = ["cable", "cable_residue", "task", "run", "frida_helper", "frida_svc",
"kill", "venv", "pycache", "pythontxt", "logs", "config", "qwen"]
# 这几个勾了就会把客户端也结束掉(界面要据此告诉用户"页面马上失联"):
# · kill —— 就是要结束它
# · venv —— 不先结束进程,DLL 被锁着删不掉
KILLS_CLIENT = {"kill", "venv"}
RESULT = os.path.join(HERE, "_uninstall_result.json")
def _notify_done(items, stuck=()):
"""把结果写成一个文件,给主程序读。
为什么用文件:本脚本是独立进程,而且**最后一步就是把主程序杀掉** ——
没法再打电话回去告诉它"我干完了"。主程序那边有线程在跟 uninstall.log,
看到 DONE 就会读这个文件,然后把结果推成 SSE 事件告诉界面。
"""
try:
import json
# kills_client:这次清理会不会把界面(客户端)也结束掉 —— 主程序要靠这个
# 决定跟用户说"页面马上失去连接"还是"客户端还在,可以继续用"。
# ⚠ 必须跟 main() 最后那步真正执行 kill 的条件**完全一致**,否则界面会撒谎。
kills = bool(KILLS_CLIENT & set(items))
with open(RESULT, "w", encoding="utf-8") as f:
json.dump({"count": len(items),
"items": [i18n.pick(LABELS.get(i, i)) for i in items],
"failed": [i18n.pick(LABELS.get(i, i)) for i in stuck],
"kills_client": kills,
"time": time.strftime("%H:%M:%S")}, f, ensure_ascii=False)
except Exception:
pass
# 清理完要**回头核实**的项:勾了、但实际还在 = 没删掉。
# 为什么要核实而不是信执行结果:.venv 会出现"报成功、其实还剩一个
# Scripts\pythonw.exe 的壳",日志最后还写「无(干净)」—— 用户下次双击启动器,
# 启动器选中这个壳,界面根本打不开,人也完全不知道发生了什么。
VERIFY = [
("task", "计划任务 VibeMote", lambda: os.path.exists(os.path.join(
os.environ.get("WINDIR", r"C:\Windows"), "System32", "Tasks", TASK_NAME))),
("run", "启动项 VibeMote", lambda: _run_value_exists()),
("venv", ".venv", lambda: os.path.exists(os.path.join(HERE, ".venv"))),
("pycache", "__pycache__", lambda: os.path.exists(os.path.join(HERE, "__pycache__"))),
("pythontxt", "python.txt", lambda: os.path.exists(os.path.join(HERE, "python.txt"))),
("logs", "elevate_setup.log", lambda: os.path.exists(os.path.join(HERE, "elevate_setup.log"))),
("config", "config.json", lambda: os.path.exists(os.path.join(HERE, "config.json"))),
("frida_svc", "frida-* 残留服务", lambda: bool(_frida_services())),
]
# 不勾就会留着的东西(正常,只是告诉用户"这些还在")
KEEPABLE = [("venv", ".venv"),
("config", "config.json"), ("pythontxt", "python.txt")]
def _run_value_exists():
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as k:
winreg.QueryValueEx(k, TASK_NAME)
return True
except OSError:
return False
def _frida_services():
names = []
try:
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-"):
names.append(n)
i += 1
except OSError:
pass
return names
def check_stuck(items):
"""返回 [item_id]:勾了却依然存在的项。"""
stuck = []
for iid, _name, probe in VERIFY:
if iid not in items:
continue
try:
if probe():
stuck.append(iid)
except Exception:
pass
return stuck
def presence():
"""每一项现在"在不在、多大" —— 界面要拿它提示用户。
为什么需要:不提示的话,用户对着一堆本来就不存在的项瞎勾
(比如轻包上「删除依赖(.venv)」永远是"不存在")。
"""
out = {}
for iid, _name, probe in VERIFY:
try:
out[iid] = {"exists": bool(probe())}
except Exception:
out[iid] = {"exists": None}
p = os.path.join(HERE, ".venv")
if os.path.exists(p):
out["venv"]["size_mb"] = round(_dir_size_mb(p), 1)
# VERIFY 里没有的项,单独探一下
try:
out["kill"] = {"exists": bool(port_owners(UI_PORT))}
except Exception:
out["kill"] = {"exists": None}
try:
out["frida_helper"] = {"exists": any(
_proc_seen(e) for e in ("frida-helper-x86.exe", "frida-helper-x86_64.exe"))}
except Exception:
out["frida_helper"] = {"exists": None}
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as k:
winreg.QueryValueEx(k, QWEN_VAR)
out["qwen"] = {"exists": True}
except OSError:
out["qwen"] = {"exists": False}
# 虚拟声卡:**故意不在 VERIFY 里**(它不是"勾了就该消失"的东西 —— 卸载要人点 GUI + 重启,
# 放进去会让核实环节误报"没删掉"),但界面一定要显示它在不在。
try:
out["cable"] = {"exists": bool(cable_installed_probe())}
except Exception:
out["cable"] = {"exists": None}
# 残留项:界面要能显示"现在到底有没有残留可清",没有就不该让用户瞎点
try:
out["cable_residue"] = {"exists": bool(_cable_residue_state())}
except Exception:
out["cable_residue"] = {"exists": None}
return out
def _proc_seen(name):
rc, out = run(["tasklist", "/FI", f"IMAGENAME eq {name}", "/NH"])
return name.lower() in out.lower()
def parse_items(argv):
for a in argv:
if a.startswith("--items="):
raw = a.split("=", 1)[1].strip()
if raw == "all":
# ★ `all` **不含 cable**。cable 是系统级驱动、要靠人在 GUI 里点
# 「Remove Driver」并重启,把它算进"全删"会让 `--items=all` 这种
# 本来可以无人值守跑完的命令突然变成"弹 UAC + 开向导 + 要重启"。
# 想连它一起卸就显式写:--items=all,cable
# (界面上的「全选」是显式列 id,不受这里影响,仍然会带上它。)
# ★ `all` 也**不含 cable_residue**:那是"装/卸卡住了"时的修复动作,
# 不是日常清理。想清就显式写:--items=all,cable_residue
return [i for i in ITEM_IDS if i not in ("cable", "cable_residue")]
picked = [x.strip() for x in raw.split(",") if x.strip()]
return [x for x in picked if x in ITEM_IDS]
return []
def main():
# 清理脚本是**独立进程**,语言得自己读一次(清单名字按当前语言出)。
try:
i18n.load_from_config()
except Exception:
pass
dry = "--dry-run" in sys.argv
items = parse_items(sys.argv)
try:
if not dry:
open(LOG, "w", encoding="utf-8").close()
if os.path.exists(RESULT):
os.remove(RESULT) # 清掉上一次的结果,免得主程序读到旧的
except Exception:
pass
log("=" * 58)
log(i18n.L(" 遥控器客户端 · 清理", " VibeMote · Cleanup")
+ (i18n.L(" 【预览,不会真删】", " [PREVIEW — nothing will actually be deleted]")
if dry else ""))
log("=" * 58)
log(i18n.L(f"包目录:{HERE}", f"Program folder: {HERE}"))
log(i18n.L(f"管理员权限:{'✓' if is_admin() else '✗(计划任务/frida 服务可能删不掉)'}",
f"Administrator: {'✓' if is_admin()
else '✗ (the scheduled task / frida services may resist)'}"))
if not items:
log(i18n.L("这次什么都没勾选 —— 没有做任何改动。",
"Nothing was ticked — no changes were made."))
log(i18n.L("(要删哪项,在界面上勾上再点;命令行用 --items=venv,pycache)",
"(Tick what you want in the UI and click again; on the command line use "
"--items=venv,pycache)"))
log(i18n.L("可选项:", "Available items: ")
+ "、".join(f"{i}({i18n.pick(LABELS[i])})" for i in ITEM_IDS))
log("DONE")
return 0
log(i18n.L("本次将处理:", "This run will handle:"))
for i in ORDER:
if i in items:
log(f" · {i18n.pick(LABELS[i])}"
+ (i18n.L(" [需要管理员]", " [needs admin]") if i in ADMIN_IDS else ""))
n = 0
for i in ORDER:
if i not in items:
continue
n += 1
log(f"{n}) {i18n.pick(LABELS[i])}")
try:
ACTIONS[i](dry)
except Exception as e:
log(i18n.L(f" ✗ 出错:{type(e).__name__}: {e}",
f" ✗ Error: {type(e).__name__}: {e}"))
if dry:
# 预览到这里就结束:不核实、不写结果文件、更不结束自己。
# (核实逻辑看到东西还在会喊"没删掉",预览下这是废话,反而像失败。)
log(i18n.L("(预览结束:没有删任何文件、没有结束任何进程、没有改注册表)",
"(End of preview: no file was deleted, no process was ended, "
"no registry key was changed)"))
log("DONE")
return 0
# 回头核实:勾了但还在的项。**别只看执行结果**(会报成功、其实剩个壳)。
stuck = check_stuck(items)
if stuck:
log(i18n.L("⚠ 这些你要删、但**没删掉**:", "⚠ You asked to delete these, but they **survived**: ")
+ "、".join(i18n.pick(LABELS[i]) for i in stuck))
log(i18n.L(" 常见原因:还有进程占着(先关掉客户端 / 别的窗口),或需要管理员。",
" Common causes: a process still holds them (close the client / other "
"windows first), or admin rights are needed."))
log(i18n.L(" 重新点一次「清理」通常就好了;顽固的话注销一次再试。",
" Clicking “Clean up” once more usually fixes it; if it is stubborn, "
"sign out and back in and retry."))
kept = [n for i, n in KEEPABLE
if i not in items and os.path.exists(os.path.join(HERE, n))]
if kept:
log(i18n.L("保留(你没勾):", "Kept (you did not tick them): ") + "、".join(kept))
if not stuck and not kept:
log(i18n.L("清理后目录里已干净。", "The program folder is clean now."))
if DEFERRED:
log(i18n.L("⏳ 已安排到本进程退出后自动删除:",
"⏳ Scheduled for automatic deletion after this process exits: ")
+ "、".join(os.path.basename(p.replace(os.sep, "/").rstrip("/")) for p in DEFERRED))
log(i18n.L(" (正在运行的 pythonw.exe 被 Windows 锁着,谁也删不掉自己,所以交给"
"后台命令接着删;约 2 秒后完成,不需要你做任何事)",
" (Windows locks a running pythonw.exe, so a process cannot delete "
"itself; a background command finishes the job — about 2 seconds, "
"nothing for you to do)"))
if {"venv", "kill"} & set(items):
# 只删了依赖/结束了进程:Python 还在(系统 Python 或包内 runtime),装回依赖即可
log(i18n.L("重新安装:双击「启动遥控器.vbs」→ 界面点「安装基础依赖」",
"To reinstall: double-click 启动遥控器.vbs, then click "
"“Install base dependencies” in the UI"))
else:
log(i18n.L("客户端没有被结束,界面可以继续用。",
"The client was not ended; the UI keeps working."))
if os.path.exists(os.path.join(HERE, "runtime")):
# 明确告诉用户"内置 Python 不在清理范围内" —— 免得他担心刚才的清理把免安装能力弄没了,
# 或者去找一个根本不存在的「删除 Python」选项。
log(i18n.L("说明:内置 Python 运行时(runtime\\)属于程序本体,**不在清理范围内、一直保留** ——"
" 这样这台机器始终是免安装版。要彻底删掉,直接删整个程序目录。",
"Note: the bundled Python runtime (runtime\\) is part of the program "
"itself — **it is never in the cleanup list and always stays**, so this "
"machine keeps its no-install setup. To remove it for good, just delete "
"the whole program folder."))
_notify_done([i for i in ORDER if i in items], stuck)
log("DONE")
# 最后才结束主程序自己(这样界面上还能看到上面的过程)
if KILLS_CLIENT & set(items):
if dry:
log(i18n.L("(预览:正常运行时这里会让客户端退出)",
"(preview: on a real run the client would exit here)"))
else:
for pid in port_owners(UI_PORT):
# 同样不加 /T —— 否则会把本脚本自己一起带走
run(["taskkill", "/PID", str(pid), "/F"])
if port_owners(UI_PORT):
log(i18n.L(" - 客户端仍在运行(可能不是本程序启动的),没强杀",
" - The client is still running (maybe it was not started by "
"this program); not forcing it"))
return 0
if __name__ == "__main__":
sys.exit(main())