import os import sys import json import requests import logging from enum import Enum from supabase import create_client, Client from packaging import version import webbrowser from dataclasses import dataclass from typing import Optional, List, Dict, Any, Callable from datetime import datetime from .__version__ import __program_id__ from PySide6.QtWidgets import ( QMessageBox, QWidget, QCheckBox, QVBoxLayout, QTextBrowser, QDialog, QProgressBar, QPushButton, QHBoxLayout, QLabel ) from PySide6.QtCore import QThread, Signal, Qt from updateManager.update_history import UpdateHistory from release_note_dialog import ReleaseNoteDialog import markdown2 from updateManager.update_types import UpdateInfo, UpdateLevel class UpdateLevel(Enum): """업데이트 등급을 정의하는 열거형 클래스""" PATCH = "patch" # 버그 수정 MINOR = "minor" # 기능 추가/개선 MAJOR = "major" # 주요 변경 @dataclass class UpdateInfo: """업데이트 정보를 담는 데이터 클래스""" version: str download_url: str update_level: UpdateLevel release_notes: str release_date: datetime program_id: str is_mandatory: bool = False class VersionManager: def __init__(self, logger: logging.Logger, supabase_manager, current_version: str = __program_id__): """ 버전 관리자 초기화 Args: logger: 로깅을 위한 Logger 인스턴스 supabase_manager: Supabase 연결 관리자 current_version: 현재 프로그램 버전 """ self.logger = logger self.program_id = __program_id__ self.current_version = current_version self.supabase = supabase_manager.client self.update_history = UpdateHistory(logger) def check_for_updates(self) -> Optional[UpdateInfo]: """ 서버에서 버전 정보를 가져와 현재 버전과 비교합니다. 업데이트가 필요한 경우 UpdateInfo를 반환하고, 그렇지 않으면 None을 반환합니다. """ try: # Supabase에서 프로그램에 대한 모든 버전 정보 가져오기 versions_info = self.supabase.table('program_versions') \ .select('*') \ .eq('program_id', self.program_id) \ .execute() if not versions_info.data: self.logger.log("버전 정보를 가져올 수 없습니다.", level=logging.ERROR) return None self.logger.log(f"가져온 버전 정보: {versions_info.data}", level=logging.DEBUG) # 현재 버전보다 높은 버전 필터링 current = version.parse(self.current_version) higher_versions = [] for ver_info in versions_info.data: try: ver = version.parse(ver_info['version']) if ver > current: higher_versions.append((ver, ver_info)) except Exception as ve: self.logger.log(f"버전 파싱 오류: {ver_info['version']}: {str(ve)}", level=logging.ERROR) if not higher_versions: self.logger.log(f"최신 버전을 사용 중입니다. (프로그램: {self.program_id}, 현재 버전: {self.current_version})", level=logging.INFO) return None # 가장 높은 버전 선택 highest_version_info = max(higher_versions, key=lambda x: x[0])[1] self.logger.log(f"선택된 최신 버전 정보: {highest_version_info}", level=logging.DEBUG) # UpdateInfo 객체 생성 update_info = UpdateInfo( version=highest_version_info['version'], download_url=highest_version_info['download_url'], update_level=UpdateLevel(highest_version_info['update_level']), release_notes=highest_version_info['release_notes'], release_date=highest_version_info['release_date'], program_id=highest_version_info['program_id'], is_mandatory=highest_version_info.get('is_mandatory', False) ) self.logger.log( f"업데이트가 필요합니다. (프로그램: {self.program_id}, " f"현재: {self.current_version}, 최신: {update_info.version}, " f"등급: {update_info.update_level.value})" ) return update_info except Exception as e: self.logger.log(f"업데이트 확인 중 오류 발생: {str(e)}", level=logging.ERROR, exc_info=True) return None def get_update_history(self) -> List[Dict[str, Any]]: """업데이트 히스토리를 가져옵니다.""" return self.update_history.get_history() def download_update(self, update_info: UpdateInfo, parent: Optional[QWidget] = None) -> bool: """ 업데이트 파일을 임시 폴더에 다운로드하고 실행합니다. Args: update_info: 업데이트 정보 객체 parent: 부모 위젯 (옵션) Returns: bool: 다운로드 및 실행 성공 여부 """ try: import tempfile import subprocess import platform from urllib.parse import urlparse # 임시 폴더 생성 temp_dir = tempfile.gettempdir() parsed_url = urlparse(update_info.download_url) file_name = os.path.basename(parsed_url.path) if not file_name: file_name = f"AutoPercenty_update_{update_info.version}.exe" download_path = os.path.join(temp_dir, file_name) self.logger.log(f"업데이트 다운로드 시작: {update_info.download_url}", level=logging.INFO) self.logger.log(f"저장 경로: {download_path}", level=logging.INFO) # 프로그레스 대화상자 표시 download_dialog = DownloadDialog( url=update_info.download_url, save_path=download_path, logger=self.logger, parent=parent, is_mandatory=update_info.is_mandatory ) result = download_dialog.exec_() if result == QDialog.Accepted and download_dialog.download_completed: # 다운로드 성공 시 파일 실행 self.logger.log(f"다운로드 성공, 설치 프로그램 실행: {download_dialog.download_path}", level=logging.INFO) # 파일 실행 if platform.system() == "Windows": subprocess.Popen([download_dialog.download_path]) elif platform.system() == "Darwin": # macOS subprocess.Popen(["open", download_dialog.download_path]) else: # Linux subprocess.Popen(["xdg-open", download_dialog.download_path]) self.logger.log(f"업데이트 프로그램 실행 완료: {download_dialog.download_path}", level=logging.INFO) return True else: # 다운로드 취소 또는 실패 self.logger.log("업데이트 다운로드가 취소되었거나 실패했습니다.", level=logging.INFO) return False except Exception as e: self.logger.log(f"업데이트 다운로드 중 오류 발생: {str(e)}", level=logging.ERROR, exc_info=True) return False def show_release_note(self, update_info: Dict[str, Any], parent: Optional[QWidget] = None) -> bool: """ 릴리즈 노트를 보여주는 대화상자를 표시합니다. :param update_info: 업데이트 정보 딕셔너리 :param parent: 부모 위젯 (옵션) :return: 사용자가 '다음에 보기'를 선택했는지 여부 """ try: # QDialog 대신 QMessageBox 사용 dialog = QMessageBox(parent) dialog.setWindowTitle(f"릴리즈 노트 - 버전 {update_info.get('version', '')}") # 메인 레이아웃 layout = QVBoxLayout() # 릴리즈 노트 텍스트 브라우저 text_browser = QTextBrowser() text_browser.setOpenExternalLinks(True) # 마크다운을 HTML로 변환 release_note = update_info.get('release_notes', '') html_content = markdown2.markdown(release_note) text_browser.setHtml(html_content) # 레이아웃에 위젯 추가 layout.addWidget(text_browser) # "다음부터 보지 않기" 체크박스 dont_show_checkbox = QCheckBox("다음부터 보지 않기") layout.addWidget(dont_show_checkbox) # QMessageBox에 커스텀 레이아웃 설정 dialog.setLayout(layout) # 확인 버튼만 표시 dialog.setStandardButtons(QMessageBox.Ok) # 대화상자 표시 dialog.exec_() # 체크박스 상태 반환 return dont_show_checkbox.isChecked() except Exception as e: self.logger.log(f"릴리즈 노트 표시 중 오류 발생: {str(e)}", level=logging.ERROR, exc_info=True) return False class DownloadThread(QThread): progress_signal = Signal(int) speed_signal = Signal(str) finished_signal = Signal(str) error_signal = Signal(str) def __init__(self, url: str, save_path: str, logger: logging.Logger): super().__init__() self.url = url self.save_path = save_path self.logger = logger self.is_canceled = False def run(self): try: import time response = requests.get(self.url, stream=True) response.raise_for_status() total_size = int(response.headers.get('content-length', 0)) downloaded_size = 0 chunk_size = 1024 * 1024 # 1MB 단위로 다운로드 start_time = time.time() last_update_time = start_time with open(self.save_path, 'wb') as f: for chunk in response.iter_content(chunk_size=chunk_size): if self.is_canceled: self.logger.log("다운로드가 취소되었습니다.", level=logging.INFO) if os.path.exists(self.save_path): os.remove(self.save_path) return if chunk: f.write(chunk) downloaded_size += len(chunk) progress = int((downloaded_size / total_size) * 100) if total_size > 0 else 0 # 1초에 한번만 UI 업데이트 (너무 자주 업데이트하면 UI가 느려짐) current_time = time.time() if current_time - last_update_time >= 0.5: # 0.5초마다 업데이트 # 다운로드 속도 계산 elapsed_time = current_time - start_time if elapsed_time > 0: speed = downloaded_size / elapsed_time # 바이트/초 # 남은 시간 추정 if speed > 0 and total_size > 0: remaining_bytes = total_size - downloaded_size remaining_seconds = remaining_bytes / speed # 포맷팅 if speed < 1024: speed_str = f"{speed:.1f} B/s" elif speed < 1024 * 1024: speed_str = f"{speed/1024:.1f} KB/s" else: speed_str = f"{speed/(1024*1024):.1f} MB/s" if remaining_seconds < 60: time_str = f"{int(remaining_seconds)}초" elif remaining_seconds < 3600: time_str = f"{int(remaining_seconds/60)}분 {int(remaining_seconds%60)}초" else: time_str = f"{int(remaining_seconds/3600)}시간 {int((remaining_seconds%3600)/60)}분" speed_info = f"{speed_str} (남은 시간: {time_str})" self.speed_signal.emit(speed_info) self.progress_signal.emit(progress) last_update_time = current_time self.logger.log(f"다운로드 진행률: {progress:.1f}%", level=logging.DEBUG) self.logger.log(f"다운로드 완료: {self.save_path}", level=logging.INFO) self.finished_signal.emit(self.save_path) except Exception as e: self.logger.log(f"다운로드 중 오류 발생: {str(e)}", level=logging.ERROR, exc_info=True) self.error_signal.emit(str(e)) if os.path.exists(self.save_path): os.remove(self.save_path) def cancel(self): self.is_canceled = True class DownloadDialog(QDialog): def __init__(self, url: str, save_path: str, logger: logging.Logger, parent=None, is_mandatory: bool = False): super().__init__(parent) self.logger = logger self.download_completed = False self.download_path = "" self.is_mandatory = is_mandatory self.setWindowTitle("업데이트 다운로드") self.setMinimumSize(450, 180) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setStyleSheet(""" QDialog { background-color: #f0f2f5; font-size: 13px; } QLabel { color: #333; font-size: 13px; } QProgressBar { border: 1px solid #ccc; border-radius: 4px; text-align: center; background-color: #f5f5f5; min-height: 22px; } QProgressBar::chunk { background-color: #4CAF50; border-radius: 3px; } QPushButton { background-color: #f0f0f0; color: #333; padding: 8px 15px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; min-width: 100px; } QPushButton:hover { background-color: #e4e6e9; } QPushButton#cancelButton { background-color: #f44336; color: white; border: none; } QPushButton#cancelButton:hover { background-color: #d32f2f; } """) # 메인 레이아웃 layout = QVBoxLayout() layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) # 제목 title_label = QLabel("업데이트를 다운로드하는 중입니다") title_label.setStyleSheet("font-size: 16px; font-weight: bold;") layout.addWidget(title_label) # 다운로드 중 메시지 self.status_label = QLabel("업데이트 파일을 다운로드하는 중입니다. 잠시만 기다려주세요...") layout.addWidget(self.status_label) # 프로그레스 바 self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.setFormat("%p%") self.progress_bar.setMinimumHeight(25) layout.addWidget(self.progress_bar) # 파일 정보 self.file_info_label = QLabel(f"파일 저장 위치: {save_path}") self.file_info_label.setStyleSheet("font-size: 11px; color: #666;") layout.addWidget(self.file_info_label) # 버튼 영역 button_layout = QHBoxLayout() button_layout.addStretch() self.cancel_button = QPushButton("취소") self.cancel_button.setObjectName("cancelButton") self.cancel_button.clicked.connect(self.cancel_download) button_layout.addWidget(self.cancel_button) layout.addLayout(button_layout) self.setLayout(layout) # 다운로드 스레드 초기화 및 시작 self.download_thread = DownloadThread(url, save_path, logger) self.download_thread.progress_signal.connect(self.update_progress) self.download_thread.speed_signal.connect(self.update_speed) self.download_thread.finished_signal.connect(self.download_finished) self.download_thread.error_signal.connect(self.download_error) self.download_thread.start() def update_progress(self, value): self.progress_bar.setValue(value) if value > 0: # 다운로드 속도 정보 등을 추가할 수 있음 self.status_label.setText(f"다운로드 중... {value}% 완료") def update_speed(self, speed_info): self.status_label.setText(f"다운로드 중... {speed_info}") def download_finished(self, file_path): self.download_completed = True self.download_path = file_path self.status_label.setText("다운로드가 완료되었습니다. 설치를 시작합니다...") self.progress_bar.setValue(100) self.progress_bar.setStyleSheet("QProgressBar::chunk { background-color: #4CAF50; }") # 잠시 대기 후 대화상자 닫기 (사용자가 완료 메시지를 볼 수 있도록) from PySide6.QtCore import QTimer QTimer.singleShot(1500, self.accept) def download_error(self, error_message): self.progress_bar.setStyleSheet("QProgressBar::chunk { background-color: #f44336; }") QMessageBox.critical(self, "다운로드 오류", f"다운로드 중 오류가 발생했습니다:\n{error_message}") self.reject() def cancel_download(self): if self.is_mandatory: reply = QMessageBox.warning( self, "필수 업데이트", "이 업데이트는 필수 업데이트입니다. 취소할 수 없습니다.\n계속 다운로드 하시겠습니까?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) if reply == QMessageBox.No: # 사용자가 명시적으로 No 선택 시 취소 허용 self.status_label.setText("다운로드를 취소하는 중...") self.download_thread.cancel() self.reject() else: # 일반 업데이트 reply = QMessageBox.question( self, "다운로드 취소", "정말로 업데이트 다운로드를 취소하시겠습니까?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: self.status_label.setText("다운로드를 취소하는 중...") self.download_thread.cancel() self.reject() def closeEvent(self, event): if not self.download_completed: if self.is_mandatory: # 필수 업데이트인 경우 닫기 버튼(X)을 통한 취소를 막음 reply = QMessageBox.warning( self, "필수 업데이트", "이 업데이트는 필수 업데이트입니다. 취소할 수 없습니다.\n계속 다운로드 하시겠습니까?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) if reply == QMessageBox.Yes: # 계속 다운로드 event.ignore() else: # 사용자가 명시적으로 No 선택 시 종료 허용 self.download_thread.cancel() event.accept() else: # 일반 업데이트인 경우 reply = QMessageBox.question( self, "다운로드 취소", "다운로드가 진행 중입니다. 정말로 취소하시겠습니까?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: self.download_thread.cancel() event.accept() else: event.ignore() else: event.accept()