Integrate updater functionality into AppController, enabling background update checks and manual update prompts. Added configuration management for updater settings via `app/updater/config.json` and enhanced error handling for update processes. Updated `.gitignore` to include new build directories and modified settings structure in `settings.json` to support updater configurations. Improved UI elements in the settings dialog for better usability.
This commit is contained in:
parent
cef29aecd8
commit
501b4e3af6
|
|
@ -1,3 +1,4 @@
|
|||
.venv/
|
||||
__pycache__
|
||||
app/build
|
||||
app/build
|
||||
app/updater_build/
|
||||
|
|
@ -26,8 +26,10 @@
|
|||
버전: 3.0 (리팩토링 - Manager 분리 완료)
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
|
||||
from utils.path_utils import get_base_dir, get_data_dir
|
||||
from utils.database import VOCDatabase
|
||||
|
|
@ -41,6 +43,9 @@ from controllers.notification_manager import NotificationManager
|
|||
from controllers.report_manager import ReportManager
|
||||
from controllers.file_manager import FileManager
|
||||
from controllers.ui_manager import UIManager
|
||||
from updater.update_manager import ConfigError as UpdaterConfigError
|
||||
from updater.update_manager import NetworkError as UpdaterNetworkError
|
||||
from updater.update_manager import create_update_manager_from_settings
|
||||
|
||||
from view.tray_icon import SystemTray
|
||||
|
||||
|
|
@ -89,19 +94,23 @@ class AppController:
|
|||
"""개발 환경과 패키징 환경(cx_Freeze)을 모두 지원하는 베이스 디렉토리 반환"""
|
||||
return get_base_dir()
|
||||
|
||||
def __init__(self, test_mode=False):
|
||||
def __init__(self, test_mode=False, test_update_now=False):
|
||||
"""
|
||||
컨트롤러 초기화
|
||||
|
||||
Args:
|
||||
test_mode (bool): 테스트 모드 여부 (True 시 크롤링 생략)
|
||||
test_update_now (bool): 테스트 모드에서 시작 직후 업데이트 1회 확인 여부
|
||||
"""
|
||||
self.logger = get_logger("Controller")
|
||||
self.test_mode = test_mode
|
||||
self.test_update_now = test_update_now
|
||||
self.base_dir = get_base_dir()
|
||||
self.settings_file = get_data_dir() / "settings.json"
|
||||
|
||||
self.logger.info(f"컨트롤러 초기화 중 (테스트 모드={test_mode})")
|
||||
self.logger.info(
|
||||
f"컨트롤러 초기화 중 (테스트 모드={test_mode}, 테스트 즉시 업데이트 확인={test_update_now})"
|
||||
)
|
||||
|
||||
# 데이터 디렉토리 확인
|
||||
self._ensure_data_dir()
|
||||
|
|
@ -130,6 +139,16 @@ class AppController:
|
|||
|
||||
# 서비스 초기화
|
||||
self.report_service = ReportService()
|
||||
|
||||
# 업데이터 초기화 (통합 단계)
|
||||
self.update_manager = None
|
||||
try:
|
||||
self.update_manager = create_update_manager_from_settings(self.settings)
|
||||
self.logger.info("업데이터 초기화 완료")
|
||||
except UpdaterConfigError as e:
|
||||
self.logger.warning(f"업데이터 초기화 생략 (설정 오류): {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"업데이터 초기화 실패: {e}", exc_info=True)
|
||||
|
||||
# Hidden root 생성 (모든 GUI의 부모)
|
||||
self.root = ctk.CTk()
|
||||
|
|
@ -148,6 +167,11 @@ class AppController:
|
|||
# UI 윈도우 참조
|
||||
self.list_window = None
|
||||
self.settings_window = None
|
||||
|
||||
# 업데이트 알림 중복 방지 상태
|
||||
self._update_prompt_active = False
|
||||
self._last_update_prompt_version = ""
|
||||
self._last_update_prompt_ts = 0.0
|
||||
|
||||
def _ensure_data_dir(self):
|
||||
"""데이터 디렉토리가 존재하는지 확인하고 없으면 생성합니다."""
|
||||
|
|
@ -191,6 +215,13 @@ class AppController:
|
|||
# 과거 알림 키워드 설정 정리 (현재는 수집 설정 키워드/부서를 기준으로 알림 판단)
|
||||
self.settings['noti'].pop('use_keywords', None)
|
||||
self.settings['noti'].pop('keywords', None)
|
||||
|
||||
self.settings.setdefault('update', {})
|
||||
self.settings['update'].setdefault('connection_config_path', 'app/updater/config.json')
|
||||
self.settings['update'].setdefault('environment', 'main')
|
||||
self.settings['update'].setdefault('check_interval_hours', 1)
|
||||
self.settings['update'].setdefault('program_id', 'voc_monitor')
|
||||
self.settings['update'].setdefault('version_table', 'program_versions')
|
||||
self.logger.info("설정 파일 로드 완료")
|
||||
except FileNotFoundError:
|
||||
# 기본 설정 생성
|
||||
|
|
@ -211,6 +242,13 @@ class AppController:
|
|||
"unchecked_delay_enabled": True,
|
||||
"use_related_filter": True
|
||||
},
|
||||
"update": {
|
||||
"connection_config_path": "app/updater/config.json",
|
||||
"environment": "main",
|
||||
"check_interval_hours": 1,
|
||||
"program_id": "voc_monitor",
|
||||
"version_table": "program_versions"
|
||||
},
|
||||
"report": {
|
||||
"output_path": str(get_data_dir() / "reports")
|
||||
}
|
||||
|
|
@ -239,7 +277,7 @@ class AppController:
|
|||
"""
|
||||
self.settings['report_options'] = options
|
||||
self.save_settings()
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 비즈니스 로직 (Business Logic)
|
||||
# ========================================================================
|
||||
|
|
@ -294,11 +332,26 @@ class AppController:
|
|||
|
||||
# 알림 관리자 콜백 설정
|
||||
self.notifier.set_popup_callback(self._create_notification_dialog)
|
||||
|
||||
# 업데이터 백그라운드 체크 시작
|
||||
if self.update_manager:
|
||||
try:
|
||||
if self.test_mode and self.test_update_now:
|
||||
self.logger.info("테스트 모드: 시작 직후 업데이트 1회 확인을 수행합니다.")
|
||||
self.root.after(1200, self._check_updates_once)
|
||||
else:
|
||||
self.update_manager.start_background_check(
|
||||
on_update_available=self._on_update_available,
|
||||
on_error=self._on_update_check_error,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"업데이터 백그라운드 시작 실패: {e}", exc_info=True)
|
||||
|
||||
# 트레이 아이콘 실행
|
||||
import threading
|
||||
threading.Thread(target=self.tray.run, args=(
|
||||
self.scheduler.run_crawling_cycle if self.scheduler else lambda: None, # on_check
|
||||
self.check_updates_manual, # on_update
|
||||
self.open_list_view, # on_list
|
||||
self.open_settings, # on_settings
|
||||
self.quit_app # on_quit
|
||||
|
|
@ -332,6 +385,108 @@ class AppController:
|
|||
"""
|
||||
from view.dialogs.notification_dialog import NotificationDialog
|
||||
NotificationDialog(self, title, msg, voc_id)
|
||||
|
||||
# ========================================================================
|
||||
# 업데이트 관련 (Updater Operations)
|
||||
# ========================================================================
|
||||
|
||||
def check_updates_manual(self):
|
||||
"""트레이 메뉴에서 수동 업데이트 확인"""
|
||||
self.root.after(0, self._check_updates_once)
|
||||
|
||||
def _check_updates_once(self):
|
||||
"""업데이트를 즉시 1회 확인"""
|
||||
if not self.update_manager:
|
||||
messagebox.showwarning("업데이트", "업데이터 설정이 없어 업데이트 확인을 수행할 수 없습니다.")
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info("수동 업데이트 확인 시작")
|
||||
version_info = self.update_manager.check_for_updates()
|
||||
if version_info is None:
|
||||
self.logger.info("수동 업데이트 확인 결과: 최신 버전")
|
||||
self.notifier.show_toast("업데이트", "현재 최신 버전을 사용 중입니다.", with_sound=False)
|
||||
return
|
||||
|
||||
self.logger.info(f"수동 업데이트 확인 결과: 새 버전 {version_info.version}")
|
||||
self._show_update_prompt(version_info)
|
||||
except UpdaterConfigError as e:
|
||||
self.logger.warning(f"업데이트 확인 실패(설정): {e}")
|
||||
messagebox.showerror("업데이트 설정 오류", str(e))
|
||||
except UpdaterNetworkError as e:
|
||||
self.logger.warning(f"업데이트 확인 실패(네트워크): {e}")
|
||||
messagebox.showerror("업데이트 네트워크 오류", str(e))
|
||||
except Exception as e:
|
||||
self.logger.error(f"업데이트 수동 확인 실패: {e}", exc_info=True)
|
||||
messagebox.showerror("업데이트 오류", f"업데이트 확인 중 오류가 발생했습니다.\n{e}")
|
||||
|
||||
def _on_update_available(self, version_info):
|
||||
"""백그라운드 업데이트 발견 콜백"""
|
||||
self.root.after(0, lambda: self._show_update_prompt(version_info))
|
||||
|
||||
def _on_update_check_error(self, error: Exception):
|
||||
"""백그라운드 업데이트 확인 실패 콜백"""
|
||||
self.logger.warning(f"백그라운드 업데이트 확인 실패: {error}")
|
||||
|
||||
def _show_update_prompt(self, version_info):
|
||||
"""업데이트 설치 여부를 사용자에게 확인"""
|
||||
manager = self.update_manager
|
||||
if manager is None:
|
||||
self.logger.warning("업데이터가 초기화되지 않아 업데이트 안내를 중단합니다.")
|
||||
return
|
||||
|
||||
# 중복 알림 방지: 다이얼로그 표시 중에는 추가 표시 금지
|
||||
if self._update_prompt_active:
|
||||
self.logger.info("업데이트 안내창이 이미 표시 중이라 중복 표시를 생략합니다.")
|
||||
return
|
||||
|
||||
# 중복 알림 방지: 동일 버전을 짧은 시간 내 재표시 금지
|
||||
now_ts = time.monotonic()
|
||||
if (
|
||||
self._last_update_prompt_version == version_info.version
|
||||
and (now_ts - self._last_update_prompt_ts) < 60
|
||||
):
|
||||
self.logger.info(
|
||||
f"동일 버전({version_info.version}) 업데이트 안내 중복 감지로 재표시를 생략합니다."
|
||||
)
|
||||
return
|
||||
|
||||
release_note = (version_info.release_note or "-").strip()
|
||||
release_note_preview = release_note[:200] + ("..." if len(release_note) > 200 else "")
|
||||
current_version = manager.current_version
|
||||
msg = (
|
||||
f"새 버전이 있습니다.\n\n"
|
||||
f"현재: {current_version}\n"
|
||||
f"최신: {version_info.version}\n\n"
|
||||
f"배포 노트:\n{release_note_preview}\n\n"
|
||||
f"지금 업데이트를 진행할까요?"
|
||||
)
|
||||
|
||||
self._update_prompt_active = True
|
||||
self._last_update_prompt_version = version_info.version
|
||||
self._last_update_prompt_ts = now_ts
|
||||
|
||||
try:
|
||||
answer = messagebox.askyesno("업데이트 확인", msg)
|
||||
if not answer:
|
||||
self.logger.info("사용자가 업데이트를 나중에 진행하도록 선택")
|
||||
return
|
||||
|
||||
ok, prep_msg = manager.prepare_update(version_info)
|
||||
if not ok:
|
||||
self.logger.error(f"업데이트 준비 실패: {prep_msg}")
|
||||
messagebox.showerror("업데이트 준비 실패", prep_msg)
|
||||
return
|
||||
|
||||
if not manager.launch_updater():
|
||||
self.logger.error("updater.exe 실행 실패")
|
||||
messagebox.showerror("업데이트 실행 실패", "updater.exe 실행에 실패했습니다.")
|
||||
return
|
||||
|
||||
self.logger.info("업데이트 프로세스 시작, 메인 앱 종료")
|
||||
self.quit_app()
|
||||
finally:
|
||||
self._update_prompt_active = False
|
||||
|
||||
# ========================================================================
|
||||
# UI 이벤트 처리 (UI Event Handling) - UIManager에 위임
|
||||
|
|
@ -476,8 +631,19 @@ class AppController:
|
|||
"""
|
||||
설정 변경 사항을 런타임에 반영하고 스케줄러를 재설정합니다.
|
||||
"""
|
||||
if self.scheduler:
|
||||
self.scheduler.update_schedule()
|
||||
try:
|
||||
if self.scheduler:
|
||||
self.scheduler.settings = self.settings
|
||||
self.scheduler.update_schedule()
|
||||
|
||||
if self.update_manager:
|
||||
update_settings = self.settings.get('update', {})
|
||||
self.update_manager.check_interval = int(update_settings.get('check_interval_hours', 1))
|
||||
|
||||
self.logger.info("런타임 설정 반영 완료")
|
||||
except Exception as e:
|
||||
self.logger.error(f"런타임 설정 반영 실패: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
# ========================================================================
|
||||
# 애플리케이션 종료 (Application Shutdown)
|
||||
|
|
@ -495,6 +661,11 @@ class AppController:
|
|||
# 스케줄러 중지
|
||||
if self.scheduler:
|
||||
self.scheduler.stop()
|
||||
|
||||
# 업데이터 백그라운드 중지
|
||||
if self.update_manager:
|
||||
self.update_manager.stop_background_check()
|
||||
self.update_manager.cleanup()
|
||||
|
||||
# 크롤러 종료
|
||||
if self.model:
|
||||
|
|
|
|||
|
|
@ -144,5 +144,12 @@
|
|||
"db_check_interval_minutes": 3,
|
||||
"unchecked_check_interval_minutes": 10,
|
||||
"unchecked_delay_enabled": true
|
||||
},
|
||||
"update": {
|
||||
"connection_config_path": "app/updater/config.json",
|
||||
"environment": "main",
|
||||
"check_interval_hours": 1,
|
||||
"program_id": "voc_monitor",
|
||||
"version_table": "program_versions"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1168,3 +1168,30 @@ KeyError: 'max_set'
|
|||
[2026-02-18 10:47:43] [INFO] [StatisticsService] 통계 보고서 생성 완료
|
||||
[2026-02-18 10:48:19] [INFO] [Controller] 애플리케이션 종료 중...
|
||||
[2026-02-18 10:48:19] [INFO] [Controller] 스케줄러 중지됨
|
||||
[2026-02-18 16:06:01] [INFO] [Main] == Application Startup [TEST MODE] ==
|
||||
[2026-02-18 16:06:01] [INFO] [Controller] 컨트롤러 초기화 중 (테스트 모드=True, 테스트 즉시 업데이트 확인=True)
|
||||
[2026-02-18 16:06:01] [INFO] [Controller] 설정 파일 로드 완료
|
||||
[2026-02-18 16:06:01] [INFO] [Database] DB 연결 성공: D:\py_train\voc_noti\app\data\voc.db
|
||||
[2026-02-18 16:06:01] [INFO] [TimetableService] 시각표 로드 완료: 40929 행 (경로: D:\py_train\voc_noti\app\data\line1_sp_timetable.parquet)
|
||||
[2026-02-18 16:06:02] [INFO] [Controller] 업데이터 초기화 완료
|
||||
[2026-02-18 16:06:02] [INFO] [Controller] 테스트 모드: 웹 크롤링 생략, 로컬 DB 사용.
|
||||
[2026-02-18 16:06:02] [INFO] [Controller] 테스트 모드: 시작 직후 업데이트 1회 확인을 수행합니다.
|
||||
[2026-02-18 16:06:02] [INFO] [Controller] 시작 알림 표시됨
|
||||
[2026-02-18 16:06:03] [INFO] [Controller] 수동 업데이트 확인 시작
|
||||
[2026-02-18 16:06:03] [INFO] [Controller] 수동 업데이트 확인 결과: 새 버전 3.5.5
|
||||
[2026-02-18 16:06:10] [ERROR] [Controller] 업데이트 준비 실패: updater.exe를 찾을 수 없습니다: D:\py_train\voc_noti\updater.exe
|
||||
[2026-02-18 16:06:13] [ERROR] [Controller] 업데이트 준비 실패: updater.exe를 찾을 수 없습니다: D:\py_train\voc_noti\updater.exe
|
||||
[2026-02-18 16:08:03] [INFO] [Controller] 애플리케이션 종료 중...
|
||||
[2026-02-18 16:08:05] [INFO] [Main] == Application Startup [TEST MODE] ==
|
||||
[2026-02-18 16:08:05] [INFO] [Controller] 컨트롤러 초기화 중 (테스트 모드=True, 테스트 즉시 업데이트 확인=True)
|
||||
[2026-02-18 16:08:05] [INFO] [Controller] 설정 파일 로드 완료
|
||||
[2026-02-18 16:08:05] [INFO] [Database] DB 연결 성공: D:\py_train\voc_noti\app\data\voc.db
|
||||
[2026-02-18 16:08:05] [INFO] [TimetableService] 시각표 로드 완료: 40929 행 (경로: D:\py_train\voc_noti\app\data\line1_sp_timetable.parquet)
|
||||
[2026-02-18 16:08:06] [INFO] [Controller] 업데이터 초기화 완료
|
||||
[2026-02-18 16:08:06] [INFO] [Controller] 테스트 모드: 웹 크롤링 생략, 로컬 DB 사용.
|
||||
[2026-02-18 16:08:06] [INFO] [Controller] 테스트 모드: 시작 직후 업데이트 1회 확인을 수행합니다.
|
||||
[2026-02-18 16:08:06] [INFO] [Controller] 시작 알림 표시됨
|
||||
[2026-02-18 16:08:07] [INFO] [Controller] 수동 업데이트 확인 시작
|
||||
[2026-02-18 16:08:07] [INFO] [Controller] 수동 업데이트 확인 결과: 새 버전 3.5.5
|
||||
[2026-02-18 16:08:11] [ERROR] [Controller] 업데이트 준비 실패: updater.exe를 찾을 수 없습니다: D:\py_train\voc_noti\updater.exe
|
||||
[2026-02-18 16:49:07] [INFO] [Controller] 애플리케이션 종료 중...
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@ if __name__ == "__main__":
|
|||
logger = get_logger("Main")
|
||||
parser = argparse.ArgumentParser(description="VOC Notification App")
|
||||
parser.add_argument("--test", action="store_true", help="Run in test mode (no web crawling, use local DB)")
|
||||
parser.add_argument(
|
||||
"--test-update-now",
|
||||
action="store_true",
|
||||
help="In test mode, run updater check once right after startup",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
mode_str = "[TEST MODE]" if args.test else "[LIVE MODE]"
|
||||
logger.info(f"== Application Startup {mode_str} ==")
|
||||
|
||||
try:
|
||||
app = AppController(test_mode=args.test)
|
||||
app = AppController(test_mode=args.test, test_update_now=args.test_update_now)
|
||||
app.start_background()
|
||||
except Exception as e:
|
||||
logger.critical(f"Application Crashed: {e}", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,16 @@ import os
|
|||
include_files = [
|
||||
("assets/", "assets/"),
|
||||
("data/", "data/"),
|
||||
("updater/config.json", "app/updater/config.json"),
|
||||
]
|
||||
|
||||
# updater.exe 통합 (사전 빌드 산출물)
|
||||
updater_exe_src = os.path.join("updater_build", "dist", "updater.exe")
|
||||
if os.path.exists(updater_exe_src):
|
||||
include_files.append((updater_exe_src, "updater.exe"))
|
||||
else:
|
||||
print("[setup.py] WARNING: updater.exe not found. Run 'python app/update_build_setup.py' first.")
|
||||
|
||||
# 2. 제외할 모듈 (용량 최적화용)
|
||||
excludes = ["tkinter.test", "unittest"]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
"""
|
||||
updater.exe 단독 빌드 스크립트
|
||||
|
||||
목표:
|
||||
- app/updater/updater_gui.py를 단일 실행파일(onefile) updater.exe로 빌드
|
||||
- 빌드 산출물과 updater 설정 파일(config.json)을 메인 패키징 입력 경로에 정리
|
||||
|
||||
사용 예시:
|
||||
python app/update_build_setup.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_command(command: list[str], workdir: Path) -> None:
|
||||
result = subprocess.run(command, cwd=str(workdir), check=False)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"명령 실행 실패: {' '.join(command)} (rc={result.returncode})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
updater_script = base_dir / "updater" / "updater_gui.py"
|
||||
updater_icon = base_dir / "assets" / "app_icon.ico"
|
||||
|
||||
build_root = base_dir / "updater_build"
|
||||
dist_root = build_root / "dist"
|
||||
pyinstaller_build_root = build_root / "build"
|
||||
spec_root = build_root / "spec"
|
||||
|
||||
config_src = base_dir / "updater" / "config.json"
|
||||
config_dst = build_root / "config.json"
|
||||
|
||||
if not updater_script.exists():
|
||||
raise FileNotFoundError(f"업데이터 스크립트가 없습니다: {updater_script}")
|
||||
if not config_src.exists():
|
||||
raise FileNotFoundError(f"업데이터 설정 파일이 없습니다: {config_src}")
|
||||
|
||||
dist_root.mkdir(parents=True, exist_ok=True)
|
||||
pyinstaller_build_root.mkdir(parents=True, exist_ok=True)
|
||||
spec_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--windowed",
|
||||
"--name",
|
||||
"updater",
|
||||
"--distpath",
|
||||
str(dist_root),
|
||||
"--workpath",
|
||||
str(pyinstaller_build_root),
|
||||
"--specpath",
|
||||
str(spec_root),
|
||||
]
|
||||
|
||||
if updater_icon.exists():
|
||||
command += ["--icon", str(updater_icon)]
|
||||
|
||||
command.append(str(updater_script))
|
||||
|
||||
print("[UpdaterBuild] updater.exe 단독 빌드 시작")
|
||||
run_command(command, base_dir)
|
||||
|
||||
updater_exe = dist_root / "updater.exe"
|
||||
if not updater_exe.exists():
|
||||
raise FileNotFoundError(f"빌드 결과가 없습니다: {updater_exe}")
|
||||
|
||||
shutil.copy2(config_src, config_dst)
|
||||
print(f"[UpdaterBuild] 완료: {updater_exe}")
|
||||
print(f"[UpdaterBuild] 설정 복사: {config_dst}")
|
||||
print("[UpdaterBuild] 메인 패키징 전에 updater_build 폴더를 유지하세요.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -35,6 +35,7 @@ from .update_manager import (
|
|||
VersionInfo,
|
||||
compare_versions,
|
||||
create_update_manager_from_settings,
|
||||
load_updater_connection_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -53,4 +54,5 @@ __all__ = [
|
|||
# 함수
|
||||
"compare_versions",
|
||||
"create_update_manager_from_settings",
|
||||
"load_updater_connection_config",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"config_url": "https://jwt.m1tcloud.cc/config",
|
||||
"default_environment": "main",
|
||||
"fallback": {
|
||||
"supabase_url": "https://kong.m1tcloud.cc",
|
||||
"anon_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIn0.S_qrtc9owXm1gIrP41rhpmcDMMdXvbufahyObsTFpv0"
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
|
|||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -18,14 +17,16 @@ import tempfile
|
|||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .__version__ import APP_NAME, PROGRAM_ID, VERSION
|
||||
from .__version__ import PROGRAM_ID, VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UPDATER_CONFIG_FILENAME = "config.json"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 데이터 클래스
|
||||
|
|
@ -86,6 +87,167 @@ class ConfigError(UpdateError):
|
|||
pass
|
||||
|
||||
|
||||
def _strip_json_comments(content: str) -> str:
|
||||
"""JSON 문자열의 주석(//, /* */) 제거"""
|
||||
result = []
|
||||
i = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
while i < len(content):
|
||||
char = content[i]
|
||||
|
||||
if escape_next:
|
||||
result.append(char)
|
||||
escape_next = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if char == "\\" and in_string:
|
||||
result.append(char)
|
||||
escape_next = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if char == '"':
|
||||
in_string = not in_string
|
||||
result.append(char)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if not in_string:
|
||||
if content[i : i + 2] == "//":
|
||||
while i < len(content) and content[i] != "\n":
|
||||
i += 1
|
||||
continue
|
||||
if content[i : i + 2] == "/*":
|
||||
i += 2
|
||||
while i < len(content) - 1 and content[i : i + 2] != "*/":
|
||||
i += 1
|
||||
i += 2
|
||||
continue
|
||||
|
||||
result.append(char)
|
||||
i += 1
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _select_environment_config(
|
||||
environments: dict[str, dict[str, Any]],
|
||||
requested_environment: Optional[str],
|
||||
default_environment: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""환경 설정 선택 (요청 환경 > 기본 환경 > 우선순위)"""
|
||||
target = requested_environment or default_environment
|
||||
if target in environments and environments[target].get("enabled", True):
|
||||
return target, environments[target]
|
||||
|
||||
enabled_items = [(k, v) for k, v in environments.items() if v.get("enabled", True)]
|
||||
if not enabled_items:
|
||||
return "", {}
|
||||
|
||||
enabled_items.sort(key=lambda item: item[1].get("priority", 999))
|
||||
return enabled_items[0]
|
||||
|
||||
|
||||
def load_updater_connection_config(
|
||||
config_path: Optional[Path] = None,
|
||||
environment: Optional[str] = None,
|
||||
timeout: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
업데이터 연결 설정 로드
|
||||
|
||||
우선순위:
|
||||
1) 원격 config_url (성공 시)
|
||||
2) 로컬 fallback
|
||||
"""
|
||||
path = config_path or (Path(__file__).resolve().parent / UPDATER_CONFIG_FILENAME)
|
||||
if not path.exists():
|
||||
raise ConfigError(f"업데이터 설정 파일이 없습니다: {path}")
|
||||
|
||||
try:
|
||||
local_raw = _strip_json_comments(path.read_text(encoding="utf-8"))
|
||||
local_config = json.loads(local_raw)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise ConfigError(f"업데이터 설정 파일 파싱 실패: {e}") from e
|
||||
|
||||
fallback = local_config.get("fallback", {})
|
||||
default_env = local_config.get("default_environment", "main")
|
||||
config_url = local_config.get("config_url", "").strip()
|
||||
|
||||
# 1) 원격 설정 시도
|
||||
if config_url:
|
||||
try:
|
||||
headers = {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
response = requests.get(config_url, timeout=timeout, headers=headers)
|
||||
response.raise_for_status()
|
||||
remote_raw = _strip_json_comments(response.text)
|
||||
remote = json.loads(remote_raw)
|
||||
|
||||
envs = remote.get("environments", {})
|
||||
if isinstance(envs, dict) and envs:
|
||||
env_name, env_config = _select_environment_config(
|
||||
environments=envs,
|
||||
requested_environment=environment,
|
||||
default_environment=remote.get("defaultEnvironment", default_env),
|
||||
)
|
||||
if env_config:
|
||||
url = env_config.get("supabaseUrl", "").strip()
|
||||
key = env_config.get("anonKey", "").strip()
|
||||
if url and key:
|
||||
return {
|
||||
"supabase_url": url,
|
||||
"supabase_key": key,
|
||||
"environment": env_name,
|
||||
"source": "remote",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"원격 연결 설정 로드 실패, fallback 사용: {e}")
|
||||
|
||||
# 2) 로컬 fallback
|
||||
url = str(fallback.get("supabase_url", "")).strip()
|
||||
key = str(fallback.get("anon_key", "")).strip()
|
||||
env = environment or default_env
|
||||
|
||||
if not url or not key:
|
||||
raise ConfigError("fallback 설정에 supabase_url 또는 anon_key가 없습니다.")
|
||||
|
||||
return {
|
||||
"supabase_url": url,
|
||||
"supabase_key": key,
|
||||
"environment": env,
|
||||
"source": "fallback",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_connection_config_path(raw_path: str) -> Path:
|
||||
"""연결 설정 파일 경로 해석 (상대경로/패킹 환경 지원)"""
|
||||
path = Path(raw_path)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
|
||||
# 1) 현재 작업 디렉토리 기준
|
||||
cwd_candidate = (Path.cwd() / path).resolve()
|
||||
if cwd_candidate.exists():
|
||||
return cwd_candidate
|
||||
|
||||
# 2) 실행 파일 기준 (패킹 환경)
|
||||
if getattr(sys, "frozen", False):
|
||||
exe_candidate = (Path(sys.executable).parent / path).resolve()
|
||||
if exe_candidate.exists():
|
||||
return exe_candidate
|
||||
|
||||
# 3) 모듈 기준 (개발 환경)
|
||||
module_candidate = (Path(__file__).resolve().parents[2] / path).resolve()
|
||||
return module_candidate
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 버전 비교 유틸리티
|
||||
# ============================================================================
|
||||
|
|
@ -163,7 +325,8 @@ class UpdateManager:
|
|||
supabase_key: str,
|
||||
program_id: str = PROGRAM_ID,
|
||||
current_version: str = VERSION,
|
||||
check_interval: int = 1
|
||||
check_interval: int = 1,
|
||||
version_table: str = "program_version",
|
||||
):
|
||||
"""
|
||||
UpdateManager 초기화
|
||||
|
|
@ -180,6 +343,7 @@ class UpdateManager:
|
|||
self.supabase_url = supabase_url.rstrip('/')
|
||||
self.supabase_key = supabase_key
|
||||
self.check_interval = check_interval
|
||||
self.version_table = version_table
|
||||
|
||||
self._latest_version: Optional[VersionInfo] = None
|
||||
self._stop_flag = threading.Event()
|
||||
|
|
@ -193,6 +357,20 @@ class UpdateManager:
|
|||
def temp_updater_path(self) -> Path:
|
||||
"""임시 updater.exe 경로"""
|
||||
return Path(tempfile.gettempdir()) / self.UPDATER_EXE_NAME
|
||||
|
||||
@property
|
||||
def install_dir(self) -> Path:
|
||||
"""메인 프로그램 설치 경로"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
@staticmethod
|
||||
def _get_creation_flags() -> int:
|
||||
"""운영체제별 프로세스 생성 플래그"""
|
||||
if sys.platform.startswith("win"):
|
||||
return subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
return 0
|
||||
|
||||
def check_for_updates(self) -> Optional[VersionInfo]:
|
||||
"""
|
||||
|
|
@ -203,8 +381,12 @@ class UpdateManager:
|
|||
None: 업데이트 없음
|
||||
"""
|
||||
try:
|
||||
if not self.supabase_url:
|
||||
raise ConfigError("Supabase URL이 비어있습니다.")
|
||||
if not self.supabase_key:
|
||||
raise ConfigError("Supabase API 키가 비어있습니다.")
|
||||
|
||||
# Supabase REST API 호출
|
||||
url = f"{self.supabase_url}/rest/v1/program_version"
|
||||
headers = {
|
||||
"apikey": self.supabase_key,
|
||||
"Authorization": f"Bearer {self.supabase_key}",
|
||||
|
|
@ -219,8 +401,26 @@ class UpdateManager:
|
|||
}
|
||||
|
||||
logger.info("업데이트 확인 중...")
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
table_candidates = [self.version_table]
|
||||
if self.version_table == "program_version":
|
||||
table_candidates.append("program_versions")
|
||||
|
||||
response = None
|
||||
for table_name in table_candidates:
|
||||
url = f"{self.supabase_url}/rest/v1/{table_name}"
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
if response.status_code == 404 and table_name != table_candidates[-1]:
|
||||
logger.warning(f"버전 테이블 미존재: {table_name}, 다음 후보 시도")
|
||||
continue
|
||||
response.raise_for_status()
|
||||
if table_name != self.version_table:
|
||||
logger.info(f"버전 테이블 자동 전환: {self.version_table} -> {table_name}")
|
||||
self.version_table = table_name
|
||||
break
|
||||
|
||||
if response is None:
|
||||
raise NetworkError("버전 조회 응답이 없습니다.")
|
||||
|
||||
data = response.json()
|
||||
if not data:
|
||||
|
|
@ -252,6 +452,8 @@ class UpdateManager:
|
|||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"업데이트 확인 실패: {e}")
|
||||
raise NetworkError(f"네트워크 오류: {e}")
|
||||
except ConfigError:
|
||||
raise
|
||||
except (KeyError, json.JSONDecodeError) as e:
|
||||
logger.error(f"버전 정보 파싱 오류: {e}")
|
||||
return None
|
||||
|
|
@ -283,39 +485,42 @@ class UpdateManager:
|
|||
# 다운로드 URL 확인
|
||||
if not version_info.download_url:
|
||||
return False, "다운로드 URL이 없습니다."
|
||||
|
||||
# 설치 경로 확인 (실행 파일 위치)
|
||||
exe_path = Path(__file__).parent.parent.parent # app/updater -> app -> project_root
|
||||
if getattr(os, 'frozen', False):
|
||||
# cx_freeze로 패킹된 경우
|
||||
exe_path = Path(sys.executable).parent
|
||||
|
||||
install_dir = self.install_dir
|
||||
restart_exe = Path(sys.executable).name if getattr(sys, "frozen", False) else "voc_noti.exe"
|
||||
|
||||
# config.json 생성
|
||||
config = UpdateConfig(
|
||||
download_url=version_info.download_url,
|
||||
target_path=str(exe_path),
|
||||
target_path=str(install_dir),
|
||||
version=version_info.version,
|
||||
restart_exe="voc_noti.exe"
|
||||
restart_exe=restart_exe
|
||||
)
|
||||
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"download_url": config.download_url,
|
||||
"target_path": config.target_path,
|
||||
"version": config.version,
|
||||
"restart_exe": config.restart_exe
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
temp_config_path = self.config_path.with_suffix(".tmp")
|
||||
with open(temp_config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(
|
||||
{
|
||||
"download_url": config.download_url,
|
||||
"target_path": config.target_path,
|
||||
"version": config.version,
|
||||
"restart_exe": config.restart_exe,
|
||||
},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
temp_config_path.replace(self.config_path)
|
||||
|
||||
logger.info(f"업데이트 설정 저장: {self.config_path}")
|
||||
|
||||
|
||||
# updater.exe 복사
|
||||
updater_src = exe_path / self.UPDATER_EXE_NAME
|
||||
if updater_src.exists():
|
||||
shutil.copy2(updater_src, self.temp_updater_path)
|
||||
logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}")
|
||||
else:
|
||||
logger.warning(f"updater.exe를 찾을 수 없습니다: {updater_src}")
|
||||
# 개발 환경에서는 updater.exe가 없을 수 있음
|
||||
updater_src = install_dir / self.UPDATER_EXE_NAME
|
||||
if not updater_src.exists():
|
||||
return False, f"updater.exe를 찾을 수 없습니다: {updater_src}"
|
||||
|
||||
shutil.copy2(updater_src, self.temp_updater_path)
|
||||
logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}")
|
||||
|
||||
return True, "업데이트 준비 완료"
|
||||
|
||||
|
|
@ -342,7 +547,7 @@ class UpdateManager:
|
|||
subprocess.Popen(
|
||||
[str(self.temp_updater_path)],
|
||||
cwd=str(self.temp_updater_path.parent),
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
creationflags=self._get_creation_flags(),
|
||||
)
|
||||
|
||||
logger.info("updater.exe 실행 완료")
|
||||
|
|
@ -413,12 +618,28 @@ def create_update_manager_from_settings(settings: dict) -> UpdateManager:
|
|||
Returns:
|
||||
UpdateManager 인스턴스
|
||||
"""
|
||||
update_settings = settings.get('update', {})
|
||||
|
||||
update_settings = settings.get("update", {})
|
||||
|
||||
# settings.json에서 직접 지정 시 우선 사용, 없으면 updater/config.json 사용
|
||||
supabase_url = str(update_settings.get("supabase_url", "")).strip()
|
||||
supabase_key = str(update_settings.get("supabase_key", "")).strip()
|
||||
|
||||
if not supabase_url or not supabase_key:
|
||||
config_path_raw = update_settings.get("connection_config_path", "")
|
||||
env_name = update_settings.get("environment")
|
||||
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
|
||||
conn = load_updater_connection_config(config_path=config_path, environment=env_name)
|
||||
supabase_url = conn["supabase_url"]
|
||||
supabase_key = conn["supabase_key"]
|
||||
logger.info(
|
||||
f"업데이터 연결 설정 로드: source={conn.get('source')} / environment={conn.get('environment')}"
|
||||
)
|
||||
|
||||
return UpdateManager(
|
||||
supabase_url=update_settings.get('supabase_url', ''),
|
||||
supabase_key=update_settings.get('supabase_key', ''),
|
||||
program_id=PROGRAM_ID,
|
||||
current_version=VERSION,
|
||||
check_interval=update_settings.get('check_interval_hours', 1)
|
||||
supabase_url=supabase_url,
|
||||
supabase_key=supabase_key,
|
||||
program_id=update_settings.get("program_id", PROGRAM_ID),
|
||||
current_version=update_settings.get("current_version", VERSION),
|
||||
check_interval=update_settings.get("check_interval_hours", 1),
|
||||
version_table=update_settings.get("version_table", "program_version"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
# 업데이트 로그
|
||||
|
||||
## v3.2.1 (2026-02-18)
|
||||
- 업데이터 연결정보 외부화
|
||||
- `app/updater/config.json` 도입 (원격 config + fallback)
|
||||
- `settings.json(update)` 연동 (`environment`, `program_id`, `version_table`)
|
||||
- Supabase 버전 테이블명 자동 폴백
|
||||
- `program_version` 실패 시 `program_versions` 재시도
|
||||
- 에러 처리/검증 강화
|
||||
- 권한 오류/손상 zip/롤백 테스트 보강
|
||||
|
||||
## v3.2.0 (2026-02-18)
|
||||
- 자동 업데이트 시스템 구현
|
||||
- UpdateManager: Supabase 기반 버전 체크
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ class UpdaterGUI(ctk.CTk):
|
|||
"""
|
||||
|
||||
CONFIG_FILENAME = "voc_updater_config.json"
|
||||
EXTRACT_DIRNAME = "voc_update_extract"
|
||||
|
||||
def __init__(self):
|
||||
"""UpdaterGUI 초기화"""
|
||||
|
|
@ -234,6 +235,13 @@ class UpdaterGUI(ctk.CTk):
|
|||
version=data.get('version', ''),
|
||||
restart_exe=data.get('restart_exe', 'voc_noti.exe')
|
||||
)
|
||||
|
||||
if not config.download_url:
|
||||
raise ConfigError("download_url이 비어 있습니다.")
|
||||
if not config.target_path:
|
||||
raise ConfigError("target_path가 비어 있습니다.")
|
||||
if not config.version:
|
||||
raise ConfigError("version이 비어 있습니다.")
|
||||
|
||||
logger.info(f"설정 로드 완료: 버전 {config.version}")
|
||||
return config
|
||||
|
|
@ -310,6 +318,7 @@ class UpdaterGUI(ctk.CTk):
|
|||
|
||||
target_path = Path(self.config.target_path)
|
||||
backup_path: Optional[Path] = None
|
||||
extract_dir = Path(tempfile.gettempdir()) / self.EXTRACT_DIRNAME
|
||||
|
||||
self._update_status("설치 중...", "파일 교체 중...")
|
||||
self._update_progress(0.75)
|
||||
|
|
@ -324,11 +333,34 @@ class UpdaterGUI(ctk.CTk):
|
|||
if target_path.exists():
|
||||
shutil.copytree(target_path, backup_path)
|
||||
logger.info(f"백업 생성: {backup_path}")
|
||||
|
||||
|
||||
# 압축 해제 준비
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 압축 해제
|
||||
self._update_progress(0.8, "압축 해제 중...")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(target_path.parent)
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
# 압축 해제 결과 루트 결정
|
||||
extracted_items = [p for p in extract_dir.iterdir()]
|
||||
source_root = extract_dir
|
||||
if len(extracted_items) == 1 and extracted_items[0].is_dir():
|
||||
source_root = extracted_items[0]
|
||||
|
||||
# 대상 경로 생성
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 파일 교체
|
||||
self._update_progress(0.9, "파일 교체 중...")
|
||||
for item in source_root.iterdir():
|
||||
dst = target_path / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, dst, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(item, dst)
|
||||
|
||||
self._update_progress(0.95, "완료 중...")
|
||||
logger.info(f"압축 해제 완료: {target_path}")
|
||||
|
|
@ -336,6 +368,8 @@ class UpdaterGUI(ctk.CTk):
|
|||
# 임시 파일 정리
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
return True, "설치 완료"
|
||||
|
||||
|
|
@ -350,6 +384,8 @@ class UpdaterGUI(ctk.CTk):
|
|||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
shutil.move(str(backup_path), str(target_path))
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
return False, f"설치 실패: {e}"
|
||||
|
||||
def _restart_main_app(self) -> bool:
|
||||
|
|
@ -370,10 +406,11 @@ class UpdaterGUI(ctk.CTk):
|
|||
return False
|
||||
|
||||
try:
|
||||
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
cwd=str(target_path),
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
creationflags=creation_flags
|
||||
)
|
||||
logger.info(f"메인 앱 실행: {exe_path}")
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class HistoryDialog(ctk.CTkToplevel):
|
|||
self.controller = controller
|
||||
self.author = " by KH.Choi"
|
||||
self.title("VOC 수집 내역" + self.author)
|
||||
self.geometry("1100x700") # 너비 약간 확장
|
||||
self.geometry("1200x700") # 너비 확장
|
||||
self.all_data = data_rows # 필터링용 원본 데이터
|
||||
|
||||
# --- 상단: 툴바 (필터 + 테마 + 새로고침) ---
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.entry_pw.pack(pady=5)
|
||||
self.entry_pw.insert(0, self.controller.settings['login']['pw'])
|
||||
|
||||
ctk.CTkButton(self.tab_login, text="설정 저장", command=self.save_all).pack(pady=20)
|
||||
|
||||
def _init_crawl_tab(self):
|
||||
font = theme_manager.get_font(12)
|
||||
ctk.CTkLabel(self.tab_crawl, text="수집 주기 (분)", font=font).pack(pady=5)
|
||||
|
|
@ -244,6 +246,8 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
font=theme_manager.get_font(10),
|
||||
text_color="gray"
|
||||
).pack(anchor="w", padx=45, pady=(5, 0))
|
||||
|
||||
ctk.CTkButton(self.tab_system, text="설정 저장", command=self.save_all).pack(pady=20)
|
||||
|
||||
def _select_report_folder(self):
|
||||
"""보고서 저장 폴더 선택 다이얼로그"""
|
||||
|
|
@ -316,6 +320,8 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
)
|
||||
self.lbl_guide.pack(anchor="w", padx=45)
|
||||
|
||||
ctk.CTkButton(self.tab_noti, text="설정 저장", command=self.save_all).pack(pady=20)
|
||||
|
||||
def save_all(self):
|
||||
self.controller.settings['login']['id'] = self.entry_id.get()
|
||||
self.controller.settings['login']['pw'] = self.entry_pw.get()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import pystray
|
||||
from PIL import Image
|
||||
from view.ui_utils import asset_loader
|
||||
|
||||
class SystemTray:
|
||||
|
|
@ -15,12 +14,13 @@ class SystemTray:
|
|||
"""에셋 로더를 통해 Tray 아이콘(PIL Image)을 생성하여 반환합니다."""
|
||||
return asset_loader.get_tray_icon_image()
|
||||
|
||||
def run(self, on_check, on_list, on_settings, on_quit):
|
||||
def run(self, on_check, on_update, on_list, on_settings, on_quit):
|
||||
"""
|
||||
트레이 아이콘을 실행하고 메뉴 이벤트를 바인딩합니다.
|
||||
|
||||
Args:
|
||||
on_check (func): '지금 확인' 메뉴 핸들러
|
||||
on_update (func): '업데이트 확인' 메뉴 핸들러
|
||||
on_list (func): 'VOC 모니터링 열기' 메뉴 핸들러
|
||||
on_settings (func): '설정' 메뉴 핸들러
|
||||
on_quit (func): '종료' 메뉴 핸들러
|
||||
|
|
@ -30,6 +30,7 @@ class SystemTray:
|
|||
pystray.MenuItem('VOC 모니터링 열기', lambda *args: on_list()), # 메인 기능
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem('지금 확인 (수동)', lambda *args: on_check()),
|
||||
pystray.MenuItem('업데이트 확인', lambda *args: on_update()),
|
||||
pystray.MenuItem('설정', lambda *args: on_settings()),
|
||||
pystray.MenuItem('종료', lambda *args: on_quit()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ class StatisticsOptions(BaseModel):
|
|||
| `AppController` | `ui_manager.request_detail_popup(voc_id)` | `str` | `None` | 상세 팝업 열기 (UIManager 위임) |
|
||||
| `AppController` | `scheduler.run_crawling_cycle()` | 없음 | 없음 | 크롤링 사이클 실행 (SchedulerManager 위임) |
|
||||
| `AppController` | `notifier.show_popup(title, msg, voc_id)` | `str, str, str` | 없음 | 팝업 알림 표시 (NotificationManager 위임) |
|
||||
| `AppController` | `update_manager.check_for_updates()` | 없음 | `Optional[VersionInfo]` | 업데이트 확인 |
|
||||
| `AppController` | `update_manager.prepare_update(version_info)` | `VersionInfo` | `tuple[bool, str]` | 업데이트 실행 준비 |
|
||||
| `AppController` | `update_manager.launch_updater()` | 없음 | `bool` | updater.exe 실행 |
|
||||
|
||||
### 2.2 Controller ↔ Service
|
||||
|
||||
|
|
@ -311,6 +314,13 @@ except TrainInfoNotFoundError as e:
|
|||
"unchecked_delay_enabled": true,
|
||||
"use_related_filter": true
|
||||
},
|
||||
"update": {
|
||||
"connection_config_path": "app/updater/config.json",
|
||||
"environment": "main",
|
||||
"check_interval_hours": 1,
|
||||
"program_id": "voc_monitor",
|
||||
"version_table": "program_versions"
|
||||
},
|
||||
"report": {
|
||||
"output_path": "D:/Reports"
|
||||
},
|
||||
|
|
@ -347,11 +357,24 @@ except TrainInfoNotFoundError as e:
|
|||
| `noti.unchecked_check_interval_minutes` | int | 10 | 미확인 글 재알림 주기 (분) |
|
||||
| `noti.unchecked_delay_enabled` | bool | true | 미확인 글 30분 경과 조건 사용 여부 |
|
||||
| `noti.use_related_filter` | bool | true | 관심 조건(키워드/부서) 기반 알림만 사용할지 여부 |
|
||||
| `update.connection_config_path` | str | "app/updater/config.json" | 업데이터 연결 설정 파일 경로 |
|
||||
| `update.environment` | str | "main" | 원격 연결 설정 환경 이름 |
|
||||
| `update.check_interval_hours` | int | 1 | 업데이트 체크 주기(시간) |
|
||||
| `update.program_id` | str | "voc_monitor" | 버전 조회 program_id |
|
||||
| `update.version_table` | str | "program_versions" | 버전 정보 테이블명 |
|
||||
|
||||
**알림 설정 분류 원칙:**
|
||||
- `crawling.keywords`, `crawling.target_depts`: 관심글 판정 기준 (중복 없이 단일 기준)
|
||||
- `noti.*`: 알림 주기/조건 토글/사운드 등 알림 동작 제어
|
||||
|
||||
**업데이터 연결 설정 원칙:**
|
||||
- Supabase URL/KEY는 코드에 하드코딩하지 않고 `app/updater/config.json` + `settings.json(update.*)`로 관리
|
||||
- 원격 config 로드 실패 시 로컬 fallback을 사용
|
||||
|
||||
**빌드 계약:**
|
||||
- `app/update_build_setup.py`는 단일 `updater.exe`를 생성해야 함
|
||||
- 메인 패키징(`app/setup.py`)은 `updater_build/dist/updater.exe`를 `updater.exe` 이름으로 포함해야 함
|
||||
|
||||
**filter_mode 설명:**
|
||||
- `"OR"`: 키워드 **또는** 부서가 매칭되면 관심글로 표시 (기본값)
|
||||
- `"AND"`: 키워드 **그리고** 부서가 모두 매칭되어야 관심글로 표시
|
||||
|
|
@ -413,6 +436,7 @@ CREATE TABLE voc_posts (
|
|||
|--------|--------|----------|
|
||||
| `open_list_view(focus_id)` | 트레이 메뉴 클릭 | `focus_id: str` (선택) |
|
||||
| `open_settings()` | 트레이 메뉴 클릭 | 없음 |
|
||||
| `check_updates_manual()` | 트레이 메뉴 클릭(업데이트 확인) | 없음 |
|
||||
| `request_detail_popup(voc_id)` | 테이블 더블클릭 | `voc_id: str` |
|
||||
|
||||
### 7.2 View → Controller
|
||||
|
|
@ -497,6 +521,11 @@ class VersionInfo(BaseModel):
|
|||
| `launch_updater()` | 없음 | `bool` | updater.exe 실행 |
|
||||
| `start_background_check(callback)` | `Callable` | `None` | 백그라운드 업데이트 체크 시작 |
|
||||
|
||||
**보강된 계약 사항**:
|
||||
- `check_for_updates()`는 `supabase_url`, `supabase_key` 누락 시 `ConfigError`를 발생시킴
|
||||
- `prepare_update()`는 `updater.exe` 미존재 시 `(False, "...")`를 반환
|
||||
- `prepare_update()`는 config 파일을 임시 파일 생성 후 원자적으로 교체 저장함
|
||||
|
||||
#### UpdaterGUI (updater.exe용)
|
||||
|
||||
| 메서드 | 입력 | 출력 | 설명 |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,50 @@
|
|||
- [x] 수집 키워드 기본값을 `1호선 + 1호선 전체 역사`로 반영, 관심 부서 기본값 `차량` 유지
|
||||
- **완료일**: 2026-02-18
|
||||
|
||||
### [진행중] 업데이터 모듈 단독 검증/안정화 🔄
|
||||
- **목표**: 통합 전 updater 모듈 단독 실행 검증, 에러처리 강화, 테스트 코드 확보
|
||||
- **관련 파일**:
|
||||
- `app/updater/update_manager.py`
|
||||
- `app/updater/updater_gui.py`
|
||||
- `docs/updater_validation.md`
|
||||
- **진행 내역**:
|
||||
- [x] Supabase 설정 누락 사전 검증
|
||||
- [x] config.json 원자적 저장 적용
|
||||
- [x] updater.exe 누락 시 명시적 실패 처리
|
||||
- [x] 압축 해제/교체 경로 안정화 및 롤백 보강
|
||||
- [x] updater 단위 테스트 작성 및 실행
|
||||
- 버전 비교
|
||||
- Supabase 설정 누락 예외
|
||||
- updater.exe 누락/권한 오류
|
||||
- 손상 zip 처리
|
||||
- 설치 실패 시 롤백 검증
|
||||
- [x] 샘플 zip 수동 리허설 절차 문서화 (`docs/updater_validation.md`)
|
||||
- [x] 연결정보 외부화: `app/updater/config.json` + `settings.json(update)` 기반 로드
|
||||
- [x] m1tcloud 테스트 환경 실조회 검증 (`program_id=voc_monitor`, `program_versions`)
|
||||
- **상태**: 완료
|
||||
|
||||
### [진행중] 업데이터 메인 앱 통합 🔄
|
||||
- **목표**: 단독 검증 완료된 updater를 컨트롤러/트레이와 연결
|
||||
- **관련 파일**:
|
||||
- `app/controllers/controller.py`
|
||||
- `app/view/tray_icon.py`
|
||||
- **진행 내역**:
|
||||
- [x] 앱 시작 시 updater 백그라운드 체크 시작
|
||||
- [x] 트레이 메뉴 `업데이트 확인` 추가
|
||||
- [x] 업데이트 발견 시 사용자 승인 다이얼로그 연결
|
||||
- [x] 승인 시 `prepare_update`/`launch_updater` 실행 후 앱 종료
|
||||
- [x] 종료 시 updater 백그라운드/임시파일 정리
|
||||
- [x] m1tcloud 실연결 확인 (`voc_monitor`, `program_versions`, 최신 버전 3.5.5 조회)
|
||||
- [x] 오류 처리 강화 (설정 오류/네트워크 오류/updater.exe 누락 시 사용자 메시지+로그)
|
||||
- [x] 테스트 모드 즉시 확인 옵션 추가 (`--test --test-update-now`)
|
||||
- [x] 업데이트 안내 중복 팝업 방지 (표시중 락 + 동일 버전 60초 쿨다운)
|
||||
- [x] 업데이터 단독 빌드 스크립트 추가 (`app/update_build_setup.py`)
|
||||
- [x] 메인 패키징에 `updater.exe`/`app/updater/config.json` 포함 로직 반영
|
||||
- [x] 실제 빌드 실행 및 산출물 검증
|
||||
- `app/updater_build/dist/updater.exe`
|
||||
- `app/build/exe.win-amd64-3.11/updater.exe`
|
||||
- **상태**: 완료
|
||||
|
||||
---
|
||||
|
||||
## 🟡 우선순위: 보통 (In Progress / Pending)
|
||||
|
|
|
|||
|
|
@ -479,6 +479,18 @@ class AutoUpdater:
|
|||
└── 업데이트 없음 → 로그만 기록
|
||||
```
|
||||
|
||||
**통합 동작 (v3.2.2 반영):**
|
||||
- 앱 시작 시 `UpdateManager` 초기화 후 백그라운드 체크 시작
|
||||
- 트레이 메뉴에 `업데이트 확인` 수동 항목 제공
|
||||
- 업데이트 발견 시 사용자 확인 다이얼로그 표시
|
||||
- 사용자 승인 시 `prepare_update()` -> `launch_updater()` -> 메인 앱 종료
|
||||
|
||||
**로그 명세 (필수 기록):**
|
||||
- 초기화 성공/실패
|
||||
- 백그라운드 체크 시작/오류
|
||||
- 수동 확인 수행/결과(최신/업데이트 필요)
|
||||
- 준비 실패/실행 실패 원인
|
||||
|
||||
---
|
||||
|
||||
### 10.6 아키텍처: updater.exe 분리 ⭐ 중요
|
||||
|
|
@ -546,6 +558,13 @@ class AutoUpdater:
|
|||
| 자기 자신 업데이트 | temp 폴더에 복사 후 별도 프로세스로 실행 |
|
||||
| 버전 불일치 | min_required_version 기준 판단 |
|
||||
|
||||
**추가 안정화 반영 (통합 전 단독 검증 단계)**:
|
||||
- Supabase URL/KEY 누락 시 사전 설정 오류 반환
|
||||
- `voc_updater_config.json` 원자적 저장(`.tmp` -> 교체)
|
||||
- updater.exe 누락 시 준비 단계에서 즉시 실패 반환
|
||||
- 압축 해제 시 임시 폴더 사용 후 대상 경로에 단계적 복사
|
||||
- 설치 실패 시 백업 롤백 + 임시 폴더 정리
|
||||
|
||||
---
|
||||
|
||||
### 10.8 멀티프로젝트 지원
|
||||
|
|
@ -583,6 +602,35 @@ updater = AutoUpdater(
|
|||
}
|
||||
```
|
||||
|
||||
### 10.10 연결 설정 관리 (하드코딩 금지)
|
||||
|
||||
- Supabase 연결 정보는 `app/updater/config.json`에서 관리
|
||||
- `config_url`: 원격 설정 URL
|
||||
- `fallback.supabase_url`, `fallback.anon_key`: 원격 실패 시 대체값
|
||||
- 런타임 동작 설정은 `settings.json`의 `update` 섹션에서 관리
|
||||
- `connection_config_path`, `environment`, `check_interval_hours`, `program_id`, `version_table`
|
||||
- 버전 체크 시 테이블 자동 폴백 지원
|
||||
- `program_version` 조회 실패(404) 시 `program_versions` 재시도
|
||||
|
||||
### 10.11 테스트 모드 검증 옵션
|
||||
|
||||
- `python app/main.py --test --test-update-now`
|
||||
- 테스트 DB 모드로 앱을 실행하면서 updater 연결 확인을 시작 직후 1회 수행
|
||||
- 네트워크/설정 오류 시 사용자 메시지 + 로그 기록
|
||||
|
||||
### 10.12 업데이터 빌드/통합 절차
|
||||
|
||||
1. 업데이터 단독 빌드
|
||||
- `python app/update_build_setup.py`
|
||||
- 결과: `app/updater_build/dist/updater.exe`
|
||||
2. 메인 패키징
|
||||
- `python app/setup.py build`
|
||||
- 포함 항목:
|
||||
- `updater.exe` (메인 exe와 동일 경로)
|
||||
- `app/updater/config.json` (연결 설정)
|
||||
3. 런타임
|
||||
- `UpdateManager.prepare_update()`가 설치 경로의 `updater.exe`를 `%TEMP%`로 복사 후 실행
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 개선 사항 (Roadmap 참조)
|
||||
|
|
|
|||
|
|
@ -207,6 +207,8 @@
|
|||
- UpdaterGUI: 별도 실행파일용 업데이트 GUI
|
||||
- updater.exe 분리 아키텍처 적용
|
||||
- updatelog.md 위치 변경 (docs/ → app/updater/)
|
||||
- 메인 앱 통합 (트레이 수동 확인 + 백그라운드 체크 + 승인 실행 플로우)
|
||||
- 빌드 체인 추가 (updater 단독 빌드 + 메인 패키징 포함)
|
||||
|
||||
### v3.0 (2026-06-30 목표)
|
||||
- 다중 호선 지원
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
# 업데이터 모듈 검증 가이드
|
||||
|
||||
## 1. 목적
|
||||
|
||||
통합 전에 `app/updater/` 모듈 단독으로 실행/오류 시나리오를 검증하기 위한 체크리스트입니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 검증 범위
|
||||
|
||||
- `update_manager.py`
|
||||
- 버전 비교
|
||||
- Supabase 조회
|
||||
- config.json 생성
|
||||
- updater.exe 복사 및 실행
|
||||
- `updater_gui.py`
|
||||
- config.json 로드/검증
|
||||
- zip 다운로드
|
||||
- 압축 해제/파일 교체
|
||||
- 롤백 처리
|
||||
- 메인 앱 재실행
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행 전 준비
|
||||
|
||||
1. `%TEMP%` 쓰기 권한 확인
|
||||
2. 설치 대상 경로 쓰기 권한 확인
|
||||
3. `updater.exe` 배포 위치 확인 (메인 exe와 동일 경로)
|
||||
4. Supabase `program_version` 테이블 데이터 확인
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 시나리오
|
||||
|
||||
### 4.1 정상 시나리오
|
||||
|
||||
1. `check_for_updates()`가 최신 안정 버전을 조회
|
||||
2. `prepare_update()`가 `%TEMP%/voc_updater_config.json` 생성
|
||||
3. `prepare_update()`가 `%TEMP%/updater.exe` 복사
|
||||
4. `launch_updater()`가 updater 프로세스 실행
|
||||
5. updater가 zip 다운로드 후 대상 경로 교체
|
||||
6. updater가 메인 프로그램 재실행
|
||||
|
||||
### 4.2 오류 시나리오
|
||||
|
||||
1. Supabase URL/키 누락
|
||||
- 기대 결과: `ConfigError`
|
||||
2. 다운로드 URL 누락
|
||||
- 기대 결과: `prepare_update()` 실패 반환
|
||||
3. updater.exe 누락
|
||||
- 기대 결과: `prepare_update()` 실패 반환
|
||||
4. 손상 zip 파일
|
||||
- 기대 결과: 설치 실패 + 롤백
|
||||
5. 대상 경로 권한 부족
|
||||
- 기대 결과: 설치 실패 + 롤백
|
||||
|
||||
---
|
||||
|
||||
## 5. 반영된 에러 처리 강화
|
||||
|
||||
- `update_manager.py`
|
||||
- Supabase URL/키 사전 검증 추가
|
||||
- 설치 경로 판별 로직 정리 (`sys.frozen` 기준)
|
||||
- config.json 원자적 저장(`.tmp` 생성 후 교체)
|
||||
- updater.exe 누락 시 실패 반환 강화
|
||||
- OS별 프로세스 생성 플래그 분기
|
||||
- `updater_gui.py`
|
||||
- config.json 필수 필드 검증 강화
|
||||
- 압축 해제 임시 폴더 분리
|
||||
- 임시 폴더 -> 대상 경로 복사 단계 명확화
|
||||
- 실패 시 롤백 + 임시 폴더 정리
|
||||
- OS별 재실행 플래그 분기
|
||||
|
||||
---
|
||||
|
||||
## 6. 통합 전 완료 조건
|
||||
|
||||
- 업데이터 단위 테스트 통과
|
||||
- `py_compile` 통과
|
||||
- 오류 시나리오에서 메시지/롤백 동작 확인
|
||||
- 문서(`project_spec.md`, `api_contract.md`, `issue.md`) 동기화
|
||||
|
||||
---
|
||||
|
||||
## 7. 수동 리허설 절차 (샘플 zip)
|
||||
|
||||
### 7.1 목적
|
||||
|
||||
실제 통합 전에 updater.exe 단독 실행으로 "다운로드 -> 교체 -> 재실행" 경로를 눈으로 확인합니다.
|
||||
|
||||
### 7.2 준비물
|
||||
|
||||
1. 테스트용 설치 폴더 (예: `D:/tmp/voc_app_test`)
|
||||
2. 샘플 업데이트 zip (루트에 `voc_noti.exe` 또는 테스트 파일 포함)
|
||||
3. `%TEMP%/voc_updater_config.json` 파일
|
||||
|
||||
### 7.3 config.json 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"download_url": "https://example.com/voc_test_update.zip",
|
||||
"target_path": "D:/tmp/voc_app_test",
|
||||
"version": "9.9.9",
|
||||
"restart_exe": "voc_noti.exe"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 실행 순서
|
||||
|
||||
1. `%TEMP%`에 config.json 배치
|
||||
2. `updater.exe` 단독 실행
|
||||
3. GUI에서 상태가 순서대로 진행되는지 확인
|
||||
- 다운로드 중
|
||||
- 압축 해제 중
|
||||
- 파일 교체 중
|
||||
- 완료
|
||||
4. 대상 경로 파일 변경 여부 확인
|
||||
5. 메인 앱 재실행 여부 확인
|
||||
|
||||
### 7.5 실패 리허설
|
||||
|
||||
1. 손상 zip URL로 config 구성
|
||||
- 기대: 실패 메시지 + 재시도 버튼
|
||||
2. 대상 경로 쓰기 권한 제거
|
||||
- 기대: 설치 실패 + 기존 파일 유지(롤백)
|
||||
|
||||
### 7.6 확인 체크리스트
|
||||
|
||||
- [ ] 성공 시 업데이트 파일이 대상 경로에 반영됨
|
||||
- [ ] 실패 시 기존 파일이 보존됨
|
||||
- [ ] config/temp 파일이 종료 시 정리됨
|
||||
- [ ] 상태 메시지가 한국어로 이해 가능하게 표시됨
|
||||
|
||||
---
|
||||
|
||||
## 8. 실행 증적 (2026-02-18)
|
||||
|
||||
### 8.1 단위 테스트
|
||||
|
||||
실행 명령:
|
||||
|
||||
```bash
|
||||
python test/test_updater_module.py -v
|
||||
```
|
||||
|
||||
결과:
|
||||
- 총 13개 테스트 통과
|
||||
- 포함 시나리오: 버전 비교, 연결 설정 fallback/remote, 테이블명 폴백, 권한 오류, 손상 zip, 롤백
|
||||
|
||||
### 8.2 실환경 연결 테스트 (m1tcloud)
|
||||
|
||||
실행 명령:
|
||||
|
||||
```bash
|
||||
python -c "import json; from pathlib import Path; from app.updater.update_manager import create_update_manager_from_settings; s=json.loads(Path('app/data/settings.json').read_text(encoding='utf-8')); m=create_update_manager_from_settings(s); print(m.supabase_url, m.version_table); v=m.check_for_updates(); print(v.version if v else 'NONE')"
|
||||
```
|
||||
|
||||
결과:
|
||||
- URL: `https://kong.m1tcloud.cc`
|
||||
- 테이블: `program_versions`
|
||||
- 조회 버전: `3.5.5`
|
||||
|
||||
---
|
||||
|
||||
## 9. 메인 통합 리허설 절차
|
||||
|
||||
### 9.1 목적
|
||||
|
||||
업데이터 단독 검증 이후, 메인 앱 통합 경로(트레이 메뉴/백그라운드)가 정상 연결되는지 확인합니다.
|
||||
|
||||
### 9.2 확인 항목
|
||||
|
||||
1. 앱 시작 시 업데이터 초기화 로그
|
||||
- `업데이터 초기화 완료`
|
||||
2. 앱 시작 후 백그라운드 체크 시작 로그
|
||||
- `백그라운드 업데이트 체크 시작 (주기: N시간)`
|
||||
3. 트레이 메뉴에 `업데이트 확인` 항목 표시
|
||||
4. 트레이 `업데이트 확인` 클릭 시:
|
||||
- 최신 버전 없음: 토스트 안내
|
||||
- 최신 버전 있음: 설치 여부 다이얼로그 표시
|
||||
5. 설치 승인 시:
|
||||
- `prepare_update` 성공
|
||||
- `launch_updater` 성공
|
||||
- 메인 앱 종료 처리
|
||||
|
||||
### 9.2.1 테스트 모드 즉시 확인 옵션
|
||||
|
||||
테스트 DB 모드에서 updater 연결을 즉시 검증하려면 아래 명령을 사용합니다.
|
||||
|
||||
```bash
|
||||
python app/main.py --test --test-update-now
|
||||
```
|
||||
|
||||
기대 동작:
|
||||
- 앱 시작 후 약 1초 내 업데이트 확인 1회 실행
|
||||
- 로그에 `수동 업데이트 확인 시작` 기록
|
||||
- 최신/신규 버전 결과 로그 및 알림 표시
|
||||
|
||||
### 9.3 실패 시 기대 동작
|
||||
|
||||
- 연결 설정 오류: 사용자에게 설정 오류 메시지 표시 + 로그
|
||||
- 네트워크 오류: 사용자에게 네트워크 오류 메시지 표시 + 로그
|
||||
- updater.exe 누락: 업데이트 실행 실패 메시지 + 로그
|
||||
|
||||
### 9.5 중복 알림 방지 규칙
|
||||
|
||||
- 업데이트 안내창 표시 중에는 추가 안내창을 띄우지 않음
|
||||
- 동일 버전 안내는 60초 이내 재표시하지 않음
|
||||
- 테스트 모드 `--test --test-update-now` 사용 시 백그라운드 즉시 체크와 중복되지 않도록 단발 확인만 수행
|
||||
|
||||
### 9.4 코드 연결 지점
|
||||
|
||||
- `app/controllers/controller.py`
|
||||
- `start_background()`에서 updater 백그라운드 시작
|
||||
- `check_updates_manual()`/`_check_updates_once()` 수동 확인
|
||||
- `_show_update_prompt()` 승인 후 실행
|
||||
- `app/view/tray_icon.py`
|
||||
- 트레이 메뉴 `업데이트 확인` 이벤트 연결
|
||||
|
||||
### 9.6 빌드 리허설
|
||||
|
||||
1. 업데이터 단독 빌드
|
||||
- `python app/update_build_setup.py`
|
||||
2. 산출물 확인
|
||||
- `app/updater_build/dist/updater.exe` 존재
|
||||
- `app/updater_build/config.json` 존재
|
||||
3. 메인 패키징
|
||||
- `python app/setup.py build`
|
||||
4. 빌드 결과 폴더 확인
|
||||
- 메인 exe와 같은 폴더에 `updater.exe` 존재
|
||||
|
||||
### 9.7 빌드 리허설 실행 증적 (2026-02-18)
|
||||
|
||||
실행 명령:
|
||||
|
||||
```bash
|
||||
python app/update_build_setup.py
|
||||
python app/setup.py build
|
||||
```
|
||||
|
||||
확인 결과:
|
||||
- `app/updater_build/dist/updater.exe` 생성 확인
|
||||
- `app/updater_build/config.json` 복사 확인
|
||||
- `app/build/exe.win-amd64-3.11/VOC_Monitor.exe` 생성 확인
|
||||
- `app/build/exe.win-amd64-3.11/updater.exe` 포함 확인
|
||||
- `app/build/exe.win-amd64-3.11/app/updater/config.json` 포함 확인
|
||||
|
||||
추가 스모크 검증:
|
||||
- 빌드된 `updater.exe`를 개발 경로에 복사 후 `prepare_update()` 실행
|
||||
- 결과: `PREPARE True` / `%TEMP%/updater.exe` 존재 확인
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import app.updater.update_manager as updater_manager
|
||||
from app.updater.updater_gui import ConfigError as UpdaterConfigError
|
||||
from app.updater.updater_gui import UpdateConfig, UpdaterGUI
|
||||
|
||||
ConfigError = updater_manager.ConfigError
|
||||
UpdateManager = updater_manager.UpdateManager
|
||||
VersionInfo = updater_manager.VersionInfo
|
||||
compare_versions = updater_manager.compare_versions
|
||||
create_update_manager_from_settings = updater_manager.create_update_manager_from_settings
|
||||
|
||||
|
||||
class TestUpdateManager(unittest.TestCase):
|
||||
def test_compare_versions_basic_cases(self):
|
||||
self.assertEqual(compare_versions("3.2.0", "3.1.9"), 1)
|
||||
self.assertEqual(compare_versions("3.2", "3.2.0"), 0)
|
||||
self.assertEqual(compare_versions("3.2.0", "3.2.1"), -1)
|
||||
|
||||
def test_check_for_updates_raises_when_supabase_config_missing(self):
|
||||
manager = UpdateManager(supabase_url="", supabase_key="")
|
||||
with self.assertRaises(ConfigError):
|
||||
manager.check_for_updates()
|
||||
|
||||
def test_load_updater_connection_config_uses_fallback_when_remote_fails(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "config.json"
|
||||
cfg.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_url": "https://invalid.example/config",
|
||||
"default_environment": "main",
|
||||
"fallback": {
|
||||
"supabase_url": "https://kong.m1tcloud.cc",
|
||||
"anon_key": "fallback-key",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch("app.updater.update_manager.requests.get", side_effect=Exception("network down")):
|
||||
conn = getattr(updater_manager, "load_updater_connection_config")(config_path=cfg, environment="main")
|
||||
|
||||
self.assertEqual(conn["source"], "fallback")
|
||||
self.assertEqual(conn["supabase_url"], "https://kong.m1tcloud.cc")
|
||||
self.assertEqual(conn["supabase_key"], "fallback-key")
|
||||
|
||||
def test_load_updater_connection_config_uses_remote_when_available(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "config.json"
|
||||
cfg.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_url": "https://jwt.m1tcloud.cc/config",
|
||||
"default_environment": "main",
|
||||
"fallback": {
|
||||
"supabase_url": "https://fallback.example",
|
||||
"anon_key": "fallback-key",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
remote_payload = {
|
||||
"defaultEnvironment": "main",
|
||||
"environments": {
|
||||
"main": {
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
"supabaseUrl": "https://kong.m1tcloud.cc",
|
||||
"anonKey": "remote-key",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return json.dumps(remote_payload, ensure_ascii=False)
|
||||
|
||||
with patch("app.updater.update_manager.requests.get", return_value=_Resp()):
|
||||
conn = getattr(updater_manager, "load_updater_connection_config")(config_path=cfg, environment="main")
|
||||
|
||||
self.assertEqual(conn["source"], "remote")
|
||||
self.assertEqual(conn["supabase_url"], "https://kong.m1tcloud.cc")
|
||||
self.assertEqual(conn["supabase_key"], "remote-key")
|
||||
|
||||
def test_create_update_manager_from_settings_reads_connection_config(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "config.json"
|
||||
cfg.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_environment": "main",
|
||||
"fallback": {
|
||||
"supabase_url": "https://kong.m1tcloud.cc",
|
||||
"anon_key": "fallback-key",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
settings = {
|
||||
"update": {
|
||||
"connection_config_path": str(cfg),
|
||||
"environment": "main",
|
||||
"program_id": "voc_monitor",
|
||||
"check_interval_hours": 2,
|
||||
}
|
||||
}
|
||||
|
||||
manager = create_update_manager_from_settings(settings)
|
||||
self.assertEqual(manager.supabase_url, "https://kong.m1tcloud.cc")
|
||||
self.assertEqual(manager.supabase_key, "fallback-key")
|
||||
self.assertEqual(manager.check_interval, 2)
|
||||
self.assertEqual(getattr(manager, "version_table"), "program_version")
|
||||
|
||||
def test_check_for_updates_fallbacks_table_name_on_404(self):
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://kong.m1tcloud.cc",
|
||||
supabase_key="dummy-key",
|
||||
current_version="3.0.0",
|
||||
)
|
||||
setattr(manager, "version_table", "program_version")
|
||||
|
||||
class _Resp404:
|
||||
status_code = 404
|
||||
|
||||
def raise_for_status(self):
|
||||
import requests
|
||||
|
||||
raise requests.HTTPError("404 not found")
|
||||
|
||||
def json(self):
|
||||
return {"message": "not found"}
|
||||
|
||||
class _Resp200:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{
|
||||
"version": "3.5.5",
|
||||
"is_stable": True,
|
||||
"release_note": "test",
|
||||
"download_url": "https://example.com/update.zip",
|
||||
"min_required_version": "3.0.0",
|
||||
}
|
||||
]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_get(*_args, **_kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return _Resp404()
|
||||
return _Resp200()
|
||||
|
||||
with patch("app.updater.update_manager.requests.get", side_effect=fake_get):
|
||||
info = manager.check_for_updates()
|
||||
|
||||
self.assertIsNotNone(info)
|
||||
self.assertEqual(getattr(info, "version"), "3.5.5")
|
||||
self.assertEqual(getattr(manager, "version_table"), "program_versions")
|
||||
|
||||
def test_prepare_update_fails_when_updater_exe_missing(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
temp_dir = Path(td)
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://example.supabase.co",
|
||||
supabase_key="dummy-key",
|
||||
)
|
||||
version_info = VersionInfo(
|
||||
version="9.9.9",
|
||||
is_stable=True,
|
||||
download_url="https://example.com/update.zip",
|
||||
)
|
||||
|
||||
with patch.object(UpdateManager, "install_dir", new_callable=PropertyMock, return_value=temp_dir):
|
||||
ok, message = manager.prepare_update(version_info)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("updater.exe", message)
|
||||
|
||||
def test_prepare_update_writes_config_and_copies_updater(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
temp_dir = Path(td)
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://example.supabase.co",
|
||||
supabase_key="dummy-key",
|
||||
)
|
||||
version_info = VersionInfo(
|
||||
version="9.9.9",
|
||||
is_stable=True,
|
||||
download_url="https://example.com/update.zip",
|
||||
)
|
||||
|
||||
fake_updater = temp_dir / "updater.exe"
|
||||
fake_updater.write_bytes(b"fake-updater")
|
||||
config_path = temp_dir / "voc_updater_config.json"
|
||||
temp_updater_path = temp_dir / "updater_temp.exe"
|
||||
|
||||
with patch.object(UpdateManager, "install_dir", new_callable=PropertyMock, return_value=temp_dir), patch.object(
|
||||
UpdateManager,
|
||||
"config_path",
|
||||
new_callable=PropertyMock,
|
||||
return_value=config_path,
|
||||
), patch.object(
|
||||
UpdateManager,
|
||||
"temp_updater_path",
|
||||
new_callable=PropertyMock,
|
||||
return_value=temp_updater_path,
|
||||
):
|
||||
ok, message = manager.prepare_update(version_info)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("완료", message)
|
||||
self.assertTrue(config_path.exists())
|
||||
self.assertTrue(temp_updater_path.exists())
|
||||
|
||||
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(config_data["version"], "9.9.9")
|
||||
self.assertEqual(config_data["download_url"], "https://example.com/update.zip")
|
||||
|
||||
def test_prepare_update_returns_permission_error_when_copy_fails(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
temp_dir = Path(td)
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://example.supabase.co",
|
||||
supabase_key="dummy-key",
|
||||
)
|
||||
version_info = VersionInfo(
|
||||
version="9.9.9",
|
||||
is_stable=True,
|
||||
download_url="https://example.com/update.zip",
|
||||
)
|
||||
|
||||
fake_updater = temp_dir / "updater.exe"
|
||||
fake_updater.write_bytes(b"fake-updater")
|
||||
config_path = temp_dir / "voc_updater_config.json"
|
||||
temp_updater_path = temp_dir / "updater_temp.exe"
|
||||
|
||||
with patch.object(UpdateManager, "install_dir", new_callable=PropertyMock, return_value=temp_dir), patch.object(
|
||||
UpdateManager,
|
||||
"config_path",
|
||||
new_callable=PropertyMock,
|
||||
return_value=config_path,
|
||||
), patch.object(
|
||||
UpdateManager,
|
||||
"temp_updater_path",
|
||||
new_callable=PropertyMock,
|
||||
return_value=temp_updater_path,
|
||||
), patch("app.updater.update_manager.shutil.copy2", side_effect=PermissionError("denied")):
|
||||
ok, message = manager.prepare_update(version_info)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("권한", message)
|
||||
|
||||
|
||||
def build_updater_gui_stub() -> UpdaterGUI:
|
||||
gui = object.__new__(UpdaterGUI)
|
||||
gui.CONFIG_FILENAME = "voc_updater_config.json"
|
||||
gui._update_status = lambda *_args, **_kwargs: None
|
||||
gui._update_progress = lambda *_args, **_kwargs: None
|
||||
gui.config = None
|
||||
gui.download_path = None
|
||||
return gui
|
||||
|
||||
|
||||
class TestUpdaterGUI(unittest.TestCase):
|
||||
def test_load_config_validates_required_fields(self):
|
||||
gui = build_updater_gui_stub()
|
||||
config_path = Path(tempfile.gettempdir()) / gui.CONFIG_FILENAME
|
||||
config_path.write_text(
|
||||
json.dumps({"download_url": "", "target_path": "", "version": ""}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
try:
|
||||
with self.assertRaises(UpdaterConfigError):
|
||||
gui._load_config()
|
||||
finally:
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
|
||||
def test_extract_and_replace_success(self):
|
||||
gui = build_updater_gui_stub()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
target_dir = root / "app_dir"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / "old.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
zip_file = root / "update.zip"
|
||||
source_dir = root / "zip_source"
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
(source_dir / "new.txt").write_text("new", encoding="utf-8")
|
||||
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(zip_file, "w") as zf:
|
||||
zf.write(source_dir / "new.txt", arcname="new.txt")
|
||||
|
||||
gui.config = UpdateConfig(
|
||||
download_url="https://example.com/update.zip",
|
||||
target_path=str(target_dir),
|
||||
version="9.9.9",
|
||||
restart_exe="voc_noti.exe",
|
||||
)
|
||||
|
||||
ok, _msg = gui._extract_and_replace(zip_file)
|
||||
self.assertTrue(ok)
|
||||
self.assertTrue((target_dir / "new.txt").exists())
|
||||
|
||||
def test_extract_and_replace_fails_on_bad_zip(self):
|
||||
gui = build_updater_gui_stub()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
target_dir = root / "app_dir"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bad_zip = root / "bad.zip"
|
||||
bad_zip.write_text("not-a-zip", encoding="utf-8")
|
||||
|
||||
gui.config = UpdateConfig(
|
||||
download_url="https://example.com/update.zip",
|
||||
target_path=str(target_dir),
|
||||
version="9.9.9",
|
||||
restart_exe="voc_noti.exe",
|
||||
)
|
||||
|
||||
ok, message = gui._extract_and_replace(bad_zip)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("손상된 zip", message)
|
||||
|
||||
def test_extract_and_replace_rolls_back_on_copy_error(self):
|
||||
gui = build_updater_gui_stub()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
target_dir = root / "app_dir"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / "old.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
zip_file = root / "update.zip"
|
||||
source_dir = root / "zip_source"
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
(source_dir / "new.txt").write_text("new", encoding="utf-8")
|
||||
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(zip_file, "w") as zf:
|
||||
zf.write(source_dir / "new.txt", arcname="new.txt")
|
||||
|
||||
gui.config = UpdateConfig(
|
||||
download_url="https://example.com/update.zip",
|
||||
target_path=str(target_dir),
|
||||
version="9.9.9",
|
||||
restart_exe="voc_noti.exe",
|
||||
)
|
||||
|
||||
with patch("app.updater.updater_gui.shutil.copy2", side_effect=PermissionError("denied")):
|
||||
ok, message = gui._extract_and_replace(zip_file)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("denied", message)
|
||||
self.assertTrue((target_dir / "old.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
Loading…
Reference in New Issue