850 lines
31 KiB
Python
850 lines
31 KiB
Python
"""
|
|
업데이트 관리자
|
|
|
|
메인 프로그램에서 사용하는 업데이트 관리 모듈입니다.
|
|
Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
|
|
|
|
작성자: KH.Choi
|
|
최종 수정: 2026-02-18
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import copy
|
|
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
|
|
update_level: str = "patch"
|
|
|
|
|
|
@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"
|
|
update_level: str = "patch"
|
|
preserve_globs: list[str] | None = None
|
|
cleanup_backup_on_success: bool = True
|
|
clean_install_before_copy: bool = False
|
|
|
|
|
|
# ============================================================================
|
|
# 예외 클래스
|
|
# ============================================================================
|
|
|
|
class UpdateError(Exception):
|
|
"""업데이트 관련 기본 예외"""
|
|
def __init__(self, message: str, code: str = "ERR_UPDATE_UNKNOWN"):
|
|
self.message = message
|
|
self.code = code
|
|
super().__init__(message)
|
|
|
|
|
|
class NetworkError(UpdateError):
|
|
"""네트워크 연결 실패"""
|
|
def __init__(self, message: str, code: str = "ERR_NETWORK"):
|
|
super().__init__(message, code)
|
|
|
|
|
|
class ConfigError(UpdateError):
|
|
"""설정 파일 오류"""
|
|
def __init__(self, message: str, code: str = "ERR_CONFIG"):
|
|
super().__init__(message, code)
|
|
|
|
|
|
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 _load_local_update_policy(config_path: Optional[Path]) -> dict[str, Any]:
|
|
"""updater/config.json에서 update_policy를 로드합니다."""
|
|
if not config_path or not config_path.exists():
|
|
return {}
|
|
|
|
try:
|
|
raw = _strip_json_comments(config_path.read_text(encoding="utf-8"))
|
|
config = json.loads(raw)
|
|
policy = config.get("update_policy", {})
|
|
if isinstance(policy, dict):
|
|
return policy
|
|
except Exception as e:
|
|
logger.warning(f"update_policy 로드 실패, 기본값 사용: {e}")
|
|
return {}
|
|
|
|
|
|
def _normalize_update_level(value: Any) -> str:
|
|
"""update_level 정규화 (patch/minor/major)"""
|
|
level = str(value or "patch").strip().lower()
|
|
if level not in {"patch", "minor", "major"}:
|
|
return "patch"
|
|
return level
|
|
|
|
|
|
def _normalize_level_list(values: Any, default: list[str]) -> list[str]:
|
|
"""레벨 목록 정규화"""
|
|
if not isinstance(values, list):
|
|
values = default
|
|
normalized = []
|
|
for value in values:
|
|
level = _normalize_update_level(value)
|
|
if level not in normalized:
|
|
normalized.append(level)
|
|
if not normalized:
|
|
normalized = default
|
|
return normalized
|
|
|
|
|
|
# ============================================================================
|
|
# 버전 비교 유틸리티
|
|
# ============================================================================
|
|
|
|
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 = "",
|
|
preserve_globs: Optional[list[str]] = None,
|
|
cleanup_backup_on_success: bool = True,
|
|
clean_install_before_copy: bool = False,
|
|
allowed_update_levels: Optional[list[str]] = None,
|
|
clean_install_levels: Optional[list[str]] = None,
|
|
):
|
|
"""
|
|
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.preserve_globs = list(preserve_globs or ["data/*.sqlite", "data/*.json", "data/*.db"])
|
|
self.cleanup_backup_on_success = cleanup_backup_on_success
|
|
self.clean_install_before_copy = clean_install_before_copy
|
|
self.allowed_update_levels = _normalize_level_list(allowed_update_levels, ["patch", "minor", "major"])
|
|
self.clean_install_levels = _normalize_level_list(clean_install_levels, ["minor", "major"])
|
|
|
|
self._latest_version: Optional[VersionInfo] = None
|
|
self._stop_flag = threading.Event()
|
|
self.last_error_code = ""
|
|
self.last_error_message = ""
|
|
|
|
def _set_last_error(self, code: str, message: str) -> None:
|
|
self.last_error_code = code
|
|
self.last_error_message = message
|
|
|
|
def _clear_last_error(self) -> None:
|
|
self.last_error_code = ""
|
|
self.last_error_message = ""
|
|
|
|
@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:
|
|
self._clear_last_error()
|
|
if not self.supabase_url:
|
|
raise ConfigError("Supabase URL이 비어있습니다.", code="ERR_SUPABASE_URL_EMPTY")
|
|
if not self.supabase_key:
|
|
raise ConfigError("Supabase API 키가 비어있습니다.", code="ERR_SUPABASE_KEY_EMPTY")
|
|
|
|
# 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]
|
|
update_level = _normalize_update_level(latest.get("update_level", "patch"))
|
|
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"),
|
|
update_level=update_level,
|
|
)
|
|
|
|
self._latest_version = version_info
|
|
|
|
if update_level not in self.allowed_update_levels:
|
|
logger.info(
|
|
f"업데이트 레벨 정책으로 건너뜀: level={update_level} / 허용={self.allowed_update_levels}"
|
|
)
|
|
return None
|
|
|
|
# 버전 비교
|
|
if compare_versions(self.current_version, version_info.version) < 0:
|
|
logger.info(f"새 버전 발견: {version_info.version} (level={update_level})")
|
|
return version_info
|
|
else:
|
|
logger.info(f"최신 버전 사용 중: {self.current_version}")
|
|
return None
|
|
|
|
except requests.exceptions.Timeout:
|
|
logger.warning("업데이트 서버 연결 시간 초과")
|
|
raise NetworkError("서버 연결 시간 초과", code="ERR_TIMEOUT")
|
|
except requests.exceptions.SSLError as e:
|
|
logger.warning(f"업데이트 SSL 검증 실패: {e}")
|
|
raise NetworkError(
|
|
"SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요.",
|
|
code="ERR_SSL_VERIFY",
|
|
)
|
|
except requests.exceptions.RequestException as e:
|
|
logger.warning(f"업데이트 확인 실패: {e}")
|
|
raise NetworkError(f"네트워크 오류: {e}", code="ERR_NETWORK_REQUEST")
|
|
except ConfigError:
|
|
raise
|
|
except (KeyError, json.JSONDecodeError) as e:
|
|
logger.error(f"버전 정보 파싱 오류: {e}")
|
|
self._set_last_error("ERR_RESPONSE_PARSE", str(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:
|
|
self._clear_last_error()
|
|
# 다운로드 URL 확인
|
|
if not version_info.download_url:
|
|
self._set_last_error("ERR_NO_DOWNLOAD_URL", "다운로드 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,
|
|
update_level=_normalize_update_level(version_info.update_level),
|
|
preserve_globs=copy.deepcopy(self.preserve_globs),
|
|
cleanup_backup_on_success=self.cleanup_backup_on_success,
|
|
clean_install_before_copy=(
|
|
self.clean_install_before_copy
|
|
or _normalize_update_level(version_info.update_level) in self.clean_install_levels
|
|
),
|
|
)
|
|
|
|
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,
|
|
"update_level": config.update_level,
|
|
"preserve_globs": config.preserve_globs,
|
|
"cleanup_backup_on_success": config.cleanup_backup_on_success,
|
|
"clean_install_before_copy": config.clean_install_before_copy,
|
|
},
|
|
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():
|
|
self._set_last_error("ERR_UPDATER_EXE_NOT_FOUND", f"updater.exe를 찾을 수 없습니다: {updater_src}")
|
|
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}")
|
|
self._set_last_error("ERR_PERMISSION", str(e))
|
|
return False, f"파일 권한 오류: {e}"
|
|
except Exception as e:
|
|
logger.error(f"업데이트 준비 실패: {e}")
|
|
self._set_last_error("ERR_PREPARE_UNKNOWN", str(e))
|
|
return False, str(e)
|
|
|
|
def launch_updater(self) -> bool:
|
|
"""
|
|
updater.exe 실행
|
|
|
|
Returns:
|
|
bool: 실행 성공 여부
|
|
"""
|
|
try:
|
|
self._clear_last_error()
|
|
if not self.temp_updater_path.exists():
|
|
logger.error(f"updater.exe가 없습니다: {self.temp_updater_path}")
|
|
self._set_last_error("ERR_UPDATER_TEMP_NOT_FOUND", str(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}")
|
|
self._set_last_error("ERR_UPDATER_LAUNCH", str(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()
|
|
config_path_raw = str(update_settings.get("connection_config_path", "")).strip()
|
|
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
|
|
local_policy = _load_local_update_policy(config_path)
|
|
|
|
preserve_globs = update_settings.get("preserve_globs", local_policy.get("preserve_globs", ["data/*.sqlite", "data/*.json", "data/*.db"]))
|
|
if not isinstance(preserve_globs, list):
|
|
preserve_globs = ["data/*.sqlite", "data/*.json", "data/*.db"]
|
|
preserve_globs = [str(p).strip() for p in preserve_globs if str(p).strip()]
|
|
|
|
cleanup_backup_on_success = _parse_ssl_verify(
|
|
update_settings.get("cleanup_backup_on_success", local_policy.get("cleanup_backup_on_success", True)),
|
|
default=True,
|
|
)
|
|
clean_install_before_copy = _parse_ssl_verify(
|
|
update_settings.get("clean_install_before_copy", local_policy.get("clean_install_before_copy", False)),
|
|
default=False,
|
|
)
|
|
allowed_update_levels = _normalize_level_list(
|
|
update_settings.get("allowed_update_levels", local_policy.get("allowed_update_levels", ["patch", "minor", "major"])),
|
|
["patch", "minor", "major"],
|
|
)
|
|
clean_install_levels = _normalize_level_list(
|
|
update_settings.get("clean_install_levels", local_policy.get("clean_install_levels", ["minor", "major"])),
|
|
["minor", "major"],
|
|
)
|
|
|
|
if not supabase_url or not supabase_key:
|
|
env_name = update_settings.get("environment")
|
|
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,
|
|
preserve_globs=preserve_globs,
|
|
cleanup_backup_on_success=cleanup_backup_on_success,
|
|
clean_install_before_copy=clean_install_before_copy,
|
|
allowed_update_levels=allowed_update_levels,
|
|
clean_install_levels=clean_install_levels,
|
|
)
|