# -*- coding: utf-8 -*- r'''验「静默自动更新」这条链路。 == 分三层验,每层都能独立定位问题 == 一、协议层 CMD_UPDATE 常量存在、分发能走到 二、updater 查版本 / 下载 / 校验(不起真进程) 三、接进服务端之后,dispatch 的结构还对(曾经被补丁弄坏过) == 为什么第三层要单独验 == 我插 _do_update 的时候,补丁把 dispatch 方法**兜底的那句 `return {"ok": False, "error": f"未知命令: {cmd}"}` 一起挪进了 _run_update 的末尾** —— 结果: * _run_update 里引用了不存在的 cmd(pyflakes 报 undefined name) * dispatch 丢了兜底分支(未知命令会直接掉到函数末尾返回 None) pyflakes 只报了第一半,第二半得靠结构断言才看得出来。 所以这里把「兜底 return 必须在 dispatch 里、且在 _do_update 之前」 写成断言。 == 不起真进程 == `apply_update` 会起分离的 .cmd 并让进程退出 —— 测试里不能真跑, 只验到"包下好了、校验过了"为止。真安装由回归里的 「安装包完整周期」那一关覆盖。 ''' from __future__ import annotations import hashlib import http.server import json import re import socketserver import sys import threading from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "windows")) FAILS: list = [] def check(name: str, ok: bool, why: str = "") -> None: print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name, (" —— " + why) if why else "")) if not ok: FAILS.append(name) SERVER_PY = ROOT / "windows" / "lanremote" / "server.py" def main() -> int: print("=" * 58) print(" 静默自动更新链路") print("=" * 58) print() # ---------------- 一、协议层 ---------------- print("一、协议层") from lanremote import protocol as P check("CMD_UPDATE 存在", hasattr(P, "CMD_UPDATE"), "协议里没有这条命令") check("CMD_UPDATE 的值是 update", getattr(P, "CMD_UPDATE", "") == "update") # ---------------- 二、updater ---------------- print() print("二、updater(查版本 / 下载 / 校验)") from lanremote import updater as U # 版本比较:必须按数字段,不能按字符串 cases = [("2.1.5", "2.1.4", True), ("2.1.4", "2.1.5", False), ("2.1.5", "2.1.5", False), ("2.2.0", "2.1.9", True), ("1.9.0", "2.0.0", False), ("2.1.10", "2.1.9", True)] for new, cur, want in cases: got = U._ver_tuple(new) > U._ver_tuple(cur) check(" %s > %s == %s" % (new, cur, want), got == want, "实际 %s" % got) # 没配中转地址要报错,不能默默成功 try: U.check("", "2.1.5") check("没配中转地址时报错", False, "它竟然没报错") except U.UpdateError: check("没配中转地址时报错", True) # 起个假中转,真跑一遍 pkg = b"MZ" + b"NEWSETUP" * 3000 sha = hashlib.sha256(pkg).hexdigest() class FakeRelay(http.server.BaseHTTPRequestHandler): def log_message(self, *a): # noqa: A003 pass def do_GET(self): # noqa: N802 if self.path.startswith("/v1/update/latest"): body = json.dumps({ "ok": True, "platform": "windows", "version": "9.9.9", "name": "ykxmz-setup-9.9.9.exe", "url": "/download/ykxmz-setup-9.9.9.exe", "size": len(pkg), "sha256": sha, }).encode() self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif self.path.startswith("/download/"): self.send_response(200) self.send_header("Content-Length", str(len(pkg))) self.end_headers() self.wfile.write(pkg) else: self.send_response(404) self.end_headers() srv = socketserver.TCPServer(("127.0.0.1", 0), FakeRelay) port = srv.server_address[1] threading.Thread(target=srv.serve_forever, daemon=True).start() try: base = "http://127.0.0.1:%d" % port info = U.check(base, "2.1.5") check("查到最新版", info.get("latest") == "9.9.9", "拿到 %r" % info.get("latest")) check("判定需要更新", info.get("needUpdate") is True) check("url 拼成了完整地址", str(info.get("url", "")).startswith(base + "/download/"), "拿到 %r" % info.get("url")) check("拿得到 sha256", len(info.get("sha256", "")) == 64) work = ROOT / "_build" / "_tmp_update_test" import shutil shutil.rmtree(work, ignore_errors=True) got = U.download(info["url"], work, info["sha256"], info["size"]) check("下载成功", got.is_file() and got.stat().st_size == len(pkg)) check("内容一致", got.read_bytes() == pkg) # 校验和不匹配必须拒绝 try: U.download(info["url"], work, "00" * 32, 0) check("校验和不匹配时拒绝", False, "它竟然接受了") except U.UpdateError: check("校验和不匹配时拒绝", True) # 已是最新时不该说要更新 info2 = U.check(base, "9.9.9") check("已是最新时不提示更新", info2.get("needUpdate") is False) finally: srv.shutdown() import shutil shutil.rmtree(ROOT / "_build" / "_tmp_update_test", ignore_errors=True) # ---------------- 三、接进服务端后的结构 ---------------- print() print("三、服务端结构(补丁曾经在这里弄坏东西)") src = SERVER_PY.read_text(encoding="utf-8") check("有 _do_update 方法", "def _do_update(self" in src) check("有 _run_update 方法", "def _run_update(self" in src) check("dispatch 里有 CMD_UPDATE 分支", "if cmd == P.CMD_UPDATE:" in src) # ⚠ 关键:未知命令的兜底必须在 **dispatch 方法里**。 # # 第一版这个断言写的是"兜底**紧跟** CMD_UPDATE 分支" —— # 后来往 dispatch 里插了 CMD_MOUSE / CMD_SHOW_DESKTOP, # 兜底被推到后面,断言误报失败。**兜底本身是好的。** # # 改成验**结构真相**,不管中间插了多少分支: # 从带 CMD_UPDATE 的那个方法体里找,必须有一句 8 空格缩进的兜底 return。 dispatch_body = "" for mm in re.finditer( r"\n def [a-z_]+\(self[^\n]*\)[^\n]*:\n(.*?)(?=\n def |\Z)", src, re.S): if "if cmd == P.CMD_UPDATE:" in mm.group(1): dispatch_body = mm.group(1) break check("找得到 dispatch 方法体", bool(dispatch_body), "没有哪个方法里有 CMD_UPDATE 分支") check("dispatch 里有未知命令的兜底", bool(re.search(r'^ return \{"ok": False, ' r'"error": f"未知命令: \{cmd\}"\}', dispatch_body, re.M)), "兜底那句不在 dispatch 里了 —— 未知命令会返回 None") # 各个新方法里不能有那句兜底 for meth in ("_run_update", "_do_update", "_do_mouse"): mm = re.search(r"\n def %s\(self.*?(?=\n def |\Z)" % meth, src, re.S) body = mm.group(0) if mm else "" check("%s 里没有引用 cmd" % meth, "未知命令: {cmd}" not in body, "兜底那句漏进 %s 了(会 NameError)" % meth) print() print("=" * 58) if FAILS: print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS))) return 1 print(" 全部通过") return 0 if __name__ == "__main__": sys.exit(main())