# 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())