mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
1. 卸载里 input() 在无 stdin 时永久阻塞
从控制面板卸载**永远卡住不结束**。安装包的 [UninstallRun]
用 runhidden 调用,没有 stdin,input() 就一直等。
改成只在 sys.stdin.isatty() 时才等回车。
2. Popen 没重定向子进程 stdio,导致 capture_output 等管道 EOF
_schedule_self_delete 起的 cmd 继承了父进程的 stdout/stderr 句柄,
任何用 capture_output 调 --uninstall 的地方都会一直等,
表现为"程序卡死"(实测 90 秒不返回,改后 0.2 秒)。
Popen 加 stdin/stdout/stderr=DEVNULL。
顺带把延迟从 timeout 换成 ping —— timeout 在 DETACHED_PROCESS
下没有控制台会立刻失败,等于没延迟直接删。
3. _kill_running 用 taskkill /IM ykxmz.exe 把**自己**杀了
--uninstall 本身就跑在 ykxmz.exe 里,这条命令一发,
后面的"删开机自启""删数据目录"全都没执行。
现象:卸载看着完成了,HKCU 的自启注册表项还留着。
改成 /FI "PID ne <self>" 排除自己。
4. 安装器关不掉以管理员身份运行的旧实例 -> 整个安装回滚
Inno 自带机制只会"请求"对方退出,遇上常驻的服务进程关不掉,
弹"无法自动关闭所有应用程序",静默模式下被压成"终止",
安装直接回滚 —— 用户看到的是"装了没反应/还是旧版本"。
改成 PrepareToInstall 里自己 taskkill /F,关掉 Inno 那套。
另外修了两个卸载残留:
* 安装目录留个空壳 -> [UninstallDelete] 加 dirifempty
* ProgramData\ykxmz 没删 -> 加进 [UninstallDelete]
还有一处 Inno 写法陷阱(踩了三次):
注释里写 [区段名] 或 {常量} 会让编译直接失败,
因为 [Code] 解析器连注释内容也处理。
加了 _build/check_iss.py 专门扫这个,并进全量套件。
实测结果:
静默安装 5 秒内退出,退出码 0,配对码正确写入,程序自动跑起来
静默卸载 0.7 秒退出(原来无限卡住)
全量回归 29 项全通过
卸载逻辑 11 项全通过
167 lines
6.3 KiB
Python
167 lines
6.3 KiB
Python
# -*- 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)
|