456 lines
14 KiB
Python
456 lines
14 KiB
Python
"""
|
|
업데이터 GUI
|
|
|
|
updater.exe용 최소 GUI 모듈입니다.
|
|
CustomTkinter 기반으로 다운로드 진행률과 상태를 표시합니다.
|
|
|
|
이 파일은 별도의 exe로 패킹되어 메인 프로그램과 함께 배포됩니다.
|
|
|
|
작성자: KH.Choi
|
|
최종 수정: 2026-02-18
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import urlparse
|
|
|
|
import customtkinter as ctk
|
|
import requests
|
|
|
|
# 로깅 설정
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# CustomTkinter 설정
|
|
ctk.set_appearance_mode("System")
|
|
ctk.set_default_color_theme("blue")
|
|
|
|
|
|
# ============================================================================
|
|
# 데이터 클래스
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class UpdateConfig:
|
|
"""
|
|
업데이트 구성 정보
|
|
|
|
updater.exe가 읽는 config.json의 구조입니다.
|
|
"""
|
|
download_url: str
|
|
target_path: str
|
|
version: str
|
|
restart_exe: str = "voc_noti.exe"
|
|
|
|
|
|
# ============================================================================
|
|
# 예외 클래스
|
|
# ============================================================================
|
|
|
|
class UpdaterError(Exception):
|
|
"""업데이터 기본 예외"""
|
|
pass
|
|
|
|
|
|
class DownloadError(UpdaterError):
|
|
"""다운로드 실패"""
|
|
pass
|
|
|
|
|
|
class ExtractError(UpdaterError):
|
|
"""압축 해제 실패"""
|
|
pass
|
|
|
|
|
|
class ConfigError(UpdaterError):
|
|
"""설정 파일 오류"""
|
|
pass
|
|
|
|
|
|
# ============================================================================
|
|
# UpdaterGUI 클래스
|
|
# ============================================================================
|
|
|
|
class UpdaterGUI(ctk.CTk):
|
|
"""
|
|
업데이터 GUI 창
|
|
|
|
CustomTkinter 기반 최소 GUI로 다음 기능을 제공합니다:
|
|
- 다운로드 진행률 표시 (Progress Bar)
|
|
- 상태 메시지 표시
|
|
- 성공/실패 결과 표시
|
|
|
|
실행 흐름:
|
|
1. config.json 로드
|
|
2. zip 다운로드
|
|
3. 압축 해제 및 파일 교체
|
|
4. 메인 프로그램 재실행
|
|
"""
|
|
|
|
CONFIG_FILENAME = "voc_updater_config.json"
|
|
|
|
def __init__(self):
|
|
"""UpdaterGUI 초기화"""
|
|
super().__init__()
|
|
|
|
self.title("VOC 모니터링 업데이터")
|
|
self.geometry("450x280")
|
|
self.resizable(False, False)
|
|
|
|
# 창을 화면 중앙에 배치
|
|
self._center_window()
|
|
|
|
# 변수
|
|
self.config: Optional[UpdateConfig] = None
|
|
self.download_path: Optional[Path] = None
|
|
|
|
# UI 구성
|
|
self._setup_ui()
|
|
|
|
# 업데이트 시작
|
|
self.after(100, self._start_update)
|
|
|
|
def _center_window(self):
|
|
"""창을 화면 중앙에 배치"""
|
|
self.update_idletasks()
|
|
width = 450
|
|
height = 280
|
|
x = (self.winfo_screenwidth() // 2) - (width // 2)
|
|
y = (self.winfo_screenheight() // 2) - (height // 2)
|
|
self.geometry(f'{width}x{height}+{x}+{y}')
|
|
|
|
def _setup_ui(self):
|
|
"""UI 구성"""
|
|
# 메인 프레임
|
|
self.main_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
self.main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
|
|
|
# 아이콘/로고 라벨
|
|
self.icon_label = ctk.CTkLabel(
|
|
self.main_frame,
|
|
text="🔄",
|
|
font=ctk.CTkFont(size=48)
|
|
)
|
|
self.icon_label.pack(pady=(0, 10))
|
|
|
|
# 상태 라벨
|
|
self.status_label = ctk.CTkLabel(
|
|
self.main_frame,
|
|
text="업데이트 준비 중...",
|
|
font=ctk.CTkFont(size=14, weight="bold")
|
|
)
|
|
self.status_label.pack(pady=(0, 10))
|
|
|
|
# 진행률 바
|
|
self.progress_bar = ctk.CTkProgressBar(
|
|
self.main_frame,
|
|
width=400,
|
|
height=20
|
|
)
|
|
self.progress_bar.set(0)
|
|
self.progress_bar.pack(pady=(0, 10))
|
|
|
|
# 세부 정보 라벨
|
|
self.detail_label = ctk.CTkLabel(
|
|
self.main_frame,
|
|
text="",
|
|
font=ctk.CTkFont(size=11),
|
|
text_color="gray"
|
|
)
|
|
self.detail_label.pack(pady=(0, 10))
|
|
|
|
# 버튼 프레임 (초기에는 숨김)
|
|
self.button_frame = ctk.CTkFrame(self.main_frame, fg_color="transparent")
|
|
|
|
self.close_button = ctk.CTkButton(
|
|
self.button_frame,
|
|
text="닫기",
|
|
width=100,
|
|
command=self._on_close
|
|
)
|
|
self.close_button.pack(side="left", padx=5)
|
|
|
|
self.retry_button = ctk.CTkButton(
|
|
self.button_frame,
|
|
text="재시도",
|
|
width=100,
|
|
command=self._start_update
|
|
)
|
|
self.retry_button.pack(side="left", padx=5)
|
|
|
|
def _update_status(self, status: str, detail: str = ""):
|
|
"""상태 업데이트"""
|
|
self.status_label.configure(text=status)
|
|
self.detail_label.configure(text=detail)
|
|
self.update_idletasks()
|
|
|
|
def _update_progress(self, value: float, detail: str = ""):
|
|
"""진행률 업데이트"""
|
|
self.progress_bar.set(value)
|
|
if detail:
|
|
self.detail_label.configure(text=detail)
|
|
self.update_idletasks()
|
|
|
|
def _show_buttons(self, show_retry: bool = False):
|
|
"""버튼 표시"""
|
|
self.retry_button.pack_forget()
|
|
if show_retry:
|
|
self.retry_button.pack(side="left", padx=5)
|
|
self.button_frame.pack(pady=10)
|
|
|
|
def _load_config(self) -> UpdateConfig:
|
|
"""
|
|
config.json 로드
|
|
|
|
Returns:
|
|
UpdateConfig: 업데이트 구성 정보
|
|
|
|
Raises:
|
|
ConfigError: 설정 파일을 찾을 수 없거나 파싱 실패
|
|
"""
|
|
config_path = Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
|
|
|
|
if not config_path.exists():
|
|
raise ConfigError(f"설정 파일을 찾을 수 없습니다: {config_path}")
|
|
|
|
try:
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
config = UpdateConfig(
|
|
download_url=data.get('download_url', ''),
|
|
target_path=data.get('target_path', ''),
|
|
version=data.get('version', ''),
|
|
restart_exe=data.get('restart_exe', 'voc_noti.exe')
|
|
)
|
|
|
|
logger.info(f"설정 로드 완료: 버전 {config.version}")
|
|
return config
|
|
|
|
except json.JSONDecodeError as e:
|
|
raise ConfigError(f"설정 파일 파싱 실패: {e}")
|
|
except KeyError as e:
|
|
raise ConfigError(f"필수 설정 누락: {e}")
|
|
|
|
def _download_zip(self) -> Path:
|
|
"""
|
|
zip 파일 다운로드
|
|
|
|
Returns:
|
|
Path: 다운로드된 zip 파일 경로
|
|
|
|
Raises:
|
|
DownloadError: 다운로드 실패
|
|
"""
|
|
if not self.config:
|
|
raise DownloadError("설정이 로드되지 않았습니다.")
|
|
|
|
url = self.config.download_url
|
|
self._update_status("다운로드 중...", "연결 중...")
|
|
|
|
try:
|
|
# 파일명 추출
|
|
parsed = urlparse(url)
|
|
filename = os.path.basename(parsed.path)
|
|
if not filename.endswith('.zip'):
|
|
filename = f"voc_noti_{self.config.version}.zip"
|
|
|
|
self.download_path = Path(tempfile.gettempdir()) / filename
|
|
|
|
# 다운로드 (스트리밍)
|
|
response = requests.get(url, stream=True, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
total_size = int(response.headers.get('content-length', 0))
|
|
downloaded = 0
|
|
chunk_size = 8192
|
|
|
|
with open(self.download_path, 'wb') as f:
|
|
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
if chunk:
|
|
f.write(chunk)
|
|
downloaded += len(chunk)
|
|
|
|
if total_size > 0:
|
|
progress = downloaded / total_size
|
|
self._update_progress(
|
|
progress * 0.7, # 전체의 70%
|
|
f"{downloaded / 1024 / 1024:.1f} MB / {total_size / 1024 / 1024:.1f} MB"
|
|
)
|
|
|
|
logger.info(f"다운로드 완료: {self.download_path}")
|
|
return self.download_path
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
raise DownloadError(f"다운로드 실패: {e}")
|
|
|
|
def _extract_and_replace(self, zip_path: Path) -> tuple[bool, str]:
|
|
"""
|
|
압축 해제 및 파일 교체
|
|
|
|
Args:
|
|
zip_path: zip 파일 경로
|
|
|
|
Returns:
|
|
tuple[bool, str]: (성공 여부, 메시지)
|
|
"""
|
|
if not self.config:
|
|
return False, "설정이 로드되지 않았습니다."
|
|
|
|
target_path = Path(self.config.target_path)
|
|
backup_path: Optional[Path] = None
|
|
|
|
self._update_status("설치 중...", "파일 교체 중...")
|
|
self._update_progress(0.75)
|
|
|
|
try:
|
|
# 백업 폴더 생성
|
|
backup_path = target_path.parent / f"{target_path.name}_backup_{self.config.version}"
|
|
if backup_path.exists():
|
|
shutil.rmtree(backup_path)
|
|
|
|
# 기존 파일 백업
|
|
if target_path.exists():
|
|
shutil.copytree(target_path, backup_path)
|
|
logger.info(f"백업 생성: {backup_path}")
|
|
|
|
# 압축 해제
|
|
self._update_progress(0.8, "압축 해제 중...")
|
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
zip_ref.extractall(target_path.parent)
|
|
|
|
self._update_progress(0.95, "완료 중...")
|
|
logger.info(f"압축 해제 완료: {target_path}")
|
|
|
|
# 임시 파일 정리
|
|
if zip_path.exists():
|
|
zip_path.unlink()
|
|
|
|
return True, "설치 완료"
|
|
|
|
except zipfile.BadZipFile as e:
|
|
return False, f"손상된 zip 파일: {e}"
|
|
except PermissionError as e:
|
|
return False, f"파일 권한 오류: {e}"
|
|
except Exception as e:
|
|
# 롤백
|
|
if backup_path and backup_path.exists():
|
|
logger.info("롤백 중...")
|
|
if target_path.exists():
|
|
shutil.rmtree(target_path)
|
|
shutil.move(str(backup_path), str(target_path))
|
|
return False, f"설치 실패: {e}"
|
|
|
|
def _restart_main_app(self) -> bool:
|
|
"""
|
|
메인 앱 재실행
|
|
|
|
Returns:
|
|
bool: 실행 성공 여부
|
|
"""
|
|
if not self.config:
|
|
return False
|
|
|
|
target_path = Path(self.config.target_path)
|
|
exe_path = target_path / self.config.restart_exe
|
|
|
|
if not exe_path.exists():
|
|
logger.warning(f"실행 파일을 찾을 수 없습니다: {exe_path}")
|
|
return False
|
|
|
|
try:
|
|
subprocess.Popen(
|
|
[str(exe_path)],
|
|
cwd=str(target_path),
|
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
|
)
|
|
logger.info(f"메인 앱 실행: {exe_path}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"메인 앱 실행 실패: {e}")
|
|
return False
|
|
|
|
def _start_update(self):
|
|
"""업데이트 시작"""
|
|
try:
|
|
# 설정 로드
|
|
self.config = self._load_config()
|
|
self._update_status(
|
|
f"버전 {self.config.version}으로 업데이트",
|
|
"준비 중..."
|
|
)
|
|
|
|
# 다운로드
|
|
zip_path = self._download_zip()
|
|
|
|
# 설치
|
|
success, message = self._extract_and_replace(zip_path)
|
|
|
|
if success:
|
|
self._update_progress(1.0)
|
|
self._update_status("업데이트 완료! ✓", message)
|
|
|
|
# 메인 앱 재실행
|
|
self._restart_main_app()
|
|
|
|
# 3초 후 종료
|
|
self.after(3000, self._on_close)
|
|
else:
|
|
self._update_status("업데이트 실패 ✗", message)
|
|
self._show_buttons(show_retry=True)
|
|
|
|
except ConfigError as e:
|
|
self._update_status("설정 오류 ✗", str(e))
|
|
self._show_buttons(show_retry=False)
|
|
except DownloadError as e:
|
|
self._update_status("다운로드 실패 ✗", str(e))
|
|
self._show_buttons(show_retry=True)
|
|
except Exception as e:
|
|
logger.exception("알 수 없는 오류")
|
|
self._update_status("알 수 없는 오류 ✗", str(e))
|
|
self._show_buttons(show_retry=True)
|
|
|
|
def _on_close(self):
|
|
"""창 닫기"""
|
|
# 임시 파일 정리
|
|
if self.download_path and self.download_path.exists():
|
|
try:
|
|
self.download_path.unlink()
|
|
except (OSError, PermissionError):
|
|
pass
|
|
|
|
# config.json 정리
|
|
config_path = Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
|
|
if config_path.exists():
|
|
try:
|
|
config_path.unlink()
|
|
except (OSError, PermissionError):
|
|
pass
|
|
|
|
self.destroy()
|
|
|
|
|
|
# ============================================================================
|
|
# 메인 실행
|
|
# ============================================================================
|
|
|
|
def main():
|
|
"""updater.exe 진입점"""
|
|
app = UpdaterGUI()
|
|
app.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|