271 lines
12 KiB
Python
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())
|