"""发布包的轻量启动器和下次启动更新器。 Launcher 不联网。主程序已经下载并校验好 ``data/update/app.new`` 后,本模块在 主程序未运行时替换 ``app``,失败时恢复 ``app.old``。 """ from __future__ import annotations import ctypes import json import os import shutil import subprocess import sys import time from pathlib import Path from typing import Callable, Optional PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 HEALTH_WAIT_SECONDS = 15.0 class UpdateApplyError(RuntimeError): """Launcher 无法安全应用或恢复更新。""" def install_root(executable: str | None = None) -> Path: """返回 Launcher.exe 所在的发布根目录。""" return Path(executable or sys.executable).resolve().parent def app_executable(root: Path) -> Path: """返回主程序路径。""" return root / "app" / "CMAutoBuy.exe" def update_directory(root: Path) -> Path: """返回在线更新状态目录。""" return root / "data" / "update" def show_error(message: str) -> None: """使用 Windows 原生对话框显示启动错误。""" ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x10) def show_information(message: str) -> None: """使用 Windows 原生对话框显示普通提示。""" ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x40) def _read_json(path: Path) -> Optional[dict]: try: value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): return None return value if isinstance(value, dict) else None def _write_json_atomic(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text( json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) os.replace(str(temporary), str(path)) def _safe_remove(root: Path, path: Path) -> None: """只删除发布根目录内明确的更新目标。""" root_resolved = root.resolve() path_resolved = path.resolve() if path_resolved == root_resolved or not path_resolved.is_relative_to(root_resolved): raise UpdateApplyError(f"拒绝删除发布目录之外的路径:{path_resolved}") if not path.exists(): return if path.is_dir(): shutil.rmtree(path) else: path.unlink() def _health_matches(directory: Path, version: str) -> bool: health = _read_json(directory / "healthy.json") return health is not None and health.get("version") == version def is_process_running(pid: int, expected_executable: Path) -> bool: """确认 PID 仍存在,并且确实是本发布目录的主程序。""" if pid <= 0 or sys.platform != "win32": return False kernel32 = ctypes.windll.kernel32 kernel32.OpenProcess.argtypes = [ ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong, ] kernel32.OpenProcess.restype = ctypes.c_void_p kernel32.QueryFullProcessImageNameW.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, ctypes.c_wchar_p, ctypes.POINTER(ctypes.c_ulong), ] kernel32.QueryFullProcessImageNameW.restype = ctypes.c_int kernel32.CloseHandle.argtypes = [ctypes.c_void_p] kernel32.CloseHandle.restype = ctypes.c_int handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) if not handle: return False try: buffer = ctypes.create_unicode_buffer(32768) size = ctypes.c_ulong(len(buffer)) if not kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size)): return False actual = Path(buffer.value).resolve() return str(actual).casefold() == str(expected_executable.resolve()).casefold() finally: kernel32.CloseHandle(handle) def is_main_program_running( root: Path, checker: Callable[[int, Path], bool] = is_process_running, ) -> bool: """读取上次启动 PID,判断主程序是否仍在运行。""" directory = update_directory(root) running_file = directory / "running.json" running = _read_json(running_file) if running is None: return False pid = running.get("pid") if isinstance(pid, int) and checker(pid, app_executable(root)): return True try: running_file.unlink(missing_ok=True) except OSError: pass return False def _validate_pending(pending: dict) -> tuple[str, str]: version = pending.get("version") state = pending.get("state") if ( pending.get("schema_version") != 1 or not isinstance(version, str) or state not in {"ready", "applied"} ): raise UpdateApplyError("待更新状态文件无效") return version, state def finalize_applied_update(root: Path) -> None: """健康启动后清除待更新状态,保留一份 app.old。""" directory = update_directory(root) (directory / "pending.json").unlink(missing_ok=True) _safe_remove(root, directory / "app.new") def rollback_applied_update(root: Path, version: str, reason: str) -> None: """把未健康启动的新 app 移走,并恢复 app.old。""" directory = update_directory(root) current_app = root / "app" old_app = root / "app.old" failed_app = directory / "app.failed" if not old_app.is_dir(): raise UpdateApplyError("新版本启动失败,但没有可恢复的 app.old") _safe_remove(root, failed_app) if current_app.exists(): os.replace(str(current_app), str(failed_app)) try: os.replace(str(old_app), str(current_app)) except OSError as exc: if failed_app.exists() and not current_app.exists(): os.replace(str(failed_app), str(current_app)) raise UpdateApplyError(f"恢复旧版本失败:{exc}") from exc (directory / "pending.json").unlink(missing_ok=True) _write_json_atomic( directory / "last_error.json", {"schema_version": 1, "version": version, "reason": reason}, ) def apply_pending_update(root: Path) -> Optional[str]: """应用已暂存更新,返回刚应用的版本;没有更新时返回 None。""" directory = update_directory(root) pending_path = directory / "pending.json" if not pending_path.exists(): return None pending = _read_json(pending_path) if pending is None: raise UpdateApplyError("待更新状态文件损坏,请删除 data/update/pending.json 后重试") version, state = _validate_pending(pending) if state == "applied": if _health_matches(directory, version): finalize_applied_update(root) return None rollback_applied_update(root, version, "新版本上次启动未通过健康检查") return None current_app = root / "app" staged_app = directory / "app.new" old_app = root / "app.old" if not current_app.is_dir() or not (current_app / "CMAutoBuy.exe").is_file(): raise UpdateApplyError("当前 app 目录不完整,不能应用更新") if not staged_app.is_dir() or not (staged_app / "CMAutoBuy.exe").is_file(): raise UpdateApplyError("已下载更新不完整,请在设置页重新下载") _safe_remove(root, old_app) (directory / "healthy.json").unlink(missing_ok=True) os.replace(str(current_app), str(old_app)) try: os.replace(str(staged_app), str(current_app)) pending["state"] = "applied" _write_json_atomic(pending_path, pending) except Exception as exc: if current_app.exists() and not staged_app.exists(): os.replace(str(current_app), str(staged_app)) if old_app.exists() and not current_app.exists(): os.replace(str(old_app), str(current_app)) raise UpdateApplyError(f"应用更新失败,已恢复旧版本:{exc}") from exc return version def wait_for_health(process, root: Path, version: str) -> Optional[bool]: """等待健康标记;成功=True,提前退出=False,超时仍运行=None。""" directory = update_directory(root) deadline = time.monotonic() + HEALTH_WAIT_SECONDS while time.monotonic() < deadline: if _health_matches(directory, version): return True if process.poll() is not None: return False time.sleep(0.25) return None def _start_main_program(root: Path): target = app_executable(root) if not target.is_file(): raise OSError(f"主程序不存在:{target}") process = subprocess.Popen([str(target)], cwd=str(target.parent)) _write_json_atomic( update_directory(root) / "running.json", {"schema_version": 1, "pid": process.pid}, ) return process def main() -> int: """安全应用待更新目录,然后启动主程序。""" root = install_root() if is_main_program_running(root): show_information("商品采集采购工具已经在运行,请勿重复启动。") return 0 try: applied_version = apply_pending_update(root) process = _start_main_program(root) except (OSError, UpdateApplyError) as exc: show_error(f"主程序启动或更新失败。\n\n{exc}") return 1 if applied_version: health = wait_for_health(process, root, applied_version) if health is True: finalize_applied_update(root) elif health is False: try: rollback_applied_update( root, applied_version, "新版本启动后提前退出", ) _start_main_program(root) except (OSError, UpdateApplyError) as exc: show_error(f"新版本启动失败,恢复旧版本时发生错误。\n\n{exc}") return 1 show_error("新版本未能正常启动,已自动恢复并启动旧版本。") return 0 if __name__ == "__main__": raise SystemExit(main())