# -*- coding: utf-8 -*- """卸载:一条命令把程序从这台电脑上清干净。 ykxmz.exe --uninstall 自删(用户手动跑) ykxmz.exe --uninstall --keep-dir 不自删(安装包卸载器调用) 清这些: 1. 停掉正在跑的其它实例 2. 删掉开机自启项 3. 删掉数据目录(配对码、已配对手表……) **故意做这个**:一个能开机自启、又能远程控制的软件, 必须能干净地卸载掉 —— 否则它就成了"赖着不走"的东西。 电脑的主人应该随时能把它彻底清掉。 ---- 2026-10 修的一个卡死 bug ---- 现象: 从控制面板卸载时,卸载器**永远卡住不结束**。 排查: 1. Inno 的 [UninstallRun] 用 waituntilterminated 等 ykxmz.exe --uninstall 退出 2. 单独跑 ykxmz.exe --uninstall 也是 90 秒不返回 —— 问题在它自己 3. 根因在 _schedule_self_delete(): Popen(["cmd", "/c", cmd], creationflags=...) **没有重定向子进程的 stdio**。子进程继承了父进程的 stdout/stderr 句柄, 而那个 cmd 要跑 `timeout /t 2` 再 `rmdir /s /q`(上千个文件), 期间一直握着管道。调用方如果用 capture_output 就会**等管道 EOF**, 永远等不到 -> 看起来像"程序卡死"。 修法: * Popen 时把 stdin/stdout/stderr 全部重定向到 DEVNULL,子进程不碰父进程的管道 * 加 --keep-dir:让安装包的卸载器调用时不走自删 (Inno 自己会删安装目录,两边一起删反而抢文件) * 补上 _kill_running() —— 之前定义了但从没被调用,文档说停进程其实没停 """ from __future__ import annotations import os import shutil import subprocess import sys import time from pathlib import Path from .autostart import VALUE_NAME, RUN_KEY, LEGACY_VALUE_NAMES, _run from .config import app_dir, data_dir _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) _DETACHED = 0x00000008 # DETACHED_PROCESS # 子进程一律不碰父进程的句柄。 # 少了这个,--uninstall 在 capture_output 场景下会"卡死"。 _QUIET = { "stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "close_fds": True, } def _kill_running() -> str: """停掉正在跑的**其它** ykxmz 实例(绝不能杀自己)。 ⚠ 这里踩过一个大坑: 原来写的是 taskkill /F /IM ykxmz.exe 但 --uninstall 命令本身**就是 ykxmz.exe** —— 这条命令把正在执行卸载的进程自己杀掉了, 于是后面的"删开机自启""删数据目录"**全都没执行**。 现象:卸载看着完成了,自启注册表项还留着。 正确做法:用 /FI "PID ne <自己的PID>" 把自己排除掉。 """ if not getattr(sys, "frozen", False): return "(源码模式,跳过进程清理)" exe = Path(sys.executable).name me = os.getpid() code, out = _run([ "taskkill", "/F", "/FI", "PID ne %d" % me, "/IM", exe, ]) if code == 0: return f"已停止其它 {exe} 实例(本进程 {me} 除外)" if "not found" in out.lower() or "没有" in out or "128" in out: return "没有正在运行的其它实例" return f"停止进程:{out[:80]}" def _remove_autostart() -> str: done = [] for name in (VALUE_NAME,) + tuple(LEGACY_VALUE_NAMES): code, _out = _run(["reg", "delete", RUN_KEY, "/v", name, "/f"]) if code == 0: done.append(name) if done: return f"已删除开机自启项 {', '.join(done)}" return "没有开机自启项" def _remove_data() -> str: """删数据目录。删不掉就留着,不影响卸载。""" target = Path(data_dir()) if not target.is_dir(): return "没有数据目录" try: shutil.rmtree(target, ignore_errors=True) if not target.is_dir(): return f"已删除数据目录 {target}" return f"数据目录没删干净(可能被占用):{target}" except Exception as exc: # noqa: BLE001 return f"删数据目录失败:{exc}" def _schedule_self_delete() -> str: """把自己这个 exe 也删掉。 进程还在跑的时候删不掉自己,所以要起一个延迟的小命令, 等我们退出后再删目录。 ⚠ 两个坑: 1. **必须重定向子进程的 stdio**。不重定向的话子进程会继承 父进程的 stdout/stderr 句柄,调用方用 capture_output 时 会一直等管道 EOF,表现为"卡死"。 2. 用 `ping` 而不是 `timeout` 做延迟。`timeout` 需要一个控制台, 在 DETACHED_PROCESS 下会立刻报错退出(那样就变成不延迟直接删, 反而可能删到一半被占用)。ping 哪都能跑。 """ if not getattr(sys, "frozen", False): return "(源码模式,不删自己)" folder = Path(sys.executable).resolve().parent # ping 3 次约 2 秒;>>nul 把它的输出吞掉 cmd = f'ping 127.0.0.1 -n 3 >nul & rmdir /s /q "{folder}"' try: subprocess.Popen( ["cmd", "/c", cmd], creationflags=_NO_WINDOW | _DETACHED, **_QUIET, ) return f"已安排删除程序目录:{folder}" except Exception as exc: # noqa: BLE001 return f"(没安排上自删,请手动删 {folder}:{exc})" def run(keep_data: bool = False, keep_dir: bool = False) -> str: """执行卸载,返回给人看的说明。 keep_data 保留数据目录 keep_dir 不安排自删程序目录 —— 安装包的卸载器调用时用这个。 因为 Inno 自己会删 {app},两边一起删会抢文件。 """ lines = ["开始卸载 " + VALUE_NAME] lines.append(" " + _kill_running()) lines.append(" " + _remove_autostart()) if keep_data: lines.append(" 保留数据目录(你要求保留)") else: lines.append(" " + _remove_data()) if keep_dir: lines.append(" 程序目录交给安装包的卸载器删除") else: lines.append(" " + _schedule_self_delete()) lines.append("") lines.append("卸载完成。") return "\n".join(lines)