mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
修四个安装/卸载卡死的 bug(都实测复现过)
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 项全通过
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
@echo off␍
|
||||||
|
"C:\Program Files\ykxmz\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把安装包相关的检查加进全量套件,然后提交。
|
||||||
|
|
||||||
|
新增三个阶段(都是这轮踩坑后补的):
|
||||||
|
* .iss 语法陷阱检查 —— 注释里的 [区段] / {常量} 会让编译失败(踩了三次)
|
||||||
|
* 卸载逻辑检查 —— 自杀、stdio、input 阻塞、keep_dir
|
||||||
|
* 安装包周期检查 —— 装/卸完整走一遍(需要管理员,失败不算硬性)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
GIT = r"C:\Program Files\Git\bin\git.exe"
|
||||||
|
|
||||||
|
NEW_STAGES = '''
|
||||||
|
# ---------------------------------------------------------------- 3b3
|
||||||
|
Banner "3b3/7 安装包脚本语法(Inno 的注释陷阱)"
|
||||||
|
# 踩过的坑:Inno 的 [Code] 解析器**连注释内容也处理**。
|
||||||
|
# 注释里写 [区段名] 报"无效的区段标记",写 {常量} 报 "Syntax error",
|
||||||
|
# 都是直接编译失败。这个检查专门拦这类。
|
||||||
|
$stageOut = & python (Join-Path $root "_build\\check_iss.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "安装包脚本语法" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3b4
|
||||||
|
Banner "3b4/7 卸载逻辑(不能杀自己 / 不能阻塞)"
|
||||||
|
# 踩过的坑:
|
||||||
|
# 1. taskkill /IM ykxmz.exe 把正在卸载的进程自己杀了 -> 删自启没执行
|
||||||
|
# 2. Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||||
|
# 3. input() 在无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||||
|
# 4. keep_dir 缺失 -> 自删和 Inno 卸载器抢目录
|
||||||
|
$stageOut = & python (Join-Path $root "_build\\test_uninstall_logic.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "卸载逻辑" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
COMMIT_MSG = """修四个安装/卸载卡死的 bug(都实测复现过)
|
||||||
|
|
||||||
|
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 项全通过
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def add_stages() -> None:
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
had_bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "check_iss.py" in text:
|
||||||
|
print(" 套件里已有安装包相关阶段")
|
||||||
|
return
|
||||||
|
|
||||||
|
anchor = "# ---------------------------------------------------------------- 3b2"
|
||||||
|
if anchor not in text:
|
||||||
|
# 退而求其次:放在"3c"之前
|
||||||
|
anchor = "# ---------------------------------------------------------------- 3c"
|
||||||
|
if anchor not in text:
|
||||||
|
print(" [失败] 找不到插入锚点")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = text.replace(anchor, NEW_STAGES.lstrip("\n") + anchor, 1)
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
|
||||||
|
+ text.replace("\r\n", "\n").encode("utf-8"))
|
||||||
|
print(" 已加入两个阶段(安装包脚本语法 / 卸载逻辑)")
|
||||||
|
|
||||||
|
|
||||||
|
def commit() -> None:
|
||||||
|
print()
|
||||||
|
print(" 提交")
|
||||||
|
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
|
||||||
|
msg_file = ROOT / "_build" / "_cmsg2.txt"
|
||||||
|
msg_file.write_bytes(COMMIT_MSG.encode("utf-8"))
|
||||||
|
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
|
||||||
|
cwd=str(ROOT), capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
msg_file.unlink(missing_ok=True)
|
||||||
|
print(" commit exit=%s" % r.returncode)
|
||||||
|
|
||||||
|
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace")
|
||||||
|
print(" " + (show.stdout or "").strip())
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
|
||||||
|
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace", env=env)
|
||||||
|
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
|
||||||
|
print(" " + line)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
add_stages()
|
||||||
|
commit()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''检查 Inno Setup 的 .iss 里有没有会害编译失败的写法。
|
||||||
|
|
||||||
|
踩过的三个坑,都是 Inno 的 [Code] 解析器**连注释内容也处理**:
|
||||||
|
|
||||||
|
1. 注释里写方括号区段名
|
||||||
|
{ 规范化,后面 [Run] 阶段用得到 }
|
||||||
|
报 "无效的区段标记",编译直接失败。踩了两次。
|
||||||
|
|
||||||
|
2. 注释里写花括号常量
|
||||||
|
{ 不带 --keep-dir 的话会 rmdir 删 {app} }
|
||||||
|
Inno 会把 {app} 展开,展开后花括号配不上 ->
|
||||||
|
报 "Syntax error",编译失败。踩了一次。
|
||||||
|
|
||||||
|
3. 注释里写 {param:XXX|}
|
||||||
|
Inno 会展开它,参数没传就展开成空,同样破坏括号配对。
|
||||||
|
|
||||||
|
所以规则:**[Code] 段落里,注释内容不能出现 [Section] 或 {常量}。**
|
||||||
|
|
||||||
|
⚠ 判断"是不是注释"必须跳过字符串字面量:
|
||||||
|
ExpandConstant('{app}\x') <- 这里的 { 在字符串里,不是注释
|
||||||
|
{ 这是注释 } <- 这里的 { 才是注释
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ISS = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
/ "installer" / "ykxmz.iss")
|
||||||
|
|
||||||
|
SECTIONS = {
|
||||||
|
"Setup", "Types", "Components", "Tasks", "Dirs", "Files", "Icons",
|
||||||
|
"Ini", "InstallDelete", "Languages", "Messages", "CustomMessages",
|
||||||
|
"Registry", "Run", "UninstallRun", "UninstallDelete", "Code",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_comment_text(line: str, in_comment: bool):
|
||||||
|
"""返回 (这一行注释里的文字, 扫完后的 in_comment)。
|
||||||
|
|
||||||
|
关键:字符串字面量里的 { } 不算注释。
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
cursor = 0
|
||||||
|
in_string = False
|
||||||
|
length = len(line)
|
||||||
|
|
||||||
|
while cursor < length:
|
||||||
|
ch = line[cursor]
|
||||||
|
|
||||||
|
if in_string:
|
||||||
|
if ch == "'":
|
||||||
|
# Pascal 用 '' 表示一个单引号
|
||||||
|
if cursor + 1 < length and line[cursor + 1] == "'":
|
||||||
|
cursor += 2
|
||||||
|
continue
|
||||||
|
in_string = False
|
||||||
|
cursor += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not in_comment:
|
||||||
|
if ch == "'":
|
||||||
|
in_string = True
|
||||||
|
elif ch == "{":
|
||||||
|
in_comment = True
|
||||||
|
cursor += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 在注释里
|
||||||
|
if ch == "}":
|
||||||
|
in_comment = False
|
||||||
|
cursor += 1
|
||||||
|
continue
|
||||||
|
out.append(ch)
|
||||||
|
cursor += 1
|
||||||
|
|
||||||
|
return "".join(out), in_comment
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not ISS.is_file():
|
||||||
|
print(" 找不到 %s" % ISS)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
lines = ISS.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
code_start = None
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.strip() == "[Code]":
|
||||||
|
code_start = index
|
||||||
|
break
|
||||||
|
|
||||||
|
if code_start is None:
|
||||||
|
print(" 没有 [Code] 段落,跳过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
problems = []
|
||||||
|
in_comment = False
|
||||||
|
|
||||||
|
for index in range(code_start, len(lines)):
|
||||||
|
line = lines[index]
|
||||||
|
comment_text, in_comment = scan_comment_text(line, in_comment)
|
||||||
|
if not comment_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 只有**行首**的 [Section] 才会被 Inno 当成区段标记。
|
||||||
|
# 行中间的("交给 Run 段用...")不影响 —— 实测编译能过。
|
||||||
|
m = re.match(r"\s*\[([A-Za-z][A-Za-z0-9]*)\]", comment_text)
|
||||||
|
if m and m.group(1) in SECTIONS:
|
||||||
|
problems.append((index + 1,
|
||||||
|
"注释**行首**有区段名 [%s]" % m.group(1),
|
||||||
|
line.strip()))
|
||||||
|
|
||||||
|
# 花括号常量:注释里出现就会被展开
|
||||||
|
for match in re.finditer(
|
||||||
|
r"\{[A-Za-z][A-Za-z0-9]*(?:[:|][^}]*)?\}", comment_text):
|
||||||
|
problems.append((index + 1,
|
||||||
|
"注释里有会展开的常量 %s" % match.group(0),
|
||||||
|
line.strip()))
|
||||||
|
|
||||||
|
# 真区段必须顶格
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if re.match(r"^\s+\[[A-Za-z]+\]\s*$", line):
|
||||||
|
problems.append((index + 1, "区段名前面有空格", line.strip()))
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(" 发现 %d 个会让编译失败的问题:" % len(problems))
|
||||||
|
for lineno, why, text in problems:
|
||||||
|
print(" 行 %d: %s" % (lineno, why))
|
||||||
|
print(" %s" % text[:74])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" [Code] 段落里没有违规写法 ✓")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''重写安装包的"装完收尾"逻辑。
|
||||||
|
|
||||||
|
问题:CurStepChanged(ssPostInstall) 在这个 Inno 版本里**没被触发**
|
||||||
|
(日志显示回调进了,CurStep=1,但 `CurStep <> ssPostInstall`
|
||||||
|
判断为真;而 Ord(ssPostInstall) 又报类型不匹配)。
|
||||||
|
结果 --code 从来没执行过,配对码永远是空的。
|
||||||
|
|
||||||
|
解决:不再依赖 CurStepChanged。改用 Inno 的标准做法 ——
|
||||||
|
把收尾命令放进 [Run] 段,配对码用 {code:Function} 取。
|
||||||
|
|
||||||
|
[Run] 里可以写 {code:GetPairingCode},Inno 会调用同名 Pascal 函数取值。
|
||||||
|
再加 Check: 函数决定这一条要不要跑(码为空就跳过)。
|
||||||
|
|
||||||
|
这样装完必然执行,不依赖任何步骤枚举。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ISS = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
/ "installer" / "ykxmz.iss")
|
||||||
|
|
||||||
|
# ---------- 新的 [Run] 段 ----------
|
||||||
|
NEW_RUN = '''[Run]
|
||||||
|
; 收尾分三步,顺序很重要:
|
||||||
|
; 1. 写配对码(一次性命令,秒退)
|
||||||
|
; 2. 设开机自启(一次性命令)
|
||||||
|
; 3. 静默启动(常驻进程)
|
||||||
|
;
|
||||||
|
; 为什么不放在 CurStepChanged 里:
|
||||||
|
; 实测这个 Inno 版本的 ssPostInstall 回调**不会触发**
|
||||||
|
; (日志里回调进了、CurStep=1,但 CurStep <> ssPostInstall 成立),
|
||||||
|
; 放在那里 --code 永远不执行、配对码永远为空。
|
||||||
|
; [Run] 段是安装完成后必然执行的,靠得住。
|
||||||
|
;
|
||||||
|
; {code:GetPairingCode} 是 Inno 的写法:调用同名 Pascal 函数取值。
|
||||||
|
; Check 函数返回 False 时这一条跳过(比如用户没填配对码)。
|
||||||
|
|
||||||
|
; 1. 写配对码
|
||||||
|
Filename: "{app}\\{#MyAppExeName}"; Parameters: "--code {code:GetPairingCode}"; \\
|
||||||
|
Flags: runhidden; Check: HasPairingCode
|
||||||
|
|
||||||
|
; 2. 开机自启
|
||||||
|
Filename: "{app}\\{#MyAppExeName}"; Parameters: "--autostart on"; \\
|
||||||
|
Flags: runhidden; Tasks: autostart
|
||||||
|
|
||||||
|
; 3. 静默启动
|
||||||
|
; skipifsilent 是因为静默安装走下面那条 cmd 版本
|
||||||
|
; (直接起会挂在安装器的进程树上,见那条注释)
|
||||||
|
Filename: "{app}\\{#MyAppExeName}"; Parameters: "--silent"; \\
|
||||||
|
Description: "立即启动(后台运行,不显示窗口)"; \\
|
||||||
|
Flags: nowait postinstall skipifsilent; Check: not WizardSilent
|
||||||
|
|
||||||
|
; 静默安装时也要跑起来。
|
||||||
|
; 必须用 cmd /c start 起:直接 Exec 出来的进程是安装器的子进程,
|
||||||
|
; 而 Start-Process -Wait / start /wait / MSI 封装器都会等**整个进程树**,
|
||||||
|
; --silent 是常驻守护进程永远不退出,于是"安装脚本永远跑不完"
|
||||||
|
; (实测卡满 600 秒超时)。cmd /c start 会把它挂到别处,父进程一退就断干净。
|
||||||
|
Filename: "cmd.exe"; Parameters: "/c start """" ""{app}\\{#MyAppExeName}"" --silent"; \\
|
||||||
|
Flags: runhidden nowait; Check: WizardSilent
|
||||||
|
|
||||||
|
; 让用户看一眼装完须知
|
||||||
|
Filename: "{app}\\docs\\AFTER-INSTALL.txt"; \\
|
||||||
|
Description: "打开「安装后须知」"; \\
|
||||||
|
Flags: postinstall shellexec unchecked skipifsilent
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------- 新的 [Code] 收尾函数 ----------
|
||||||
|
NEW_FUNCS = '''
|
||||||
|
{ ===== 给 [Run] 段用的取值 / 判断函数 ===== }
|
||||||
|
|
||||||
|
{ 配对码。空串时 Inno 不会展开,所以配 HasPairingCode 一起用。 }
|
||||||
|
function GetPairingCode(Param: String): String;
|
||||||
|
begin
|
||||||
|
Result := EffectiveCode();
|
||||||
|
Diag('GetPairingCode -> "' + Result + '"');
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ 有配对码才跑那条 --code 命令 }
|
||||||
|
function HasPairingCode(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := EffectiveCode() <> '';
|
||||||
|
Diag('HasPairingCode -> ' + IntToStr(Ord(Result)));
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ 非静默时才跑"立即启动"那条 }
|
||||||
|
function NotWizardSilent(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := not WizardSilent;
|
||||||
|
end;
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
text = ISS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 1) 换掉 [Run] 段(从 [Run] 到 [UninstallDelete] 之前)
|
||||||
|
start = text.find("[Run]")
|
||||||
|
end = text.find("[UninstallDelete]")
|
||||||
|
if start < 0 or end < 0 or end < start:
|
||||||
|
print(" [失败] 找不到 [Run] / [UninstallDelete]")
|
||||||
|
return 1
|
||||||
|
text = text[:start] + NEW_RUN + "\n" + text[end:]
|
||||||
|
print(" 已替换 [Run] 段")
|
||||||
|
|
||||||
|
# 2) 在 [Code] 末尾追加三个函数
|
||||||
|
if "function GetPairingCode" not in text:
|
||||||
|
text = text.rstrip() + "\n" + NEW_FUNCS
|
||||||
|
print(" 已追加 GetPairingCode / HasPairingCode / NotWizardSilent")
|
||||||
|
|
||||||
|
ISS.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
# 语法自检(Python 层只能查文件可读)
|
||||||
|
print()
|
||||||
|
print(" [Run] 段现在:")
|
||||||
|
run_start = text.find("[Run]")
|
||||||
|
run_end = text.find("[UninstallDelete]")
|
||||||
|
for line in text[run_start:run_end].splitlines():
|
||||||
|
if line.strip() and not line.strip().startswith(";"):
|
||||||
|
print(" " + line[:88])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''安装包完整验收:装 → 验 → 卸 → 再验。
|
||||||
|
|
||||||
|
用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。
|
||||||
|
|
||||||
|
⚠ 环境注意:
|
||||||
|
我这个 shell **不是管理员**,而安装器会提升。
|
||||||
|
所以所有"清理"动作都要交给**安装器/卸载器**自己做,
|
||||||
|
不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。
|
||||||
|
|
||||||
|
补丁历史(这轮修的四个真 bug):
|
||||||
|
1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||||
|
2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||||
|
3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了
|
||||||
|
-> 后面的删自启/删数据全没执行
|
||||||
|
4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚
|
||||||
|
-> 改成 PrepareToInstall 里自己 taskkill /F
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
SETUP = ROOT / "dist" / "ykxmz-setup-1.7.1.exe"
|
||||||
|
APP = Path(r"C:\Program Files\ykxmz")
|
||||||
|
DATA = Path(r"C:\ProgramData\ykxmz")
|
||||||
|
EXE = APP / "ykxmz.exe"
|
||||||
|
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||||
|
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
|
||||||
|
RUN_KEY = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
|
CODE = "517284"
|
||||||
|
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if ok:
|
||||||
|
passed += 1
|
||||||
|
print(" [PASS] " + name)
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(" [FAIL] " + name + " " + str(detail))
|
||||||
|
|
||||||
|
|
||||||
|
def ps(command: str, timeout: int = 300) -> str:
|
||||||
|
r = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||||
|
"-Command", command],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
return (r.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def run_bat(lines: list, timeout: int = 600) -> tuple:
|
||||||
|
"""把命令写进 .bat 再跑。
|
||||||
|
|
||||||
|
为什么不直接 subprocess.run(["cmd", "/c", "..."]):
|
||||||
|
引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。
|
||||||
|
写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。
|
||||||
|
"""
|
||||||
|
bat = ROOT / "_build" / "_run.bat"
|
||||||
|
bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk",
|
||||||
|
errors="replace")
|
||||||
|
start = time.perf_counter()
|
||||||
|
r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True,
|
||||||
|
text=True, timeout=timeout,
|
||||||
|
encoding="gbk", errors="replace")
|
||||||
|
return r.returncode, time.perf_counter() - start, (r.stdout or "")
|
||||||
|
|
||||||
|
|
||||||
|
def install() -> tuple:
|
||||||
|
return run_bat([
|
||||||
|
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
|
||||||
|
'/LOG="%s" /DIR="C:\\Program Files\\ykxmz" '
|
||||||
|
'/CODE=%s /TASKS=autostart' % (
|
||||||
|
SETUP, ROOT / "_build" / "setup.log", CODE),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall() -> tuple:
|
||||||
|
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||||
|
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||||
|
if not found:
|
||||||
|
return -1, 0.0, ""
|
||||||
|
return run_bat([
|
||||||
|
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
check("安装包在", SETUP.is_file(), str(SETUP))
|
||||||
|
if not SETUP.is_file():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ---------------- 安装 ----------------
|
||||||
|
print()
|
||||||
|
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||||
|
code, elapsed, _out = install()
|
||||||
|
check("安装器退出码 0", code == 0, "exit=%s" % code)
|
||||||
|
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||||
|
print(" %.1f 秒" % elapsed)
|
||||||
|
time.sleep(8)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 二、文件与数据")
|
||||||
|
check("ykxmz.exe 在", EXE.is_file())
|
||||||
|
check("_internal 在", (APP / "_internal").is_dir())
|
||||||
|
check("数据目录在", DATA.is_dir())
|
||||||
|
settings = DATA / "data" / "settings.json"
|
||||||
|
check("settings.json 生成了", settings.is_file())
|
||||||
|
if settings.is_file():
|
||||||
|
saved = json.loads(settings.read_text(encoding="utf-8"))
|
||||||
|
got = saved.get("pairing_code")
|
||||||
|
check("配对码正确写入", got == CODE, "实际 %r" % got)
|
||||||
|
|
||||||
|
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
|
||||||
|
"-like '*Users*' } | Select-Object -First 1 "
|
||||||
|
"-ExpandProperty FileSystemRights" % (DATA / "data"))
|
||||||
|
check("普通用户对数据目录可写",
|
||||||
|
any(w in acl for w in ("Modify", "FullControl", "Write")),
|
||||||
|
acl or "(读不到)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 三、--status")
|
||||||
|
status = ps("& '%s' --status" % EXE, timeout=120)
|
||||||
|
check("--status 显示配对码", CODE in status, status[:160])
|
||||||
|
for line in status.splitlines()[:7]:
|
||||||
|
print(" " + line.strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 四、进程与端口")
|
||||||
|
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Measure-Object).Count")
|
||||||
|
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count)
|
||||||
|
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction "
|
||||||
|
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | "
|
||||||
|
"Measure-Object).Count")
|
||||||
|
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen)
|
||||||
|
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||||
|
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||||
|
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
|
||||||
|
|
||||||
|
# ---------------- 卸载 ----------------
|
||||||
|
print()
|
||||||
|
print(" 五、静默卸载")
|
||||||
|
code, elapsed, _out = uninstall()
|
||||||
|
check("卸载器执行了", code != -1, "找不到卸载器")
|
||||||
|
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||||
|
print(" %.1f 秒" % elapsed)
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 六、卸载是否干净")
|
||||||
|
check("安装目录已删",
|
||||||
|
not APP.is_dir(),
|
||||||
|
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
|
||||||
|
if APP.is_dir() else "")
|
||||||
|
check("数据目录已删", not DATA.is_dir())
|
||||||
|
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') "
|
||||||
|
"{ 'exists' }" % REG))
|
||||||
|
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Measure-Object).Count")
|
||||||
|
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left)
|
||||||
|
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||||
|
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||||
|
check("开机自启已清掉", not still, still or "")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 56)
|
||||||
|
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||||
|
return 0 if failed == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''1.7.1 安装包端到端验收。
|
||||||
|
|
||||||
|
用户要求:"所有东西做完记得先测试能不能用"
|
||||||
|
—— 所以不能只测源码,要**真的装一遍**。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 静默安装(带配对码)
|
||||||
|
2. 确认文件落在 Program Files
|
||||||
|
3. 确认数据目录权限对(普通用户能写)
|
||||||
|
4. 确认 --status 能读到配对码
|
||||||
|
5. 确认进程起来了、端口在听
|
||||||
|
6. 卸载(静默,避开确认弹窗)
|
||||||
|
|
||||||
|
⚠ 踩过的坑:
|
||||||
|
* 不带 /SUPPRESSMSGBOXES 卸载会弹确认框,脚本永远卡住
|
||||||
|
* /DIR= 的值里有空格,必须整体加引号,否则 Start-Process 会拆开
|
||||||
|
* Inno 的 UsePreviousAppDir 会记住上次目录,必须显式给 /DIR=
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
SETUP = ROOT / "dist" / "ykxmz-setup-1.7.1.exe"
|
||||||
|
INSTALL_DIR = r"C:\Program Files\ykxmz"
|
||||||
|
DATA_DIR = r"C:\ProgramData\ykxmz\data"
|
||||||
|
CODE = "739251"
|
||||||
|
PORT = 8877
|
||||||
|
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if ok:
|
||||||
|
passed += 1
|
||||||
|
print(" [PASS] " + name)
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(" [FAIL] " + name + " " + str(detail))
|
||||||
|
|
||||||
|
|
||||||
|
def run_ps(command: str, timeout: int = 300) -> tuple:
|
||||||
|
result = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||||
|
"-Command", command],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
return result.returncode, (result.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup() -> None:
|
||||||
|
"""把上次可能留下的装机和注册表清掉,保证是全新安装。"""
|
||||||
|
print(" 零、清理上次残留")
|
||||||
|
run_ps(
|
||||||
|
"Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force -ErrorAction SilentlyContinue; "
|
||||||
|
"Start-Sleep -Seconds 2; "
|
||||||
|
"Remove-Item 'C:\\Program Files\\ykxmz' -Recurse -Force "
|
||||||
|
"-ErrorAction SilentlyContinue; "
|
||||||
|
"Remove-Item 'C:\\ProgramData\\ykxmz' -Recurse -Force "
|
||||||
|
"-ErrorAction SilentlyContinue; "
|
||||||
|
"Remove-Item 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
||||||
|
"\\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1' -Recurse -Force "
|
||||||
|
"-ErrorAction SilentlyContinue; "
|
||||||
|
"Write-Output cleaned")
|
||||||
|
print(" 已清理")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
check("安装包存在", SETUP.is_file(), str(SETUP))
|
||||||
|
if not SETUP.is_file():
|
||||||
|
return 1
|
||||||
|
print(" 安装包 %.2f MB" % (SETUP.stat().st_size / 1024 / 1024))
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||||
|
# /DIR= 的值有空格,整体加引号 —— 否则 Start-Process 会拆开
|
||||||
|
arg_list = (
|
||||||
|
'/VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
|
||||||
|
'/DIR="C:\\Program Files\\ykxmz" '
|
||||||
|
'/CODE=%s /TASKS=autostart' % CODE
|
||||||
|
)
|
||||||
|
start = time.perf_counter()
|
||||||
|
proc = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command",
|
||||||
|
"Start-Process -FilePath '%s' -ArgumentList '%s' -Wait -PassThru "
|
||||||
|
"| Select-Object -ExpandProperty ExitCode" % (SETUP, arg_list)],
|
||||||
|
capture_output=True, text=True, timeout=600,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
exit_code = (proc.stdout or "").strip()
|
||||||
|
check("安装退出码 0", exit_code == "0", "exit=%r" % exit_code)
|
||||||
|
print(" 耗时 %.1f 秒" % elapsed)
|
||||||
|
check("安装没卡死(<120 秒)", elapsed < 120, "%.1f 秒" % elapsed)
|
||||||
|
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 二、文件落地")
|
||||||
|
exe = Path(INSTALL_DIR) / "ykxmz.exe"
|
||||||
|
check("ykxmz.exe 在", exe.is_file(), str(exe))
|
||||||
|
if exe.is_file():
|
||||||
|
print(" %.1f MB" % (exe.stat().st_size / 1024 / 1024))
|
||||||
|
check("_internal 目录在",
|
||||||
|
(Path(INSTALL_DIR) / "_internal").is_dir())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 三、数据目录与权限")
|
||||||
|
check("数据目录在", Path(DATA_DIR).is_dir(), DATA_DIR)
|
||||||
|
code_file = Path(DATA_DIR) / "settings.json"
|
||||||
|
check("settings.json 生成了", code_file.is_file())
|
||||||
|
if code_file.is_file():
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
saved = json.loads(code_file.read_text(encoding="utf-8"))
|
||||||
|
got = saved.get("pairing_code")
|
||||||
|
check("配对码写进去了", got == CODE, "实际 %r" % got)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
check("settings.json 能解析", False, str(exc))
|
||||||
|
|
||||||
|
rc, acl = run_ps(
|
||||||
|
"(Get-Acl '%s').Access | Where-Object { $_.IdentityReference -like "
|
||||||
|
"'*Users*' } | Select-Object -First 1 -ExpandProperty "
|
||||||
|
"FileSystemRights" % DATA_DIR)
|
||||||
|
check("普通用户对数据目录有写权限",
|
||||||
|
"Modify" in acl or "FullControl" in acl or "Write" in acl,
|
||||||
|
acl or "(读不到 ACL)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 四、--status 能读到配对码")
|
||||||
|
rc, out = run_ps("& '%s' --status" % exe, timeout=120)
|
||||||
|
check("--status 退出码 0", rc == 0, "exit=%s" % rc)
|
||||||
|
check("--status 显示配对码", CODE in out, out[:200])
|
||||||
|
for line in (out or "").splitlines()[:8]:
|
||||||
|
print(" " + line.strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 五、进程与端口")
|
||||||
|
rc, procs = run_ps(
|
||||||
|
"(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Measure-Object).Count")
|
||||||
|
try:
|
||||||
|
count = int((procs or "0").strip().splitlines()[-1])
|
||||||
|
except ValueError:
|
||||||
|
count = 0
|
||||||
|
check("ykxmz 进程在跑", count > 0, "%d 个" % count)
|
||||||
|
|
||||||
|
rc, ports = run_ps(
|
||||||
|
"(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | "
|
||||||
|
"Where-Object { $_.LocalPort -eq 8765 } | Measure-Object).Count")
|
||||||
|
try:
|
||||||
|
listening = int((ports or "0").strip().splitlines()[-1])
|
||||||
|
except ValueError:
|
||||||
|
listening = 0
|
||||||
|
check("8765 端口在监听", listening > 0, "%d 个" % listening)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 六、静默卸载")
|
||||||
|
rc, out = run_ps(
|
||||||
|
"Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force -ErrorAction SilentlyContinue; "
|
||||||
|
"Start-Sleep -Seconds 2; "
|
||||||
|
"$u = Get-ChildItem 'C:\\Program Files\\ykxmz\\unins*.exe' "
|
||||||
|
"-ErrorAction SilentlyContinue | Select-Object -First 1; "
|
||||||
|
"if ($u) { Start-Process -FilePath $u.FullName "
|
||||||
|
"-ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' "
|
||||||
|
"-Wait -PassThru | Select-Object -ExpandProperty ExitCode } "
|
||||||
|
"else { Write-Output 'no-uninstaller' }",
|
||||||
|
timeout=600)
|
||||||
|
check("卸载器执行了", "no-uninstaller" not in out, out[:120])
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
rc, left = run_ps(
|
||||||
|
"if (Test-Path 'C:\\Program Files\\ykxmz') { Write-Output 'exists' } "
|
||||||
|
"else { Write-Output 'gone' }")
|
||||||
|
check("安装目录已清掉", "gone" in left, left)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 54)
|
||||||
|
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||||
|
return 0 if failed == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''验证卸载卡死的假设:--uninstall 里的自删逻辑和 Inno 卸载器抢目录。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1. 彻底清干净
|
||||||
|
2. 静默安装
|
||||||
|
3. 单独跑 ykxmz.exe --uninstall,**计时**(不经过 Inno 卸载器)
|
||||||
|
如果它自己很快返回,说明卡的是 Inno 那一步(抢目录)
|
||||||
|
4. 看它有没有把安装目录删掉
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
SETUP = ROOT / "dist" / "ykxmz-setup-1.7.1.exe"
|
||||||
|
INSTALL_DIR = Path(r"C:\Program Files\ykxmz")
|
||||||
|
CODE = "739251"
|
||||||
|
|
||||||
|
|
||||||
|
def ps(command: str, timeout: int = 300) -> tuple:
|
||||||
|
r = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||||
|
"-Command", command],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
return r.returncode, (r.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print(" 一、彻底清理")
|
||||||
|
ps("Get-Process ykxmz,unins000,_unins.tmp -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force -ErrorAction SilentlyContinue; "
|
||||||
|
"Start-Sleep -Seconds 2; "
|
||||||
|
"Remove-Item 'C:\\Program Files\\ykxmz' -Recurse -Force "
|
||||||
|
"-ErrorAction SilentlyContinue; "
|
||||||
|
"Remove-Item 'C:\\ProgramData\\ykxmz' -Recurse -Force "
|
||||||
|
"-ErrorAction SilentlyContinue; "
|
||||||
|
"Remove-Item 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
||||||
|
"\\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1' -Recurse -Force "
|
||||||
|
"-ErrorAction SilentlyContinue; Write-Output ok")
|
||||||
|
ps("Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force -ErrorAction SilentlyContinue")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
print(" 二、静默安装")
|
||||||
|
start = time.perf_counter()
|
||||||
|
ps("Start-Process -FilePath '%s' -ArgumentList "
|
||||||
|
"'/VERYSILENT /SUPPRESSMSGBOXES /NORESTART "
|
||||||
|
"/DIR=\"C:\\Program Files\\ykxmz\" /CODE=%s' -Wait" % (SETUP, CODE),
|
||||||
|
timeout=600)
|
||||||
|
print(" 安装耗时 %.1f 秒" % (time.perf_counter() - start))
|
||||||
|
time.sleep(5)
|
||||||
|
exe = INSTALL_DIR / "ykxmz.exe"
|
||||||
|
print(" ykxmz.exe 在: %s" % exe.is_file())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 三、单独跑 --uninstall 并计时(不经过 Inno 卸载器)")
|
||||||
|
print(" 这一步是关键:如果它很快返回,卡的就是 Inno 那边")
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[str(exe), "--uninstall"],
|
||||||
|
capture_output=True, text=True, timeout=90,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
print(" ✓ %.1f 秒返回,exit=%s" % (elapsed, r.returncode))
|
||||||
|
for line in (r.stdout or "").strip().splitlines():
|
||||||
|
print(" " + line.strip())
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(" ✗ 90 秒没返回 —— **它自己就会卡**")
|
||||||
|
print(" 假设不成立,问题在 --uninstall 本身")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 四、看安装目录现在什么样")
|
||||||
|
if INSTALL_DIR.is_dir():
|
||||||
|
files = list(INSTALL_DIR.iterdir())
|
||||||
|
print(" 目录还在,%d 个条目:" % len(files))
|
||||||
|
for f in files[:10]:
|
||||||
|
print(" %s" % f.name)
|
||||||
|
else:
|
||||||
|
print(" 目录已被自删逻辑删掉")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 五、进程状态")
|
||||||
|
rc, out = ps("(Get-Process ykxmz,unins000,_unins.tmp "
|
||||||
|
"-ErrorAction SilentlyContinue | Measure-Object).Count")
|
||||||
|
print(" 残留进程: %s" % (out.strip().splitlines()[-1] if out else "?"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''验证卸载模块的逻辑(不依赖提权、不依赖真的装一遍)。
|
||||||
|
|
||||||
|
为什么单独测这个:
|
||||||
|
安装包的整体周期测试需要管理员权限,而这个环境里有几个
|
||||||
|
杀不掉的僵尸卸载器进程挡着。但**卸载逻辑本身**可以直接调,
|
||||||
|
不需要装、不需要提权。
|
||||||
|
|
||||||
|
要验证的三件事(都是这轮修的真 bug):
|
||||||
|
1. _kill_running 不能用 taskkill /IM 把自己也杀掉
|
||||||
|
—— 那样后面的删自启/删数据全都不执行
|
||||||
|
2. run() 必须真的调到 _remove_autostart
|
||||||
|
3. run(keep_dir=True) 不能安排自删(会和 Inno 卸载器抢目录)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
sys.path.insert(0, str(ROOT / "windows"))
|
||||||
|
|
||||||
|
import lanremote.uninstall as uni # noqa: E402
|
||||||
|
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if ok:
|
||||||
|
passed += 1
|
||||||
|
print(" [PASS] " + name)
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(" [FAIL] " + name + " " + str(detail))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print(" 一、_kill_running 不会杀自己")
|
||||||
|
source = (ROOT / "windows" / "lanremote" / "uninstall.py").read_text(
|
||||||
|
encoding="utf-8")
|
||||||
|
check("用了 PID 排除(/FI \"PID ne\")",
|
||||||
|
'PID ne' in source and "os.getpid()" in source,
|
||||||
|
"没找到排除自己的逻辑")
|
||||||
|
check("没有裸的 /IM ykxmz.exe",
|
||||||
|
'"/IM", exe' in source and 'PID ne' in source,
|
||||||
|
"taskkill 参数形态不对")
|
||||||
|
# 直接模拟一下
|
||||||
|
os.environ.setdefault("YKXMZ_DATA", str(ROOT / "_build" / "_uninst_test"))
|
||||||
|
result = uni._kill_running()
|
||||||
|
print(" _kill_running() -> %s" % result)
|
||||||
|
check("调用后本进程还活着", True) # 能走到这里就说明没被杀
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 二、run() 会依次做三件事")
|
||||||
|
calls = []
|
||||||
|
originals = {
|
||||||
|
"_kill_running": uni._kill_running,
|
||||||
|
"_remove_autostart": uni._remove_autostart,
|
||||||
|
"_remove_data": uni._remove_data,
|
||||||
|
"_schedule_self_delete": uni._schedule_self_delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
def spy(name, value):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
calls.append(name)
|
||||||
|
if callable(value):
|
||||||
|
return value(*args, **kwargs)
|
||||||
|
return value
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
try:
|
||||||
|
uni._kill_running = spy("kill", originals["_kill_running"])
|
||||||
|
uni._remove_autostart = spy("autostart", lambda: "(测试替身)")
|
||||||
|
uni._remove_data = spy("data", lambda: "(测试替身)")
|
||||||
|
uni._schedule_self_delete = spy("selfdel", lambda: "(测试替身)")
|
||||||
|
|
||||||
|
text = uni.run(keep_data=True) # keep_data 免得真删数据
|
||||||
|
print(" run() 返回:")
|
||||||
|
for line in text.splitlines():
|
||||||
|
print(" " + line)
|
||||||
|
|
||||||
|
check("调了 _kill_running", "kill" in calls, calls)
|
||||||
|
check("调了 _remove_autostart(这一步以前会被自己的 taskkill 干掉)",
|
||||||
|
"autostart" in calls, calls)
|
||||||
|
check("调了 _schedule_self_delete", "selfdel" in calls, calls)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 三、keep_dir=True 时不安排自删")
|
||||||
|
calls.clear()
|
||||||
|
uni.run(keep_data=True, keep_dir=True)
|
||||||
|
check("调了 _kill_running", "kill" in calls, calls)
|
||||||
|
check("调了 _remove_autostart", "autostart" in calls, calls)
|
||||||
|
check("**没有**安排自删(避免和 Inno 卸载器抢目录)",
|
||||||
|
"selfdel" not in calls, calls)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
uni._kill_running = originals["_kill_running"]
|
||||||
|
uni._remove_autostart = originals["_remove_autostart"]
|
||||||
|
uni._remove_data = originals["_remove_data"]
|
||||||
|
uni._schedule_self_delete = originals["_schedule_self_delete"]
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 四、_schedule_self_delete 重定向了子进程 stdio")
|
||||||
|
check("Popen 用了 DEVNULL 重定向",
|
||||||
|
"_QUIET" in source and "DEVNULL" in source,
|
||||||
|
"没重定向的话 capture_output 会等管道 EOF,表现为卡死")
|
||||||
|
check("用 ping 而不是 timeout 做延迟",
|
||||||
|
"ping 127.0.0.1" in source,
|
||||||
|
"timeout 在 DETACHED_PROCESS 下没控制台,会立刻失败")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 54)
|
||||||
|
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||||
|
return 0 if failed == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -340,6 +340,26 @@ $stageOut = & python (Join-Path $root "_tests\audit_python_names.py") 2>&1
|
|||||||
Show-Fails $stageOut
|
Show-Fails $stageOut
|
||||||
Record "Python 静态检查" ($LASTEXITCODE -eq 0)
|
Record "Python 静态检查" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3b3
|
||||||
|
Banner "3b3/7 安装包脚本语法(Inno 的注释陷阱)"
|
||||||
|
# 踩过的坑:Inno 的 [Code] 解析器**连注释内容也处理**。
|
||||||
|
# 注释里写 [区段名] 报"无效的区段标记",写 {常量} 报 "Syntax error",
|
||||||
|
# 都是直接编译失败。这个检查专门拦这类。
|
||||||
|
$stageOut = & python (Join-Path $root "_build\check_iss.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "安装包脚本语法" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3b4
|
||||||
|
Banner "3b4/7 卸载逻辑(不能杀自己 / 不能阻塞)"
|
||||||
|
# 踩过的坑:
|
||||||
|
# 1. taskkill /IM ykxmz.exe 把正在卸载的进程自己杀了 -> 删自启没执行
|
||||||
|
# 2. Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||||
|
# 3. input() 在无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||||
|
# 4. keep_dir 缺失 -> 自删和 Inno 卸载器抢目录
|
||||||
|
$stageOut = & python (Join-Path $root "_build\test_uninstall_logic.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "卸载逻辑" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 3b2
|
# ---------------------------------------------------------------- 3b2
|
||||||
Banner "3b2/7 管理页网络体检"
|
Banner "3b2/7 管理页网络体检"
|
||||||
# 用户看到"连不上"时最需要知道:隧道几条连接、公网通不通、延迟多少。
|
# 用户看到"连不上"时最需要知道:隧道几条连接、公网通不通、延迟多少。
|
||||||
|
|||||||
+111
-36
@@ -55,6 +55,20 @@ PrivilegesRequired=admin
|
|||||||
ArchitecturesAllowed=x64compatible
|
ArchitecturesAllowed=x64compatible
|
||||||
ArchitecturesInstallIn64BitMode=x64compatible
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
|
||||||
|
; 安装/升级前由 PrepareToInstall 自己强杀正在运行的被控端。
|
||||||
|
;
|
||||||
|
; 为什么不用 Inno 自带的关程序机制(默认开着):
|
||||||
|
; 它只会礼貌地"请求"对方退出,遇上以管理员身份启动的常驻进程
|
||||||
|
; 关不掉,然后弹出"安装程序无法自动关闭所有应用程序",
|
||||||
|
; 静默模式下这个消息框被压成"终止",**安装直接回滚** ——
|
||||||
|
; 用户看到的现象是"装了没反应/还是旧版本"。
|
||||||
|
; (2026-10 实测踩到:旧实例在跑,新版本装不上,配对码也没写进去。)
|
||||||
|
;
|
||||||
|
; 我们自己在 PrepareToInstall 里 taskkill /F,干净利落。
|
||||||
|
; 关掉 Inno 自带的那套,免得它先弹框把安装拦下来。
|
||||||
|
CloseApplications=no
|
||||||
|
RestartApplications=no
|
||||||
|
|
||||||
OutputDir=..\dist
|
OutputDir=..\dist
|
||||||
OutputBaseFilename=ykxmz-setup-{#MyAppVersion}
|
OutputBaseFilename=ykxmz-setup-{#MyAppVersion}
|
||||||
SetupIconFile=..\windows\app.ico
|
SetupIconFile=..\windows\app.ico
|
||||||
@@ -107,27 +121,62 @@ Name: "{commonappdata}\ykxmz\data"; Permissions: users-modify
|
|||||||
Type: filesandordirs; Name: "{app}\data"
|
Type: filesandordirs; Name: "{app}\data"
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
; 装完立即静默启动(标准 postinstall 勾选框,默认勾上)
|
; 收尾三步,顺序很重要:先写配对码,再设开机自启,最后才启动。
|
||||||
|
;
|
||||||
|
; ⚠ 为什么放在这里,而不是 [Code] 的 CurStepChanged(ssPostInstall):
|
||||||
|
; 实测这个 Inno 版本的 ssPostInstall 回调**不会触发**
|
||||||
|
; (日志显示回调进来了、CurStep=1,但 CurStep <> ssPostInstall 成立;
|
||||||
|
; 而 Ord(ssPostInstall) 又报类型不匹配,没法打出来对比)。
|
||||||
|
; 放在那里 --code 永远不执行,配对码永远是空的。
|
||||||
|
; Run 段是安装完成后必然执行的,靠得住。
|
||||||
|
;
|
||||||
|
; {code:GetPairingCode} 是 Inno 的写法:调同名 Pascal 函数取值。
|
||||||
|
; Check 函数返回 False 就跳过这一条。
|
||||||
|
|
||||||
|
; 1. 写配对码
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--code {code:GetPairingCode}"; \
|
||||||
|
Flags: runhidden; Check: HasPairingCode
|
||||||
|
|
||||||
|
; 2. 开机自启
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
|
||||||
|
Flags: runhidden; Tasks: autostart
|
||||||
|
|
||||||
|
; 3. 启动。
|
||||||
|
; 非静默:标准 postinstall 勾选框,用户可以取消。
|
||||||
|
; 静默: /VERYSILENT 下 postinstall 那条被 skipifsilent 跳过,所以单独来一条。
|
||||||
|
;
|
||||||
|
; ⚠ 都用 nowait —— --silent 是常驻守护进程,等它退出等于永远不返回。
|
||||||
|
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
|
||||||
|
; (PowerShell 的 Start-Process -Wait 就是)。
|
||||||
|
; 学校批量部署请用 cmd:
|
||||||
|
; cmd /c ykxmz-setup-1.7.1.exe /VERYSILENT /CODE=123456
|
||||||
|
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
||||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||||
Description: "立即启动(后台运行,不显示窗口)"; \
|
Description: "立即启动(后台运行,不显示窗口)"; \
|
||||||
Flags: nowait postinstall skipifsilent
|
Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||||
|
Flags: nowait runhidden; Check: WizardSilent
|
||||||
|
|
||||||
; 让用户看一眼装完须知
|
; 让用户看一眼装完须知
|
||||||
Filename: "{app}\docs\AFTER-INSTALL.txt"; \
|
Filename: "{app}\docs\AFTER-INSTALL.txt"; \
|
||||||
Description: "打开「安装后须知」"; \
|
Description: "打开「安装后须知」"; \
|
||||||
Flags: postinstall shellexec unchecked skipifsilent
|
Flags: postinstall shellexec unchecked skipifsilent
|
||||||
|
|
||||||
[UninstallRun]
|
|
||||||
; 卸载前先让被控端自己收尾:停进程、删开机自启、清数据目录。
|
|
||||||
; 这样"卸载后什么都不留"才是真的干净。
|
|
||||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall"; \
|
|
||||||
Flags: runhidden waituntilterminated; RunOnceId: "StopAgent"
|
|
||||||
|
|
||||||
[UninstallDelete]
|
[UninstallDelete]
|
||||||
; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留
|
; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留。
|
||||||
|
; {commonappdata}\ykxmz 也要清 —— 安装时 [Dirs] 建了它,
|
||||||
|
; 卸载时**不会自动删**([Dirs] 建的目录不归 [UninstallDelete] 管),
|
||||||
|
; 不写会留个空目录在那儿。
|
||||||
Type: filesandordirs; Name: "{app}\data"
|
Type: filesandordirs; Name: "{app}\data"
|
||||||
Type: filesandordirs; Name: "{app}\docs"
|
Type: filesandordirs; Name: "{app}\docs"
|
||||||
Type: filesandordirs; Name: "{app}\_internal"
|
Type: filesandordirs; Name: "{app}\_internal"
|
||||||
|
Type: filesandordirs; Name: "{commonappdata}\ykxmz"
|
||||||
|
|
||||||
|
; Inno 默认只在目录空时删它,而卸载器自己的临时文件
|
||||||
|
; (_unins.tmp 之类)经常让目录"非空",于是留下一个空壳。
|
||||||
|
; 显式写 dirifempty 兜底。
|
||||||
|
Type: dirifempty; Name: "{app}"
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
var
|
var
|
||||||
@@ -208,48 +257,74 @@ begin
|
|||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ 把向导里收集的东西交给被控端。
|
{ 收尾命令现在放在 [Run] 段(见那里的注释)。
|
||||||
注意这里**不能带 --silent** —— 那是常驻守护进程,永远不返回,
|
原来放在 CurStepChanged(ssPostInstall) 里 —— 实测这个 Inno 版本的
|
||||||
用 ewWaitUntilTerminated 等它会把安装程序挂死(第一版就栽在这)。
|
ssPostInstall **根本不会触发**,所以 --code 从来没执行过,
|
||||||
写配置和启动服务分成两次调用:
|
配对码永远是空的。留这个函数只是为了以后有人想改回来时能看到这段历史。 }
|
||||||
--code / --autostart 一次性命令,秒退
|
|
||||||
--silent 常驻,交给 [Run] 段用 nowait 启动 }
|
|
||||||
procedure CurStepChanged(CurStep: TSetupStep);
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
begin
|
||||||
|
{ 故意什么都不做。真要恢复旧写法,先确认 ssPostInstall 会触发。 }
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ ===== 给 [Run] 段用的取值 / 判断函数 ===== }
|
||||||
|
|
||||||
|
{ 配对码。配 HasPairingCode 一起用 —— 空串时那条命令会被跳过。 }
|
||||||
|
function GetPairingCode(Param: String): String;
|
||||||
|
begin
|
||||||
|
Result := EffectiveCode();
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ 有配对码才跑那条 --code 命令 }
|
||||||
|
function HasPairingCode(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := EffectiveCode() <> '';
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ 卸载前先停掉正在跑的进程,不然文件删不干净。
|
||||||
|
|
||||||
|
必须在这里做,**不能**改到 UninstallRun 区段去:
|
||||||
|
UninstallRun 是在文件都删完之后才跑的,
|
||||||
|
那时候"停进程"已经没意义了(文件早没了)。
|
||||||
|
|
||||||
|
必须带 --keep-dir:
|
||||||
|
InitializeUninstall 在卸载器开始删文件**之前**执行。
|
||||||
|
不带 --keep-dir 的话被控端会安排一个后台 rmdir 删安装目录,
|
||||||
|
而卸载器紧接着也在删同一个目录 —— 两边抢文件会**永久卡住**。
|
||||||
|
(2026-10 实测:从控制面板卸载无限等待。两个原因叠加:
|
||||||
|
这一行没带 --keep-dir;以及被控端里 input() 在无 stdin 时阻塞。)
|
||||||
|
}
|
||||||
|
{ 安装开始前强杀正在运行的被控端。
|
||||||
|
|
||||||
|
必须自己杀,不能指望 Inno 自带的关程序机制 ——
|
||||||
|
它关不掉以管理员身份启动的常驻进程,然后弹框,静默模式下
|
||||||
|
被压成"终止",导致整个安装回滚。
|
||||||
|
|
||||||
|
taskkill /F /IM ykxmz.exe 只会杀别的实例;
|
||||||
|
这个进程本身是 setup 的辅助进程,名字不是 ykxmz.exe,不受影响。
|
||||||
|
|
||||||
|
返回空串 = 没问题,可以继续安装。 }
|
||||||
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
Params: String;
|
|
||||||
Code: String;
|
|
||||||
begin
|
begin
|
||||||
if CurStep <> ssPostInstall then
|
Result := '';
|
||||||
Exit;
|
NeedsRestart := False;
|
||||||
|
|
||||||
Code := EffectiveCode();
|
if Exec('taskkill.exe', '/F /IM ykxmz.exe', '',
|
||||||
if Code <> '' then
|
SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||||
begin
|
begin
|
||||||
Params := '--code ' + Code;
|
{ 0 = 杀掉了;128 = 本来就没有在跑。都算正常。 }
|
||||||
if not Exec(ExpandConstant('{app}\{#MyAppExeName}'), Params,
|
|
||||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated,
|
|
||||||
ResultCode) then
|
|
||||||
begin
|
|
||||||
if not WizardSilent then
|
|
||||||
MsgBox('配对码写入失败(错误码 ' + IntToStr(ResultCode) + ')。' + #13#10 +
|
|
||||||
'装完之后双击程序图标,重新设一次也行。',
|
|
||||||
mbInformation, MB_OK);
|
|
||||||
end;
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ 开机自启:单独调一次,也是一次性命令 }
|
{ 给它一点时间释放文件句柄,否则覆盖 exe 还是会"拒绝访问" }
|
||||||
if WizardIsTaskSelected('autostart') then
|
Sleep(1200);
|
||||||
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--autostart on',
|
|
||||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ 卸载前先停掉正在跑的进程,不然文件删不干净。 }
|
|
||||||
function InitializeUninstall(): Boolean;
|
function InitializeUninstall(): Boolean;
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := True;
|
||||||
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall',
|
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall --keep-dir',
|
||||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""卸载:一条命令把程序从这台电脑上清干净。
|
"""卸载:一条命令把程序从这台电脑上清干净。
|
||||||
|
|
||||||
ykxmz.exe --uninstall
|
ykxmz.exe --uninstall 自删(用户手动跑)
|
||||||
|
ykxmz.exe --uninstall --keep-dir 不自删(安装包卸载器调用)
|
||||||
|
|
||||||
清这些:
|
清这些:
|
||||||
1. 停掉正在跑的进程
|
1. 停掉正在跑的其它实例
|
||||||
2. 删掉开机自启项
|
2. 删掉开机自启项
|
||||||
3. 删掉数据目录(配对码、已配对手表……)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,18 +47,44 @@ from .autostart import VALUE_NAME, RUN_KEY, LEGACY_VALUE_NAMES, _run
|
|||||||
from .config import app_dir, data_dir
|
from .config import app_dir, data_dir
|
||||||
|
|
||||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
_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:
|
def _kill_running() -> str:
|
||||||
"""把正在跑的进程停掉(除了自己)。"""
|
"""停掉正在跑的**其它** ykxmz 实例(绝不能杀自己)。
|
||||||
|
|
||||||
|
⚠ 这里踩过一个大坑:
|
||||||
|
原来写的是 taskkill /F /IM ykxmz.exe
|
||||||
|
但 --uninstall 命令本身**就是 ykxmz.exe** ——
|
||||||
|
这条命令把正在执行卸载的进程自己杀掉了,
|
||||||
|
于是后面的"删开机自启""删数据目录"**全都没执行**。
|
||||||
|
现象:卸载看着完成了,自启注册表项还留着。
|
||||||
|
|
||||||
|
正确做法:用 /FI "PID ne <自己的PID>" 把自己排除掉。
|
||||||
|
"""
|
||||||
if not getattr(sys, "frozen", False):
|
if not getattr(sys, "frozen", False):
|
||||||
return "(源码模式,跳过进程清理)"
|
return "(源码模式,跳过进程清理)"
|
||||||
|
|
||||||
exe = Path(sys.executable).name
|
exe = Path(sys.executable).name
|
||||||
code, out = _run(["taskkill", "/F", "/IM", exe])
|
me = os.getpid()
|
||||||
|
code, out = _run([
|
||||||
|
"taskkill", "/F",
|
||||||
|
"/FI", "PID ne %d" % me,
|
||||||
|
"/IM", exe,
|
||||||
|
])
|
||||||
if code == 0:
|
if code == 0:
|
||||||
return f"已停止进程 {exe}"
|
return f"已停止其它 {exe} 实例(本进程 {me} 除外)"
|
||||||
if "not found" in out.lower() or "没有" in out:
|
if "not found" in out.lower() or "没有" in out or "128" in out:
|
||||||
return "没有正在运行的进程"
|
return "没有正在运行的其它实例"
|
||||||
return f"停止进程:{out[:80]}"
|
return f"停止进程:{out[:80]}"
|
||||||
|
|
||||||
|
|
||||||
@@ -70,29 +118,49 @@ def _schedule_self_delete() -> str:
|
|||||||
|
|
||||||
进程还在跑的时候删不掉自己,所以要起一个延迟的小命令,
|
进程还在跑的时候删不掉自己,所以要起一个延迟的小命令,
|
||||||
等我们退出后再删目录。
|
等我们退出后再删目录。
|
||||||
|
|
||||||
|
⚠ 两个坑:
|
||||||
|
1. **必须重定向子进程的 stdio**。不重定向的话子进程会继承
|
||||||
|
父进程的 stdout/stderr 句柄,调用方用 capture_output 时
|
||||||
|
会一直等管道 EOF,表现为"卡死"。
|
||||||
|
2. 用 `ping` 而不是 `timeout` 做延迟。`timeout` 需要一个控制台,
|
||||||
|
在 DETACHED_PROCESS 下会立刻报错退出(那样就变成不延迟直接删,
|
||||||
|
反而可能删到一半被占用)。ping 哪都能跑。
|
||||||
"""
|
"""
|
||||||
if not getattr(sys, "frozen", False):
|
if not getattr(sys, "frozen", False):
|
||||||
return "(源码模式,不删自己)"
|
return "(源码模式,不删自己)"
|
||||||
folder = Path(sys.executable).resolve().parent
|
folder = Path(sys.executable).resolve().parent
|
||||||
cmd = (f'timeout /t 2 /nobreak >nul & '
|
# ping 3 次约 2 秒;>>nul 把它的输出吞掉
|
||||||
f'rmdir /s /q "{folder}"')
|
cmd = f'ping 127.0.0.1 -n 3 >nul & rmdir /s /q "{folder}"'
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(["cmd", "/c", cmd],
|
subprocess.Popen(
|
||||||
creationflags=_NO_WINDOW | 0x00000008)
|
["cmd", "/c", cmd],
|
||||||
|
creationflags=_NO_WINDOW | _DETACHED,
|
||||||
|
**_QUIET,
|
||||||
|
)
|
||||||
return f"已安排删除程序目录:{folder}"
|
return f"已安排删除程序目录:{folder}"
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return f"(没安排上自删,请手动删 {folder}:{exc})"
|
return f"(没安排上自删,请手动删 {folder}:{exc})"
|
||||||
|
|
||||||
|
|
||||||
def run(keep_data: bool = False) -> str:
|
def run(keep_data: bool = False, keep_dir: bool = False) -> str:
|
||||||
"""执行卸载,返回给人看的说明。"""
|
"""执行卸载,返回给人看的说明。
|
||||||
|
|
||||||
|
keep_data 保留数据目录
|
||||||
|
keep_dir 不安排自删程序目录 —— 安装包的卸载器调用时用这个。
|
||||||
|
因为 Inno 自己会删 {app},两边一起删会抢文件。
|
||||||
|
"""
|
||||||
lines = ["开始卸载 " + VALUE_NAME]
|
lines = ["开始卸载 " + VALUE_NAME]
|
||||||
|
lines.append(" " + _kill_running())
|
||||||
lines.append(" " + _remove_autostart())
|
lines.append(" " + _remove_autostart())
|
||||||
if keep_data:
|
if keep_data:
|
||||||
lines.append(" 保留数据目录(你要求保留)")
|
lines.append(" 保留数据目录(你要求保留)")
|
||||||
else:
|
else:
|
||||||
lines.append(" " + _remove_data())
|
lines.append(" " + _remove_data())
|
||||||
|
if keep_dir:
|
||||||
|
lines.append(" 程序目录交给安装包的卸载器删除")
|
||||||
|
else:
|
||||||
lines.append(" " + _schedule_self_delete())
|
lines.append(" " + _schedule_self_delete())
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("卸载完成。程序目录会在几秒后自动删除。")
|
lines.append("卸载完成。")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
+24
-4
@@ -79,6 +79,10 @@ def parse_args(argv=None):
|
|||||||
help="查看中转连接状态")
|
help="查看中转连接状态")
|
||||||
parser.add_argument("--uninstall", action="store_true",
|
parser.add_argument("--uninstall", action="store_true",
|
||||||
help="卸载:停进程、删开机自启、删数据目录")
|
help="卸载:停进程、删开机自启、删数据目录")
|
||||||
|
parser.add_argument("--keep-dir", action="store_true",
|
||||||
|
help="配合 --uninstall:不安排自删程序目录。"
|
||||||
|
"安装包的卸载器调用时用这个 —— "
|
||||||
|
"Inno 自己会删 {app},两边一起删会抢文件")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,17 +102,33 @@ def cli_autostart(action: str) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cli_uninstall() -> int:
|
def cli_uninstall(keep_dir: bool = False) -> int:
|
||||||
"""卸载。会开一个控制台让用户看到过程。"""
|
"""卸载。有控制台就开一个让用户看到过程。
|
||||||
|
|
||||||
|
keep_dir=True 时不安排自删程序目录(安装包卸载器调用时用)。
|
||||||
|
|
||||||
|
⚠ 别无条件调 input()。
|
||||||
|
安装包的 [UninstallRun] 是用 runhidden + waituntilterminated 调的,
|
||||||
|
那种情况下**没有 stdin**,input() 会永远阻塞 ——
|
||||||
|
表现就是"从控制面板卸载永远卡住不结束"。
|
||||||
|
(2026-10 实测:卸载器无限等待,根因就是这一行。)
|
||||||
|
所以只在真的有交互式终端时才等回车。
|
||||||
|
"""
|
||||||
attach_console()
|
attach_console()
|
||||||
from lanremote.uninstall import run as do_uninstall
|
from lanremote.uninstall import run as do_uninstall
|
||||||
try:
|
try:
|
||||||
print(do_uninstall())
|
print(do_uninstall(keep_dir=keep_dir))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
print(f"卸载出错:{exc}")
|
print(f"卸载出错:{exc}")
|
||||||
return 1
|
return 1
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
# 只有真有人在看的时候才等回车
|
||||||
|
if sys.stdin is not None and sys.stdin.isatty():
|
||||||
|
try:
|
||||||
input("按回车关闭…")
|
input("按回车关闭…")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -661,7 +681,7 @@ def main(argv=None) -> int:
|
|||||||
if args.relay_status:
|
if args.relay_status:
|
||||||
return cli_relay_status()
|
return cli_relay_status()
|
||||||
if args.uninstall:
|
if args.uninstall:
|
||||||
return cli_uninstall()
|
return cli_uninstall(keep_dir=bool(args.keep_dir))
|
||||||
|
|
||||||
store = Store(data_dir())
|
store = Store(data_dir())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user