feat(update): add transactional external updater
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
"""在主程序退出后执行程序根项目事务替换的独立更新器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
PACKAGE_FORMAT = "cmshopee-portable-v1"
|
||||
UPDATER_PROTOCOL = 1
|
||||
ALLOWED_ROOTS = {
|
||||
"cmshopee.exe",
|
||||
"_internal",
|
||||
"version.txt",
|
||||
"README.txt",
|
||||
"package-manifest.json",
|
||||
"cmshopee-updater.exe",
|
||||
}
|
||||
PROTECTED_ROOTS = {"data", ".cmshopee-update"}
|
||||
|
||||
|
||||
class UpdaterError(RuntimeError):
|
||||
"""独立更新器拒绝计划或无法安全完成事务。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdatePlan:
|
||||
parent_pid: int
|
||||
install_root: Path
|
||||
staging_root: Path
|
||||
target_version: str
|
||||
transaction_id: str
|
||||
log_path: Path
|
||||
|
||||
|
||||
def create_plan(install_root, staging_root, target_version, parent_pid):
|
||||
install_root = Path(install_root).resolve()
|
||||
transaction_id = uuid.uuid4().hex
|
||||
update_root = install_root / ".cmshopee-update"
|
||||
plan = UpdatePlan(
|
||||
parent_pid=int(parent_pid),
|
||||
install_root=install_root,
|
||||
staging_root=Path(staging_root).resolve(),
|
||||
target_version=str(target_version),
|
||||
transaction_id=transaction_id,
|
||||
log_path=(update_root / "logs" / (transaction_id + ".log")).resolve(),
|
||||
)
|
||||
validate_plan(plan)
|
||||
plan_path = update_root / "plans" / (transaction_id + ".json")
|
||||
_atomic_json(
|
||||
plan_path,
|
||||
{
|
||||
"parent_pid": plan.parent_pid,
|
||||
"install_root": str(plan.install_root),
|
||||
"staging_root": str(plan.staging_root),
|
||||
"target_version": plan.target_version,
|
||||
"transaction_id": plan.transaction_id,
|
||||
"log_path": str(plan.log_path),
|
||||
},
|
||||
)
|
||||
return plan_path
|
||||
|
||||
|
||||
def _is_child(path, parent):
|
||||
try:
|
||||
Path(path).resolve().relative_to(Path(parent).resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _atomic_json(path, payload):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(str(temporary), str(path))
|
||||
|
||||
|
||||
def _append_log(path, message):
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with Path(path).open("a", encoding="utf-8") as stream:
|
||||
stream.write("%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), message))
|
||||
|
||||
|
||||
def load_plan(plan_path):
|
||||
plan_path = Path(plan_path).resolve()
|
||||
try:
|
||||
payload = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
plan = UpdatePlan(
|
||||
parent_pid=int(payload["parent_pid"]),
|
||||
install_root=Path(payload["install_root"]).resolve(),
|
||||
staging_root=Path(payload["staging_root"]).resolve(),
|
||||
target_version=str(payload["target_version"]),
|
||||
transaction_id=str(payload["transaction_id"]),
|
||||
log_path=Path(payload["log_path"]).resolve(),
|
||||
)
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
raise UpdaterError("更新计划无效") from exc
|
||||
validate_plan(plan, plan_path)
|
||||
return plan
|
||||
|
||||
|
||||
def validate_plan(plan, plan_path=None):
|
||||
if plan.parent_pid <= 0:
|
||||
raise UpdaterError("更新计划缺少主程序进程编号")
|
||||
if not plan.install_root.is_absolute() or not plan.install_root.is_dir():
|
||||
raise UpdaterError("程序安装目录无效")
|
||||
update_root = plan.install_root / ".cmshopee-update"
|
||||
staging_parent = update_root / "staging"
|
||||
if not plan.staging_root.is_dir() or plan.staging_root.parent != staging_parent.resolve():
|
||||
raise UpdaterError("新版暂存目录越界")
|
||||
if not re.fullmatch(r"\d+(?:\.\d+)*", plan.target_version):
|
||||
raise UpdaterError("目标版本号格式不正确")
|
||||
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", plan.transaction_id):
|
||||
raise UpdaterError("更新事务编号无效")
|
||||
if not _is_child(plan.log_path, update_root / "logs"):
|
||||
raise UpdaterError("更新日志路径越界")
|
||||
if plan_path is not None and not _is_child(plan_path, update_root):
|
||||
raise UpdaterError("更新计划文件路径越界")
|
||||
probe = update_root / (".write-probe-" + plan.transaction_id)
|
||||
try:
|
||||
probe.parent.mkdir(parents=True, exist_ok=True)
|
||||
probe.write_bytes(b"")
|
||||
probe.unlink()
|
||||
except OSError as exc:
|
||||
raise UpdaterError("程序安装目录不可写") from exc
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_staging(plan):
|
||||
manifest_path = plan.staging_root / "package-manifest.json"
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise UpdaterError("新版程序清单无效") from exc
|
||||
if (
|
||||
manifest.get("package_format") != PACKAGE_FORMAT
|
||||
or manifest.get("app_version") != plan.target_version
|
||||
or manifest.get("entry_point") != "cmshopee.exe"
|
||||
or int(manifest.get("updater_protocol") or 0) != UPDATER_PROTOCOL
|
||||
):
|
||||
raise UpdaterError("新版程序清单与更新计划不一致")
|
||||
roots = manifest.get("replace_roots")
|
||||
if not isinstance(roots, list) or not roots:
|
||||
raise UpdaterError("新版程序替换范围无效")
|
||||
roots = list(dict.fromkeys(str(root) for root in roots))
|
||||
if any(root not in ALLOWED_ROOTS or root in PROTECTED_ROOTS for root in roots):
|
||||
raise UpdaterError("新版程序包含未授权替换范围")
|
||||
if "cmshopee.exe" not in roots or "_internal" not in roots:
|
||||
raise UpdaterError("新版程序缺少必要替换项目")
|
||||
|
||||
declared = {}
|
||||
for item in manifest.get("files") or []:
|
||||
relative = PurePosixPath(str(item.get("path") or ""))
|
||||
if not relative.parts or ".." in relative.parts or relative.parts[0] not in roots:
|
||||
raise UpdaterError("新版程序清单包含越界文件")
|
||||
key = relative.as_posix().casefold()
|
||||
if key in declared:
|
||||
raise UpdaterError("新版程序清单包含重复文件")
|
||||
declared[key] = (relative, int(item.get("size_bytes") or -1), str(item.get("sha256") or ""))
|
||||
actual = {}
|
||||
for path in plan.staging_root.rglob("*"):
|
||||
if path.is_symlink():
|
||||
raise UpdaterError("新版程序不允许符号链接")
|
||||
if path.is_file() and path.name != "package-manifest.json":
|
||||
relative = PurePosixPath(path.relative_to(plan.staging_root).as_posix())
|
||||
actual[relative.as_posix().casefold()] = relative
|
||||
if set(actual) != set(declared):
|
||||
raise UpdaterError("新版程序文件与清单不一致")
|
||||
for key, (relative, size_bytes, expected_hash) in declared.items():
|
||||
path = plan.staging_root.joinpath(*relative.parts)
|
||||
if path.stat().st_size != size_bytes or _sha256(path).lower() != expected_hash.lower():
|
||||
raise UpdaterError("新版程序文件校验失败")
|
||||
if (plan.staging_root / "version.txt").read_text(encoding="utf-8-sig").strip() != plan.target_version:
|
||||
raise UpdaterError("新版程序版本文件不一致")
|
||||
return roots
|
||||
|
||||
|
||||
def _pid_running(pid):
|
||||
if pid <= 0:
|
||||
return False
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
|
||||
handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
|
||||
if handle:
|
||||
ctypes.windll.kernel32.CloseHandle(handle)
|
||||
return True
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_parent_exit(pid, timeout=60, poll_seconds=0.2):
|
||||
deadline = time.monotonic() + timeout
|
||||
while _pid_running(pid):
|
||||
if time.monotonic() >= deadline:
|
||||
raise UpdaterError("等待旧版程序退出超时")
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
|
||||
class TransactionLock:
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
self.fd = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(self.fd, str(os.getpid()).encode("ascii"))
|
||||
except FileExistsError as exc:
|
||||
raise UpdaterError("已有更新程序正在处理当前安装目录") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
try:
|
||||
self.path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _default_launch(executable, args):
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
return subprocess.Popen([str(executable), *args], close_fds=True, creationflags=creationflags)
|
||||
|
||||
|
||||
def apply_update(plan, *, wait_parent=wait_for_parent_exit, move=os.replace, launcher=_default_launch):
|
||||
validate_plan(plan)
|
||||
roots = validate_staging(plan)
|
||||
update_root = plan.install_root / ".cmshopee-update"
|
||||
old_version_path = plan.install_root / "version.txt"
|
||||
old_version = old_version_path.read_text(encoding="utf-8-sig").strip() if old_version_path.exists() else "unknown"
|
||||
backup_root = update_root / "backup" / (old_version + "-" + plan.transaction_id)
|
||||
failed_root = update_root / "failed" / plan.transaction_id
|
||||
journal_path = update_root / "transactions" / (plan.transaction_id + ".json")
|
||||
operations = []
|
||||
|
||||
def journal(status, error=""):
|
||||
_atomic_json(
|
||||
journal_path,
|
||||
{
|
||||
"transaction_id": plan.transaction_id,
|
||||
"target_version": plan.target_version,
|
||||
"status": status,
|
||||
"operations": operations,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
with TransactionLock(update_root / "update.lock"):
|
||||
wait_parent(plan.parent_pid)
|
||||
backup_root.mkdir(parents=True, exist_ok=False)
|
||||
journal("moving_old")
|
||||
try:
|
||||
for root in roots:
|
||||
source = plan.install_root / root
|
||||
if source.exists():
|
||||
destination = backup_root / root
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
move(str(source), str(destination))
|
||||
operations.append({"kind": "old_to_backup", "root": root})
|
||||
journal("moving_old")
|
||||
for root in roots:
|
||||
source = plan.staging_root / root
|
||||
if not source.exists():
|
||||
raise UpdaterError("新版程序缺少替换项目:%s" % root)
|
||||
destination = plan.install_root / root
|
||||
move(str(source), str(destination))
|
||||
operations.append({"kind": "new_to_install", "root": root})
|
||||
journal("moving_new")
|
||||
launcher(
|
||||
plan.install_root / "cmshopee.exe",
|
||||
["--update-transaction", plan.transaction_id, "--update-target-version", plan.target_version],
|
||||
)
|
||||
journal("launched")
|
||||
_append_log(plan.log_path, "新版程序已替换并启动:%s" % plan.target_version)
|
||||
return backup_root
|
||||
except Exception as exc:
|
||||
journal("rolling_back", str(exc))
|
||||
failed_root.mkdir(parents=True, exist_ok=True)
|
||||
rollback_errors = []
|
||||
for operation in reversed(operations):
|
||||
root = operation["root"]
|
||||
try:
|
||||
if operation["kind"] == "new_to_install":
|
||||
source = plan.install_root / root
|
||||
if source.exists():
|
||||
destination = failed_root / root
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
move(str(source), str(destination))
|
||||
else:
|
||||
source = backup_root / root
|
||||
if source.exists():
|
||||
move(str(source), str(plan.install_root / root))
|
||||
except Exception as rollback_exc:
|
||||
rollback_errors.append(str(rollback_exc))
|
||||
status = "rollback_failed" if rollback_errors else "rolled_back"
|
||||
journal(status, ";".join(rollback_errors) or str(exc))
|
||||
_append_log(plan.log_path, "更新失败,已尝试恢复旧版:%s" % exc)
|
||||
if rollback_errors:
|
||||
raise UpdaterError("更新失败且旧版恢复不完整,请联系管理员") from exc
|
||||
raise UpdaterError("更新失败,已恢复旧版程序") from exc
|
||||
|
||||
|
||||
def copy_and_launch_updater(updater_path, plan_path):
|
||||
updater_path = Path(updater_path).resolve()
|
||||
if not updater_path.is_file():
|
||||
raise UpdaterError("独立更新程序不存在")
|
||||
temporary_dir = Path(tempfile.mkdtemp(prefix="cmshopee-update-"))
|
||||
external_updater = temporary_dir / updater_path.name
|
||||
shutil.copy2(str(updater_path), str(external_updater))
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
|
||||
subprocess, "DETACHED_PROCESS", 0
|
||||
)
|
||||
return subprocess.Popen(
|
||||
[str(external_updater), "--plan", str(Path(plan_path).resolve())],
|
||||
close_fds=True,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="蝦皮圈优化助手独立更新程序")
|
||||
parser.add_argument("--plan", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
plan = load_plan(args.plan)
|
||||
apply_update(plan)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
try:
|
||||
if "plan" in locals():
|
||||
_append_log(plan.log_path, "自动更新失败:%s" % exc)
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user