VOC_Monitor/app/updater/updater_gui.py

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()