AutoPercenty3/updateManager/test/test_updater.py

271 lines
12 KiB
Python

# updater.py — Windows 전용, 무GUI 업데이트 실행기 (외부 패키지 0)
# 흐름:
# 0) TEMP에서 재실행 보장
# 1) 대상 프로세스 종료(taskkill)
# 2) {app}\lib\src\user_data → TEMP로 백업
# 3) 기존 버전 언인스톨(가능하면)
# 4) 새 설치본 실행(사일런트)
# 5) user_data 복원(덮어쓰기 병합)
# 6) 메인 앱 재실행
import argparse
import os
import shutil
import subprocess
import sys
import time
import tempfile
import uuid
try:
import winreg # Windows 전용
except ImportError:
winreg = None
TARGETS = ["Edit_PartTimer3.exe", "updater.exe", "main.exe"]
# ─────────────────────────────────────────────────────────────────────────────
# TEMP 재실행
# ─────────────────────────────────────────────────────────────────────────────
def _is_running_from_temp() -> bool:
exe = os.path.abspath(sys.argv[0] if getattr(sys, 'frozen', False) else sys.executable)
tmp = os.path.abspath(tempfile.gettempdir())
return exe.lower().startswith(tmp.lower())
def _reexec_from_temp_if_needed():
if _is_running_from_temp():
return
# frozen(exe) 기준
if not getattr(sys, 'frozen', False):
# 개발 테스트 중 스크립트로 실행했다면 exe 빌드를 권장
# 필요시 아래 두 줄로 스크립트 재실행도 가능:
# subprocess.Popen([sys.executable, __file__, *sys.argv[1:]])
# sys.exit(0)
pass
src_exe = sys.executable if getattr(sys, 'frozen', False) else sys.executable
dst_exe = os.path.join(tempfile.gettempdir(), f"updater_{uuid.uuid4().hex}.exe")
try:
# exe 빌드물 복사
if getattr(sys, 'frozen', False):
shutil.copy2(src_exe, dst_exe)
else:
# 스크립트 상태는 개발용. 그대로 진행(권장 X)
pass
subprocess.Popen([dst_exe, *sys.argv[1:]], shell=False)
sys.exit(0)
except Exception:
# 복사 실패면 현 위치에서 진행
return
# ─────────────────────────────────────────────────────────────────────────────
# 프로세스 종료 / 인스톨러 실행 / 재실행
# ─────────────────────────────────────────────────────────────────────────────
def kill_targets():
for name in TARGETS:
try:
subprocess.run(
["taskkill", "/IM", name, "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
shell=False,
)
except Exception:
pass
time.sleep(0.5)
def run_installer(installer_path: str, silent: bool) -> int:
args = [installer_path]
if silent:
args += ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/CLOSEAPPLICATIONS"]
try:
completed = subprocess.run(args, check=False, shell=False)
return completed.returncode
except Exception:
return 1
def relaunch(appdir: str, appexe: str):
full = os.path.join(appdir, appexe) if appdir else appexe
try:
subprocess.Popen([full], shell=False)
except Exception:
pass
# ─────────────────────────────────────────────────────────────────────────────
# 언인스톨 (레지스트리 / appdir의 unins*.exe)
# ─────────────────────────────────────────────────────────────────────────────
UNINSTALL_KEYS = [
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
]
def find_uninstaller_by_appid_or_name(app_name_keywords=(), app_id=None):
if winreg is None:
return None
hives = [(winreg.HKEY_LOCAL_MACHINE, "HKLM"), (winreg.HKEY_CURRENT_USER, "HKCU")]
for hive, _ in hives:
for base in UNINSTALL_KEYS:
try:
with winreg.OpenKey(hive, base) as k:
idx = 0
while True:
try:
subkey_name = winreg.EnumKey(k, idx)
except OSError:
break
idx += 1
try:
with winreg.OpenKey(k, subkey_name) as sk:
display_name = None
uninstall_str = None
try:
display_name, _ = winreg.QueryValueEx(sk, "DisplayName")
except OSError:
pass
try:
uninstall_str, _ = winreg.QueryValueEx(sk, "UninstallString")
except OSError:
pass
if app_id:
try:
appid_reg, _ = winreg.QueryValueEx(sk, "Inno Setup: AppId")
if appid_reg and appid_reg.strip().lower() == app_id.strip().lower():
if uninstall_str:
return uninstall_str
except OSError:
pass
if display_name and app_name_keywords:
name_l = display_name.lower()
if all(kw.lower() in name_l for kw in app_name_keywords):
if uninstall_str:
return uninstall_str
except OSError:
continue
except OSError:
continue
return None
def run_uninstaller(uninstall_cmd: str):
if not uninstall_cmd:
return 0
cmdline = uninstall_cmd.strip()
extra = " /VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
if extra.lower() not in cmdline.lower():
cmdline = f"{cmdline} {extra}"
try:
completed = subprocess.run(cmdline, check=False, shell=True)
return completed.returncode
except Exception:
return 1
def find_unins_in_appdir(appdir: str):
if not appdir or not os.path.isdir(appdir):
return None
for name in os.listdir(appdir):
if name.lower().startswith("unins") and name.lower().endswith(".exe"):
return os.path.join(appdir, name)
return None
# ─────────────────────────────────────────────────────────────────────────────
# user_data 백업/복원
# ─────────────────────────────────────────────────────────────────────────────
def user_data_path(appdir: str) -> str:
# {app}\lib\src\user_data
return os.path.join(appdir, "lib", "src", "user_data")
def backup_user_data(appdir: str) -> str | None:
"""언인스 전에 user_data를 TEMP로 백업. (없으면 None)"""
src = user_data_path(appdir)
if not appdir or not os.path.isdir(src):
return None
backup_root = os.path.join(tempfile.gettempdir(), f"user_data_backup_{uuid.uuid4().hex}")
try:
# 전체 복사 (크면 시간 걸릴 수 있음). 병합을 고려하면 copytree가 안전
shutil.copytree(src, backup_root, dirs_exist_ok=True)
return backup_root
except Exception:
# 실패해도 업데이트는 계속
return None
def restore_user_data(appdir: str, backup_dir: str | None):
"""설치 후 user_data를 복원(병합)."""
if not backup_dir or not os.path.isdir(backup_dir) or not appdir:
return
dst = user_data_path(appdir)
os.makedirs(dst, exist_ok=True)
# 병합 복사: Python 3.11의 copytree(dirs_exist_ok=True)는 덮어씀
for root, dirs, files in os.walk(backup_dir):
rel = os.path.relpath(root, backup_dir)
target = os.path.join(dst, rel) if rel != "." else dst
os.makedirs(target, exist_ok=True)
for d in dirs:
os.makedirs(os.path.join(target, d), exist_ok=True)
for f in files:
src_file = os.path.join(root, f)
dst_file = os.path.join(target, f)
try:
shutil.copy2(src_file, dst_file)
except Exception:
pass
# 백업 삭제(선택)
try:
shutil.rmtree(backup_dir, ignore_errors=True)
except Exception:
pass
# ─────────────────────────────────────────────────────────────────────────────
# 메인
# ─────────────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="Minimal updater (Windows)")
ap.add_argument("--installer", required=True, help="Inno Setup 설치본 경로(.exe)")
ap.add_argument("--appexe", required=True, help="설치 후 재실행할 메인 EXE 파일명")
ap.add_argument("--appdir", default="", help="메인 앱 설치 경로 (예: C:\\Program Files\\Edit PartTimer3)")
ap.add_argument("--nosilent", action="store_true", help="사일런트 해제")
ap.add_argument("--appid", default="", help="Inno Setup AppId (선택)")
ap.add_argument("--appnamekw", nargs="*", default=["Edit", "PartTimer"], help="Uninstall DisplayName 키워드(선택)")
a = ap.parse_args()
_reexec_from_temp_if_needed()
installer = os.path.abspath(a.installer)
appdir = os.path.abspath(a.appdir) if a.appdir else ""
if not os.path.exists(installer):
return 2
# 1) 실행 중 프로세스 종료 (user_data 잠금 해제)
kill_targets()
# 2) user_data 백업
backup_dir = backup_user_data(appdir)
# 3) 기존 버전 언인스톨
unins_rc = 0
uninstall_cmd = None
if a.appid or (a.appnamekw and len(a.appnamekw) > 0):
uninstall_cmd = find_uninstaller_by_appid_or_name(tuple(a.appnamekw), a.appid)
if not uninstall_cmd and appdir:
unins_path = find_unins_in_appdir(appdir)
if unins_path:
uninstall_cmd = f'"{unins_path}"'
if uninstall_cmd:
unins_rc = run_uninstaller(uninstall_cmd)
time.sleep(0.7)
# 4) 새 버전 설치
rc = run_installer(installer, silent=not a.nosilent)
# 5) user_data 복원
restore_user_data(appdir, backup_dir)
# 6) 메인 앱 재실행
relaunch(appdir, a.appexe)
# 설치/언인스 코드 반환
return rc if rc != 0 else unins_rc or 0
if __name__ == "__main__":
sys.exit(main())