""" 업데이트 관리자 메인 프로그램에서 사용하는 업데이트 관리 모듈입니다. Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다. 작성자: KH.Choi 최종 수정: 2026-02-18 """ import json import logging import shutil import subprocess import sys import tempfile import threading from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Optional import requests from .__version__ import PROGRAM_ID, VERSION logger = logging.getLogger(__name__) UPDATER_CONFIG_FILENAME = "config.json" # ============================================================================ # 데이터 클래스 # ============================================================================ @dataclass class VersionInfo: """ Supabase에서 조회한 버전 정보 Attributes: version: 버전 번호 is_stable: 안정판 여부 release_note: 배포 노트 download_url: 다운로드 URL min_required_version: 최소 요구 버전 """ version: str is_stable: bool release_note: Optional[str] = None download_url: Optional[str] = None min_required_version: Optional[str] = None @dataclass class UpdateConfig: """ updater.exe로 전달되는 구성 정보 Attributes: download_url: 다운로드 URL (zip 파일) target_path: 설치 대상 경로 version: 업데이트할 버전 restart_exe: 업데이트 후 실행할 실행파일명 """ download_url: str target_path: str version: str restart_exe: str = "voc_noti.exe" # ============================================================================ # 예외 클래스 # ============================================================================ class UpdateError(Exception): """업데이트 관련 기본 예외""" pass class NetworkError(UpdateError): """네트워크 연결 실패""" pass class ConfigError(UpdateError): """설정 파일 오류""" pass def _parse_ssl_verify(value: Any, default: bool = True) -> bool: """ssl_verify 값을 bool로 정규화""" if isinstance(value, bool): return value if isinstance(value, str): lowered = value.strip().lower() if lowered in {"true", "1", "yes", "on"}: return True if lowered in {"false", "0", "no", "off"}: return False if isinstance(value, (int, float)): return bool(value) return default def _build_requests_verify_option(ssl_verify: bool, ca_bundle_path: str) -> bool | str: """requests.verify 옵션 생성 (bool 또는 CA 번들 경로)""" if not ssl_verify: return False bundle_path = str(ca_bundle_path or "").strip() if not bundle_path: return True bundle = Path(bundle_path).expanduser() if bundle.exists(): return str(bundle) logger.warning(f"CA 번들 경로를 찾을 수 없습니다. 기본 인증서 검증 사용: {bundle}") return True def _strip_json_comments(content: str) -> str: """JSON 문자열의 주석(//, /* */) 제거""" result = [] i = 0 in_string = False escape_next = False while i < len(content): char = content[i] if escape_next: result.append(char) escape_next = False i += 1 continue if char == "\\" and in_string: result.append(char) escape_next = True i += 1 continue if char == '"': in_string = not in_string result.append(char) i += 1 continue if not in_string: if content[i : i + 2] == "//": while i < len(content) and content[i] != "\n": i += 1 continue if content[i : i + 2] == "/*": i += 2 while i < len(content) - 1 and content[i : i + 2] != "*/": i += 1 i += 2 continue result.append(char) i += 1 return "".join(result) def _select_environment_config( environments: dict[str, dict[str, Any]], requested_environment: Optional[str], default_environment: str, ) -> tuple[str, dict[str, Any]]: """환경 설정 선택 (요청 환경 > 기본 환경 > 우선순위)""" target = requested_environment or default_environment if target in environments and environments[target].get("enabled", True): return target, environments[target] enabled_items = [(k, v) for k, v in environments.items() if v.get("enabled", True)] if not enabled_items: return "", {} enabled_items.sort(key=lambda item: item[1].get("priority", 999)) return enabled_items[0] def load_updater_connection_config( config_path: Optional[Path] = None, environment: Optional[str] = None, timeout: int = 10, ) -> dict[str, Any]: """ 업데이터 연결 설정 로드 우선순위: 1) 원격 config_url (성공 시) 2) 로컬 fallback """ path = config_path or (Path(__file__).resolve().parent / UPDATER_CONFIG_FILENAME) if not path.exists(): raise ConfigError(f"업데이터 설정 파일이 없습니다: {path}") try: local_raw = _strip_json_comments(path.read_text(encoding="utf-8")) local_config = json.loads(local_raw) except (OSError, json.JSONDecodeError) as e: raise ConfigError(f"업데이터 설정 파일 파싱 실패: {e}") from e fallback = local_config.get("fallback", {}) default_env = local_config.get("default_environment", "main") config_url = local_config.get("config_url", "").strip() local_ssl_verify = _parse_ssl_verify(local_config.get("ssl_verify", True), default=True) local_ca_bundle = str(local_config.get("ca_bundle_path", "")).strip() # 1) 원격 설정 시도 if config_url: try: headers = { "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0", } response = requests.get( config_url, timeout=timeout, headers=headers, verify=_build_requests_verify_option(local_ssl_verify, local_ca_bundle), ) response.raise_for_status() remote_raw = _strip_json_comments(response.text) remote = json.loads(remote_raw) envs = remote.get("environments", {}) if isinstance(envs, dict) and envs: env_name, env_config = _select_environment_config( environments=envs, requested_environment=environment, default_environment=remote.get("defaultEnvironment", default_env), ) if env_config: url = env_config.get("supabaseUrl", "").strip() key = env_config.get("anonKey", "").strip() if url and key: env_ssl_verify = _parse_ssl_verify( env_config.get("sslVerify", local_ssl_verify), default=local_ssl_verify ) env_ca_bundle = str(env_config.get("caBundlePath", local_ca_bundle)).strip() return { "supabase_url": url, "supabase_key": key, "ssl_verify": env_ssl_verify, "ca_bundle_path": env_ca_bundle, "environment": env_name, "source": "remote", } except Exception as e: logger.warning(f"원격 연결 설정 로드 실패, fallback 사용: {e}") # 2) 로컬 fallback url = str(fallback.get("supabase_url", "")).strip() key = str(fallback.get("anon_key", "")).strip() fallback_ssl_verify = _parse_ssl_verify( fallback.get("ssl_verify", local_ssl_verify), default=local_ssl_verify ) fallback_ca_bundle = str(fallback.get("ca_bundle_path", local_ca_bundle)).strip() env = environment or default_env if not url or not key: raise ConfigError("fallback 설정에 supabase_url 또는 anon_key가 없습니다.") return { "supabase_url": url, "supabase_key": key, "ssl_verify": fallback_ssl_verify, "ca_bundle_path": fallback_ca_bundle, "environment": env, "source": "fallback", } def _resolve_connection_config_path(raw_path: str) -> Path: """연결 설정 파일 경로 해석 (상대경로/패킹 환경 지원)""" path = Path(raw_path) if path.is_absolute(): return path # 1) 현재 작업 디렉토리 기준 cwd_candidate = (Path.cwd() / path).resolve() if cwd_candidate.exists(): return cwd_candidate # 2) 실행 파일 기준 (패킹 환경) if getattr(sys, "frozen", False): exe_candidate = (Path(sys.executable).parent / path).resolve() if exe_candidate.exists(): return exe_candidate # 3) 모듈 기준 (개발 환경) module_candidate = (Path(__file__).resolve().parents[2] / path).resolve() return module_candidate # ============================================================================ # 버전 비교 유틸리티 # ============================================================================ def compare_versions(v1: str, v2: str) -> int: """ 버전 비교 Args: v1: 첫 번째 버전 (예: "3.2.0") v2: 두 번째 버전 (예: "3.1.0") Returns: -1: v1 < v2 0: v1 == v2 1: v1 > v2 """ try: parts1 = [int(x) for x in v1.split('.')] parts2 = [int(x) for x in v2.split('.')] # 길이 맞추기 max_len = max(len(parts1), len(parts2)) parts1.extend([0] * (max_len - len(parts1))) parts2.extend([0] * (max_len - len(parts2))) for p1, p2 in zip(parts1, parts2): if p1 < p2: return -1 elif p1 > p2: return 1 return 0 except (ValueError, AttributeError): logger.warning(f"버전 비교 실패: {v1} vs {v2}") return 0 # ============================================================================ # UpdateManager 클래스 # ============================================================================ class UpdateManager: """ 자동 업데이트 관리자 Supabase에서 버전 정보를 조회하고 업데이트를 처리합니다. Attributes: program_id: 프로그램 식별자 current_version: 현재 버전 supabase_url: Supabase 프로젝트 URL supabase_key: Supabase API 키 check_interval: 업데이트 체크 간격 (시간) 사용 예시: updater = UpdateManager( supabase_url="https://xxx.supabase.co", supabase_key="xxx" ) # 업데이트 확인 version_info = updater.check_for_updates() if version_info: # 업데이트 준비 및 실행 updater.prepare_update(version_info) updater.launch_updater() """ CONFIG_FILENAME = "voc_updater_config.json" UPDATER_EXE_NAME = "updater.exe" def __init__( self, supabase_url: str, supabase_key: str, program_id: str = PROGRAM_ID, current_version: str = VERSION, check_interval: int = 1, version_table: str = "program_version", ssl_verify: bool = True, ca_bundle_path: str = "", ): """ UpdateManager 초기화 Args: supabase_url: Supabase 프로젝트 URL supabase_key: Supabase API 키 (anon key) program_id: 프로그램 식별자 current_version: 현재 버전 check_interval: 업데이트 체크 간격 (시간) """ self.program_id = program_id self.current_version = current_version self.supabase_url = supabase_url.rstrip('/') self.supabase_key = supabase_key self.check_interval = check_interval self.version_table = version_table self.ssl_verify = ssl_verify self.ca_bundle_path = ca_bundle_path self._latest_version: Optional[VersionInfo] = None self._stop_flag = threading.Event() @property def config_path(self) -> Path: """업데이트 설정 파일 경로""" return Path(tempfile.gettempdir()) / self.CONFIG_FILENAME @property def temp_updater_path(self) -> Path: """임시 updater.exe 경로""" return Path(tempfile.gettempdir()) / self.UPDATER_EXE_NAME @property def install_dir(self) -> Path: """메인 프로그램 설치 경로""" if getattr(sys, "frozen", False): return Path(sys.executable).parent return Path(__file__).resolve().parents[2] @staticmethod def _get_creation_flags() -> int: """운영체제별 프로세스 생성 플래그""" if sys.platform.startswith("win"): return subprocess.CREATE_NEW_PROCESS_GROUP return 0 def check_for_updates(self) -> Optional[VersionInfo]: """ Supabase에서 최신 버전 확인 Returns: VersionInfo: 업데이트 가능한 경우 버전 정보 None: 업데이트 없음 """ try: if not self.supabase_url: raise ConfigError("Supabase URL이 비어있습니다.") if not self.supabase_key: raise ConfigError("Supabase API 키가 비어있습니다.") # Supabase REST API 호출 headers = { "apikey": self.supabase_key, "Authorization": f"Bearer {self.supabase_key}", "Content-Type": "application/json" } params = { "select": "*", "program_id": f"eq.{self.program_id}", "is_stable": "eq.true", "order": "created_at.desc", "limit": "1" } logger.info("업데이트 확인 중...") table_candidates = [self.version_table] if self.version_table == "program_version": table_candidates.append("program_versions") response = None verify_option = _build_requests_verify_option(self.ssl_verify, self.ca_bundle_path) for table_name in table_candidates: url = f"{self.supabase_url}/rest/v1/{table_name}" response = requests.get( url, headers=headers, params=params, timeout=10, verify=verify_option, ) if response.status_code == 404 and table_name != table_candidates[-1]: logger.warning(f"버전 테이블 미존재: {table_name}, 다음 후보 시도") continue response.raise_for_status() if table_name != self.version_table: logger.info(f"버전 테이블 자동 전환: {self.version_table} -> {table_name}") self.version_table = table_name break if response is None: raise NetworkError("버전 조회 응답이 없습니다.") data = response.json() if not data: logger.info("등록된 버전 정보가 없습니다.") return None latest = data[0] version_info = VersionInfo( version=latest.get("version", ""), is_stable=latest.get("is_stable", True), release_note=latest.get("release_note"), download_url=latest.get("download_url"), min_required_version=latest.get("min_required_version") ) self._latest_version = version_info # 버전 비교 if compare_versions(self.current_version, version_info.version) < 0: logger.info(f"새 버전 발견: {version_info.version}") return version_info else: logger.info(f"최신 버전 사용 중: {self.current_version}") return None except requests.exceptions.Timeout: logger.warning("업데이트 서버 연결 시간 초과") raise NetworkError("서버 연결 시간 초과") except requests.exceptions.SSLError as e: logger.warning(f"업데이트 SSL 검증 실패: {e}") raise NetworkError( "SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요." ) except requests.exceptions.RequestException as e: logger.warning(f"업데이트 확인 실패: {e}") raise NetworkError(f"네트워크 오류: {e}") except ConfigError: raise except (KeyError, json.JSONDecodeError) as e: logger.error(f"버전 정보 파싱 오류: {e}") return None def is_update_available(self) -> bool: """ 업데이트 필요 여부 확인 (캐시된 버전 정보 사용) Returns: bool: 업데이트 가능 여부 """ if self._latest_version is None: return False return compare_versions(self.current_version, self._latest_version.version) < 0 def prepare_update(self, version_info: VersionInfo) -> tuple[bool, str]: """ 업데이트 준비 config.json을 생성하고 updater.exe를 임시 폴더로 복사합니다. Args: version_info: 업데이트할 버전 정보 Returns: tuple[bool, str]: (성공 여부, 메시지) """ try: # 다운로드 URL 확인 if not version_info.download_url: return False, "다운로드 URL이 없습니다." install_dir = self.install_dir restart_exe = Path(sys.executable).name if getattr(sys, "frozen", False) else "voc_noti.exe" # config.json 생성 config = UpdateConfig( download_url=version_info.download_url, target_path=str(install_dir), version=version_info.version, restart_exe=restart_exe ) temp_config_path = self.config_path.with_suffix(".tmp") with open(temp_config_path, 'w', encoding='utf-8') as f: json.dump( { "download_url": config.download_url, "target_path": config.target_path, "version": config.version, "restart_exe": config.restart_exe, }, f, ensure_ascii=False, indent=2, ) temp_config_path.replace(self.config_path) logger.info(f"업데이트 설정 저장: {self.config_path}") # updater.exe 복사 updater_src = install_dir / self.UPDATER_EXE_NAME if not updater_src.exists(): return False, f"updater.exe를 찾을 수 없습니다: {updater_src}" shutil.copy2(updater_src, self.temp_updater_path) logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}") return True, "업데이트 준비 완료" except PermissionError as e: logger.error(f"파일 권한 오류: {e}") return False, f"파일 권한 오류: {e}" except Exception as e: logger.error(f"업데이트 준비 실패: {e}") return False, str(e) def launch_updater(self) -> bool: """ updater.exe 실행 Returns: bool: 실행 성공 여부 """ try: if not self.temp_updater_path.exists(): logger.error(f"updater.exe가 없습니다: {self.temp_updater_path}") return False # updater.exe 실행 subprocess.Popen( [str(self.temp_updater_path)], cwd=str(self.temp_updater_path.parent), creationflags=self._get_creation_flags(), ) logger.info("updater.exe 실행 완료") return True except Exception as e: logger.error(f"updater.exe 실행 실패: {e}") return False def start_background_check( self, on_update_available: Optional[Callable[[VersionInfo], None]] = None, on_error: Optional[Callable[[Exception], None]] = None ) -> None: """ 백그라운드 업데이트 체크 시작 Args: on_update_available: 업데이트 발견 시 콜백 on_error: 에러 발생 시 콜백 """ def _check_loop(): while not self._stop_flag.is_set(): try: version_info = self.check_for_updates() if version_info and on_update_available: on_update_available(version_info) except Exception as e: logger.warning(f"백그라운드 업데이트 체크 오류: {e}") if on_error: on_error(e) # 다음 체크까지 대기 self._stop_flag.wait(self.check_interval * 3600) thread = threading.Thread(target=_check_loop, daemon=True) thread.start() logger.info(f"백그라운드 업데이트 체크 시작 (주기: {self.check_interval}시간)") def stop_background_check(self) -> None: """백그라운드 업데이트 체크 중지""" self._stop_flag.set() logger.info("백그라운드 업데이트 체크 중지") def cleanup(self) -> None: """임시 파일 정리""" try: if self.config_path.exists(): self.config_path.unlink() if self.temp_updater_path.exists(): self.temp_updater_path.unlink() logger.info("업데이트 임시 파일 정리 완료") except Exception as e: logger.warning(f"임시 파일 정리 실패: {e}") # ============================================================================ # 편의 함수 # ============================================================================ def create_update_manager_from_settings(settings: dict) -> UpdateManager: """ settings.json에서 UpdateManager 생성 Args: settings: settings.json에서 로드한 딕셔너리 Returns: UpdateManager 인스턴스 """ update_settings = settings.get("update", {}) # settings.json에서 직접 지정 시 우선 사용, 없으면 updater/config.json 사용 supabase_url = str(update_settings.get("supabase_url", "")).strip() supabase_key = str(update_settings.get("supabase_key", "")).strip() ssl_verify_raw = update_settings.get("ssl_verify", None) ca_bundle_path = str(update_settings.get("ca_bundle_path", "")).strip() if not supabase_url or not supabase_key: config_path_raw = update_settings.get("connection_config_path", "") env_name = update_settings.get("environment") config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None conn = load_updater_connection_config(config_path=config_path, environment=env_name) supabase_url = conn["supabase_url"] supabase_key = conn["supabase_key"] if ssl_verify_raw is None: ssl_verify_raw = conn.get("ssl_verify", True) if not ca_bundle_path: ca_bundle_path = str(conn.get("ca_bundle_path", "")).strip() logger.info( f"업데이터 연결 설정 로드: source={conn.get('source')} / environment={conn.get('environment')}" ) ssl_verify = _parse_ssl_verify(ssl_verify_raw, default=True) return UpdateManager( supabase_url=supabase_url, supabase_key=supabase_key, program_id=update_settings.get("program_id", PROGRAM_ID), current_version=update_settings.get("current_version", VERSION), check_interval=update_settings.get("check_interval_hours", 1), version_table=update_settings.get("version_table", "program_version"), ssl_verify=ssl_verify, ca_bundle_path=ca_bundle_path, )