AutoPercenty3/updateManager/version_manager.py

513 lines
21 KiB
Python

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