901 lines
37 KiB
Python
901 lines
37 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, QLineEdit, QComboBox, QHBoxLayout, QLabel, QDialog, QProgressBar, QPushButton, QListWidget, QListWidgetItem
|
|
)
|
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer
|
|
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
|
|
is_stable: bool = True # True=안정버전, 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),
|
|
is_stable=highest_version_info.get('is_stable', True)
|
|
)
|
|
|
|
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 list_available_versions(self, limit: int = 10) -> List[UpdateInfo]:
|
|
"""
|
|
최신 버전부터 최대 `limit`개까지 반환한다.
|
|
(현재 버전보다 낮은 버전도 포함 → 롤백 지원)
|
|
"""
|
|
try:
|
|
rows = (
|
|
self.supabase.table('program_versions')
|
|
.select('*')
|
|
.eq('program_id', self.program_id)
|
|
.order('release_date', desc=True)
|
|
.limit(limit)
|
|
.execute()
|
|
.data
|
|
)
|
|
|
|
updates: list[UpdateInfo] = []
|
|
for row in rows:
|
|
try:
|
|
# 디버깅: 각 버전의 update_level과 is_stable 값 로그 출력
|
|
self.logger.log(
|
|
f"버전 {row['version']}: update_level={row.get('update_level', 'None')}, "
|
|
f"is_stable={row.get('is_stable', 'None')} (기본값: True)",
|
|
level=logging.INFO
|
|
)
|
|
|
|
updates.append(
|
|
UpdateInfo(
|
|
version=row['version'],
|
|
download_url=row['download_url'],
|
|
update_level=UpdateLevel(row['update_level']),
|
|
release_notes=row['release_notes'],
|
|
release_date=row['release_date'],
|
|
program_id=row['program_id'],
|
|
is_mandatory=row.get('is_mandatory', False),
|
|
is_stable=row.get('is_stable', True),
|
|
)
|
|
)
|
|
except Exception as ve:
|
|
self.logger.log(f"버전 파싱 오류: {row}: {ve}", level=logging.ERROR)
|
|
|
|
return updates
|
|
except Exception as e:
|
|
self.logger.log(f"버전 목록 조회 실패: {e}", level=logging.ERROR, exc_info=True)
|
|
return []
|
|
|
|
# 선택 버전 1개만 얻고 싶을 때 사용
|
|
def fetch_update_info(self, version_str: str) -> Optional[UpdateInfo]:
|
|
for info in self.list_available_versions(limit=20):
|
|
if info.version == version_str:
|
|
return info
|
|
return None
|
|
|
|
|
|
def download_update(self, update_info: UpdateInfo, parent: Optional[QWidget] = None) -> bool:
|
|
"""
|
|
업데이트 파일을 임시 폴더에 다운로드하고 실행합니다.
|
|
|
|
Args:
|
|
update_info: 업데이트 정보 객체
|
|
parent: 부모 위젯 (옵션)
|
|
|
|
Returns:
|
|
bool: 다운로드 및 실행 성공 여부
|
|
"""
|
|
try:
|
|
import tempfile
|
|
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"EditPartTimer_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_path}", level=logging.INFO)
|
|
return download_dialog.download_path
|
|
else:
|
|
self.logger.log("업데이트 다운로드가 취소되었거나 실패했습니다.", level=logging.INFO)
|
|
return None
|
|
|
|
except Exception as e:
|
|
self.logger.log(f"업데이트 다운로드 중 오류 발생: {str(e)}", level=logging.ERROR, exc_info=True)
|
|
return None
|
|
|
|
|
|
def run_update_file(self, file_path: str) -> bool:
|
|
"""
|
|
다운로드된 업데이트 파일(설치 프로그램)을 실행합니다.
|
|
|
|
Args:
|
|
file_path: 실행할 파일 경로
|
|
|
|
Returns:
|
|
bool: 실행 성공 여부
|
|
"""
|
|
try:
|
|
import subprocess
|
|
import platform
|
|
|
|
self.logger.log(f"설치 프로그램 실행: {file_path}", level=logging.INFO)
|
|
|
|
if platform.system() == "Windows":
|
|
subprocess.Popen([file_path])
|
|
elif platform.system() == "Darwin": # macOS
|
|
subprocess.Popen(["open", file_path])
|
|
else: # Linux
|
|
subprocess.Popen(["xdg-open", file_path])
|
|
|
|
self.logger.log(f"업데이트 프로그램 실행 완료: {file_path}", level=logging.INFO)
|
|
return True
|
|
|
|
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; }")
|
|
|
|
# 잠시 대기 후 대화상자 닫기 (사용자가 완료 메시지를 볼 수 있도록)
|
|
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()
|
|
|
|
|
|
class MultiVersionUpdateDialog(QDialog):
|
|
"""
|
|
최근 N개 버전을 목록으로 보여주고,
|
|
선택 시 릴리즈 노트를 출력하며
|
|
[업데이트/롤백] 버튼으로 해당 버전을 설치할 수 있는 대화상자.
|
|
"""
|
|
def __init__(
|
|
self,
|
|
update_infos: list[UpdateInfo],
|
|
current_version: str,
|
|
parent=None,
|
|
logger: logging.Logger | None = None,
|
|
):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("업데이트 / 롤백 관리자")
|
|
self.setMinimumSize(640, 480)
|
|
self.setStyleSheet("QDialog { background:#f0f2f5; }")
|
|
|
|
self.logger = logger or logging.getLogger(__name__)
|
|
self.update_infos = update_infos
|
|
self.current_version = version.parse(current_version)
|
|
self.selected_info: UpdateInfo | None = None
|
|
# 자동 종료용 타이머 및 남은 초
|
|
self.auto_close_timer = QTimer(self)
|
|
self.auto_close_timer.setInterval(1000)
|
|
self.auto_close_timer.timeout.connect(self._on_auto_close_tick)
|
|
self.countdown_remaining: int = 5
|
|
|
|
# ── UI ──────────────────────────────────────────────
|
|
main = QHBoxLayout(self)
|
|
main.setContentsMargins(12, 12, 12, 12)
|
|
|
|
# 왼쪽 영역: 채널 선택 + 버전 목록
|
|
left_container = QVBoxLayout()
|
|
|
|
# 채널 선택 콤보박스
|
|
self.channel_combo = QComboBox()
|
|
self.channel_combo.addItems(["안정 버전", "실험 버전"])
|
|
left_container.addWidget(self.channel_combo)
|
|
|
|
# 버전 목록
|
|
self.list_widget = QListWidget()
|
|
left_container.addWidget(self.list_widget, 1)
|
|
|
|
main.addLayout(left_container, 35)
|
|
|
|
# 모든 버전 보관 및 초기 필터 적용
|
|
self.all_update_infos = update_infos
|
|
self.channel = 'stable'
|
|
self.filtered_infos: list[UpdateInfo] = []
|
|
# has_update 초기화 (버튼 상태 계산 전에 사용될 수 있음)
|
|
self.has_update: bool = False
|
|
|
|
self.channel_combo.currentIndexChanged.connect(self._on_channel_changed)
|
|
|
|
# ── 업데이트 존재 여부 확인 후 별도 알림 ───────────────
|
|
# (다이얼로그가 뜨자마자 한 번만 표시)
|
|
self._alert_new_version_once = False # 플래그
|
|
|
|
# 초기 목록 필터만 설정 (리스트/버튼은 UI 생성 후 처리)
|
|
self._filter_update_infos()
|
|
|
|
# 오른쪽: 릴리즈 노트 & 버튼
|
|
right = QVBoxLayout()
|
|
main.addLayout(right, 65)
|
|
|
|
self.note_view = QTextBrowser()
|
|
self.note_view.setOpenExternalLinks(True)
|
|
right.addWidget(self.note_view, 1)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
self.install_btn = QPushButton()
|
|
self.install_btn.setDefault(True)
|
|
self.cancel_btn = QPushButton("닫기")
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(self.install_btn)
|
|
btn_layout.addWidget(self.cancel_btn)
|
|
right.addLayout(btn_layout)
|
|
|
|
# ── 시그널 ───────────────────────────────────────────
|
|
self.list_widget.currentRowChanged.connect(self._on_version_changed)
|
|
self.install_btn.clicked.connect(self._on_install_clicked)
|
|
self.cancel_btn.clicked.connect(self._on_cancel_clicked)
|
|
|
|
# 사용자 상호작용 시 자동 종료 취소 연결
|
|
self.list_widget.itemClicked.connect(lambda *_: self._cancel_auto_close())
|
|
self.channel_combo.activated.connect(lambda *_: self._cancel_auto_close())
|
|
|
|
# UI 생성 후 목록/버튼 초기화
|
|
self._update_install_btn_state()
|
|
self._populate_list()
|
|
|
|
# 최신 버전 알림 (최초 1회)
|
|
if self.has_update and not self._alert_new_version_once:
|
|
QMessageBox.information(self, "새 버전 알림", "최신 버전이 있습니다! 업데이트를 선택하거나 알바생 실행을 계속 진행해 주세요.")
|
|
self._alert_new_version_once = True
|
|
|
|
# 초기 상태 반영은 _populate_list 내부에서 처리됩니다.
|
|
|
|
# 현재 선택이 바뀔 때
|
|
def _on_version_changed(self, row: int):
|
|
# 범위 체크 및 자동 종료 취소
|
|
self._cancel_auto_close()
|
|
# 범위 체크
|
|
if row < 0 or row >= len(self.filtered_infos):
|
|
return
|
|
self.selected_info = self.filtered_infos[row]
|
|
# 릴리즈 노트 표시
|
|
html = markdown2.markdown(self.selected_info.release_notes)
|
|
self.note_view.setHtml(html)
|
|
|
|
sel_ver = version.parse(self.selected_info.version)
|
|
|
|
# 버튼/텍스트 결정 (채널별 규칙)
|
|
if sel_ver == self.current_version:
|
|
# 현재 버전 선택
|
|
self.install_btn.hide()
|
|
if self.auto_close_timer.isActive():
|
|
self.cancel_btn.setText(f"알바생 실행 ({self.countdown_remaining})")
|
|
else:
|
|
self.cancel_btn.setText("알바생 실행")
|
|
else:
|
|
# 다른 버전 선택
|
|
self.install_btn.show()
|
|
|
|
if self.channel == 'experimental':
|
|
# 실험 버전에서는 항상 업데이트
|
|
self.install_btn.setText("업데이트")
|
|
else:
|
|
# 안정 채널: 버전 비교로 업데이트/롤백 결정
|
|
if sel_ver > self.current_version:
|
|
self.install_btn.setText("업데이트")
|
|
else:
|
|
self.install_btn.setText("롤백")
|
|
self.cancel_btn.setText("닫기")
|
|
|
|
# 필수 업데이트라면 닫기 불가
|
|
if self.selected_info.is_mandatory and sel_ver > self.current_version:
|
|
self.setWindowFlags(self.windowFlags() & ~Qt.WindowCloseButtonHint)
|
|
self.cancel_btn.setDisabled(True)
|
|
else:
|
|
# 채널 변경 등에 의해 다시 열릴 수 있도록 복구
|
|
self.setWindowFlags(self.windowFlags() | Qt.WindowCloseButtonHint)
|
|
self.cancel_btn.setDisabled(False)
|
|
|
|
# 기본 버튼 포커스 설정 (Enter 키 동작)
|
|
if sel_ver == self.current_version:
|
|
self.install_btn.setDefault(False)
|
|
self.cancel_btn.setDefault(True)
|
|
else:
|
|
self.cancel_btn.setDefault(False)
|
|
self.install_btn.setDefault(True)
|
|
|
|
# 채널 변경 시 처리
|
|
def _on_channel_changed(self, index: int):
|
|
self.channel = 'stable' if index == 0 else 'experimental'
|
|
self._filter_update_infos()
|
|
self._update_install_btn_state()
|
|
self._populate_list()
|
|
|
|
# 선택된 채널에 맞게 버전 목록 필터링
|
|
def _filter_update_infos(self):
|
|
# 디버깅: 필터링 전 전체 버전 정보 로그
|
|
self.logger.log(f"필터링 전 전체 버전 수: {len(self.all_update_infos)}", level=logging.INFO)
|
|
for info in self.all_update_infos:
|
|
self.logger.log(
|
|
f"버전 {info.version}: update_level={info.update_level.value}, is_stable={info.is_stable}",
|
|
level=logging.INFO
|
|
)
|
|
|
|
if self.channel == 'stable':
|
|
self.filtered_infos = [i for i in self.all_update_infos if i.is_stable]
|
|
self.logger.log(f"안정 채널 필터링 결과: {len(self.filtered_infos)}개 버전", level=logging.INFO)
|
|
else:
|
|
self.filtered_infos = [i for i in self.all_update_infos if not i.is_stable]
|
|
self.logger.log(f"실험 채널 필터링 결과: {len(self.filtered_infos)}개 버전", level=logging.INFO)
|
|
|
|
# 필터 결과가 비어있으면 전체 목록을 사용해 빈 화면 방지
|
|
if not self.filtered_infos:
|
|
self.logger.log("필터링 결과가 비어있어 전체 목록을 사용합니다.", level=logging.WARNING)
|
|
self.filtered_infos = self.all_update_infos
|
|
|
|
# 리스트 위젯에 버전 표시
|
|
def _populate_list(self):
|
|
self.list_widget.blockSignals(True)
|
|
self.list_widget.clear()
|
|
for info in self.filtered_infos:
|
|
v = version.parse(info.version)
|
|
txt = f"{info.version}"
|
|
# 최신 버전 마킹 (filtered list 기준 첫 번째)
|
|
if v == version.parse(self.filtered_infos[0].version):
|
|
txt += " (최신)"
|
|
if v == self.current_version:
|
|
txt += " [현재]"
|
|
item = QListWidgetItem(txt)
|
|
if info.update_level is UpdateLevel.MAJOR:
|
|
item.setForeground(Qt.red)
|
|
elif info.update_level is UpdateLevel.MINOR:
|
|
item.setForeground(Qt.darkYellow)
|
|
self.list_widget.addItem(item)
|
|
|
|
# 신호 재활성화
|
|
self.list_widget.blockSignals(False)
|
|
|
|
# 리스트 초기화 후 기본 선택 및 포커스 설정
|
|
self._ensure_default_selection_and_focus()
|
|
|
|
# 업데이트 가능 여부에 따라 버튼 및 자동 종료 처리
|
|
def _update_install_btn_state(self):
|
|
# 업데이트 존재 여부
|
|
if self.channel == 'experimental':
|
|
# 실험 버전: 현재와 다른 버전이 하나라도 있으면 업데이트 가능
|
|
self.has_update = any(version.parse(info.version) != self.current_version for info in self.filtered_infos)
|
|
else:
|
|
# 안정 버전: 더 높은 버전이 있어야 업데이트
|
|
self.has_update = any(version.parse(info.version) > self.current_version for info in self.filtered_infos)
|
|
|
|
# 자동 로그인 처리
|
|
if not self.has_update:
|
|
# 업데이트가 전혀 없을 때만 자동 로그인 카운트다운 시작
|
|
self._start_auto_close()
|
|
self.cancel_btn.setText(f"알바생 실행 ({self.countdown_remaining})")
|
|
else:
|
|
self._cancel_auto_close()
|
|
self.cancel_btn.setText("닫기")
|
|
|
|
# 자동 종료 타이머 시작
|
|
def _start_auto_close(self):
|
|
self._cancel_auto_close() # 기존 타이머가 있으면 중지
|
|
self.countdown_remaining = 5
|
|
self.cancel_btn.setText(f"알바생 실행 ({self.countdown_remaining})")
|
|
self.auto_close_timer.start()
|
|
|
|
# 타이머 취소 및 버튼 원복
|
|
def _cancel_auto_close(self):
|
|
if self.auto_close_timer.isActive():
|
|
self.auto_close_timer.stop()
|
|
|
|
# 상호작용 이후 텍스트는 _on_version_changed 에서 재설정
|
|
|
|
# 타이머 Tick
|
|
def _on_auto_close_tick(self):
|
|
self.countdown_remaining -= 1
|
|
if self.countdown_remaining <= 0:
|
|
self.auto_close_timer.stop()
|
|
self.reject() # 다이얼로그만 닫기 (알바생 실행)
|
|
return
|
|
self.cancel_btn.setText(f"알바생 실행 ({self.countdown_remaining})")
|
|
|
|
# 취소(닫기) 버튼 클릭
|
|
def _on_cancel_clicked(self):
|
|
label = self.cancel_btn.text()
|
|
if label.startswith("알바생 실행"):
|
|
# 알바생 실행 → 다이얼로그만 닫기
|
|
self.reject()
|
|
else:
|
|
# 업데이트를 무시하고 프로그램 종료
|
|
sys.exit(0)
|
|
|
|
# reject 동작은 기본(QDialog.reject) 그대로 사용
|
|
|
|
# 설치(업데이트/롤백/재설치) 버튼 클릭 시
|
|
def _on_install_clicked(self):
|
|
if not self.selected_info:
|
|
return
|
|
|
|
sel_ver = version.parse(self.selected_info.version)
|
|
is_experimental = (self.channel == 'experimental')
|
|
|
|
if sel_ver == self.current_version:
|
|
# 현재 버전 선택
|
|
if self.has_update:
|
|
title = "알바생 실행"
|
|
message = "현재 버전으로 계속 알바생을 실행하시겠습니까?"
|
|
else:
|
|
title = "재설치"
|
|
message = f"현재 버전({self.selected_info.version})을 재설치하시겠습니까?"
|
|
else:
|
|
# 다른 버전
|
|
if is_experimental or sel_ver > self.current_version:
|
|
title = "업데이트"
|
|
message = f"선택한 {self.selected_info.version} 버전으로 업데이트하시겠습니까?"
|
|
else:
|
|
title = "롤백"
|
|
message = f"{self.selected_info.version} 버전으로 롤백하시겠습니까?"
|
|
|
|
reply = QMessageBox.question(self, title, message, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
|
if reply == QMessageBox.Yes:
|
|
if title == "알바생 실행":
|
|
# 다이얼로그 닫기만
|
|
self.reject()
|
|
else:
|
|
self.accept()
|
|
# No 선택 시 아무것도 하지 않음
|
|
|
|
# 기본 선택 및 버튼 포커스 결정
|
|
def _ensure_default_selection_and_focus(self):
|
|
if not self.filtered_infos:
|
|
return
|
|
|
|
# 업데이트가 있을 경우(더 최신 버전 존재) → 가장 최신(index 0) 선택
|
|
# 없을 경우 → 현재 버전 행 선택
|
|
if self.has_update:
|
|
target_row = 0
|
|
else:
|
|
target_row = 0
|
|
for idx, info in enumerate(self.filtered_infos):
|
|
if version.parse(info.version) == self.current_version:
|
|
target_row = idx
|
|
break
|
|
|
|
# setCurrentRow 트리거 시 _on_version_changed 호출되어 포커스까지 자동 설정
|
|
self.list_widget.setCurrentRow(target_row)
|