mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 19:30:42 +00:00
用户报:学校的电脑是 Windows 7,装完提示缺 DLL。
== 根因不是漏打包,是 Python 版本越界 ==
Python 3.8 -> 最后一个支持 Windows 7 的版本
Python 3.9+ -> 只支持 Windows 8 及以上
Python 3.9 起用到了 api-ms-win-core-path-l1-1-0.dll 里的
PathCchCanonicalizeEx,而这个 DLL **Windows 7 上根本不存在**,
也没法用 api-set 转发(那套机制只在 Win8+ 生效)。
所以之前用 3.11 打出来的包,在 Win7 上必然报缺 DLL ——
**那个 DLL 不是"忘了放进包",是"放进去也没用"。**
顺带说明:包里那 47 个 api-ms-win-core-*.dll 转发库正是
Python 为兼容 Win7 才带上的 —— 上游本来就考虑过 Win7,
只是 3.11 这个版本号越界了。
== 改法 ==
1. **改用 Python 3.8 打包,而且选 32 位**
32 位在 Win7 的 32 位和 64 位系统上都能跑,学校不用分两种发。
(64 位的包在 32 位 Windows 上直接起不来。)
依赖固定到 3.8 能用的版本:
Pillow==9.5.0(最后一个支持 3.8 的)
pyinstaller 6.x(保持 _internal 布局,和安装包/测试/卸载逻辑一致;
5.x 会把依赖摊在根目录,导致"_internal 在"那一项失败)
2. **打包脚本主动拦**
windows\build_exe.ps1 打包前先校验 Python:
* 找不到 3.8 -> 报错并给出安装命令
* 版本不是 3.8 -> 直接 throw,而不是默默打一个别人装不上的包
* 不是 32 位 -> 警告
Python 从 YKXMZ_PY 环境变量或常见路径里找,不写死。
3. **新增 Win7 兼容校验** _build\check_win7_compat.py
盯三件事:内嵌是不是 3.8 的运行库、**有没有**那个要命的 DLL、
exe 是不是 32 位。打包时和回归套件(3a9 关)里都会跑,
不过就拒绝出包。
== 顺手修的 ==
_build/test_installer_cycle.py 里写死了 ykxmz-setup-1.8.2.exe,
于是一直在**装一个不存在的旧包** -> 静默失败 -> 报出一堆
莫名其妙的失败("配对码实际是 xxx"、"版本显示 v1.8.2")。
改成自动找 dist 下版本号最新的包(按数字段排序,不能按字符串,
否则 1.9.0 会排到 2.1.4 前面)。
== 验证(用户要求:自己测一遍,没问题再上传)==
全量回归 42 个阶段全 PASS(含新的 "Windows 7 兼容" 关)
安装包完整周期 20/20
Win7 校验:python38.dll / 无 api-ms-win-core-path / 32 位
实际启动打包的 EXE:进程活着、TCP 监听、UDP 自动发现在听、
连接能建立(日志里有"设备接入")
产物扫描:三件全部干净(无旧密码、无服务器 IP)
提交前泄漏闸门通过
安装包体积 14.4 MB -> 9.0 MB。
213 lines
8.1 KiB
Python
213 lines
8.1 KiB
Python
# -*- 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 re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||
|
||
|
||
def _newest_setup() -> Path:
|
||
"""自动找 dist 下最新的安装包。
|
||
|
||
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
|
||
|
||
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
|
||
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
|
||
("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||
|
||
版本号每次发版都在变,写死必然过期。
|
||
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
|
||
|
||
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
|
||
"""
|
||
cands = []
|
||
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
|
||
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
|
||
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
|
||
if m:
|
||
cands.append((tuple(int(x) for x in m.groups()), p))
|
||
if not cands:
|
||
return ROOT / "dist" / "ykxmz-setup-不存在.exe"
|
||
cands.sort(key=lambda t: t[0])
|
||
return cands[-1][1]
|
||
|
||
|
||
SETUP = _newest_setup()
|
||
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")
|
||
# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。
|
||
# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空,
|
||
# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。)
|
||
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())
|