""" 독립 자가 업데이터. Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약을 헬퍼 프로세스로 우회. 메인 앱이 종료된 직후 파일 교체 + 재실행. 사용법 (메인 앱이 호출): updater.exe --pid <메인_PID> --new --target 흐름: 1. 메인 앱 종료 대기 (PID 폴링, 최대 30초) 2. target_exe를 .bak으로 백업 3. new_exe → target_exe 이동 4. target_exe 재실행 + 업데이터 자가 종료 실패 시 .bak 복원 """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import time from pathlib import Path def is_pid_running(pid: int) -> bool: """Windows에서 PID 실행 중인지 확인.""" if sys.platform != 'win32': try: os.kill(pid, 0) return True except OSError: return False try: import ctypes from ctypes import wintypes PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 STILL_ACTIVE = 259 kernel32 = ctypes.windll.kernel32 h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) if not h: return False try: exit_code = wintypes.DWORD() if kernel32.GetExitCodeProcess(h, ctypes.byref(exit_code)): return exit_code.value == STILL_ACTIVE return False finally: kernel32.CloseHandle(h) except Exception: return False def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool: """PID가 종료될 때까지 폴링. timeout 시 False.""" deadline = time.time() + timeout_sec while time.time() < deadline: if not is_pid_running(pid): return True time.sleep(0.5) return False def replace_file(new_path: Path, target_path: Path) -> Path | None: """target을 .bak으로 백업하고 new를 target 위치로 이동. Returns: 백업 파일 경로 (롤백용). 실패 시 None. """ backup = target_path.with_suffix(target_path.suffix + '.bak') try: if backup.exists(): backup.unlink() if target_path.exists(): shutil.move(str(target_path), str(backup)) shutil.move(str(new_path), str(target_path)) return backup except OSError as e: print(f"[updater] replace failed: {e}", file=sys.stderr) # 롤백 시도 if backup.exists() and not target_path.exists(): try: shutil.move(str(backup), str(target_path)) except OSError: pass return None def launch(exe_path: Path) -> bool: """새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기.""" try: if sys.platform == 'win32': DETACHED_PROCESS = 0x00000008 subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True) else: subprocess.Popen([str(exe_path)], close_fds=True) return True except OSError as e: print(f"[updater] launch failed: {e}", file=sys.stderr) return False def main() -> int: parser = argparse.ArgumentParser(description="Clock-out Calculator updater") parser.add_argument('--pid', type=int, required=True, help='메인 앱 PID') parser.add_argument('--new', required=True, help='새 .exe 경로') parser.add_argument('--target', required=True, help='교체 대상 .exe 경로') parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함') args = parser.parse_args() new_exe = Path(args.new).resolve() target_exe = Path(args.target).resolve() if not new_exe.exists(): print(f"[updater] new exe not found: {new_exe}", file=sys.stderr) return 2 if not wait_for_exit(args.pid, timeout_sec=30): print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr) return 3 # Windows 파일 핸들 해제 시간 여유 time.sleep(0.5) backup = replace_file(new_exe, target_exe) if backup is None: return 4 if args.no_launch: return 0 if not launch(target_exe): # 시작 실패 시 롤백 try: target_exe.unlink() shutil.move(str(backup), str(target_exe)) launch(target_exe) except OSError: pass return 5 # 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink. return 0 if __name__ == '__main__': sys.exit(main())