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:
9700X_PC 2026-02-18 16:50:17 +09:00
parent cef29aecd8
commit 501b4e3af6
22 changed files with 1414 additions and 52 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.venv/ .venv/
__pycache__ __pycache__
app/build app/build
app/updater_build/

View File

@ -26,8 +26,10 @@
버전: 3.0 (리팩토링 - Manager 분리 완료) 버전: 3.0 (리팩토링 - Manager 분리 완료)
""" """
import json import json
import time
from typing import Optional from typing import Optional
import customtkinter as ctk import customtkinter as ctk
from tkinter import messagebox
from utils.path_utils import get_base_dir, get_data_dir from utils.path_utils import get_base_dir, get_data_dir
from utils.database import VOCDatabase from utils.database import VOCDatabase
@ -41,6 +43,9 @@ from controllers.notification_manager import NotificationManager
from controllers.report_manager import ReportManager from controllers.report_manager import ReportManager
from controllers.file_manager import FileManager from controllers.file_manager import FileManager
from controllers.ui_manager import UIManager 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 from view.tray_icon import SystemTray
@ -89,19 +94,23 @@ class AppController:
"""개발 환경과 패키징 환경(cx_Freeze)을 모두 지원하는 베이스 디렉토리 반환""" """개발 환경과 패키징 환경(cx_Freeze)을 모두 지원하는 베이스 디렉토리 반환"""
return get_base_dir() return get_base_dir()
def __init__(self, test_mode=False): def __init__(self, test_mode=False, test_update_now=False):
""" """
컨트롤러 초기화 컨트롤러 초기화
Args: Args:
test_mode (bool): 테스트 모드 여부 (True 크롤링 생략) test_mode (bool): 테스트 모드 여부 (True 크롤링 생략)
test_update_now (bool): 테스트 모드에서 시작 직후 업데이트 1 확인 여부
""" """
self.logger = get_logger("Controller") self.logger = get_logger("Controller")
self.test_mode = test_mode self.test_mode = test_mode
self.test_update_now = test_update_now
self.base_dir = get_base_dir() self.base_dir = get_base_dir()
self.settings_file = get_data_dir() / "settings.json" 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() self._ensure_data_dir()
@ -131,6 +140,16 @@ class AppController:
# 서비스 초기화 # 서비스 초기화
self.report_service = ReportService() 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의 부모) # Hidden root 생성 (모든 GUI의 부모)
self.root = ctk.CTk() self.root = ctk.CTk()
self.root.withdraw() # 메인 창은 숨김 self.root.withdraw() # 메인 창은 숨김
@ -149,6 +168,11 @@ class AppController:
self.list_window = None self.list_window = None
self.settings_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): def _ensure_data_dir(self):
"""데이터 디렉토리가 존재하는지 확인하고 없으면 생성합니다.""" """데이터 디렉토리가 존재하는지 확인하고 없으면 생성합니다."""
data_dir = self.base_dir / "data" data_dir = self.base_dir / "data"
@ -191,6 +215,13 @@ class AppController:
# 과거 알림 키워드 설정 정리 (현재는 수집 설정 키워드/부서를 기준으로 알림 판단) # 과거 알림 키워드 설정 정리 (현재는 수집 설정 키워드/부서를 기준으로 알림 판단)
self.settings['noti'].pop('use_keywords', None) self.settings['noti'].pop('use_keywords', None)
self.settings['noti'].pop('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("설정 파일 로드 완료") self.logger.info("설정 파일 로드 완료")
except FileNotFoundError: except FileNotFoundError:
# 기본 설정 생성 # 기본 설정 생성
@ -211,6 +242,13 @@ class AppController:
"unchecked_delay_enabled": True, "unchecked_delay_enabled": True,
"use_related_filter": 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": { "report": {
"output_path": str(get_data_dir() / "reports") "output_path": str(get_data_dir() / "reports")
} }
@ -295,10 +333,25 @@ class AppController:
# 알림 관리자 콜백 설정 # 알림 관리자 콜백 설정
self.notifier.set_popup_callback(self._create_notification_dialog) 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 import threading
threading.Thread(target=self.tray.run, args=( threading.Thread(target=self.tray.run, args=(
self.scheduler.run_crawling_cycle if self.scheduler else lambda: None, # on_check 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_list_view, # on_list
self.open_settings, # on_settings self.open_settings, # on_settings
self.quit_app # on_quit self.quit_app # on_quit
@ -333,6 +386,108 @@ class AppController:
from view.dialogs.notification_dialog import NotificationDialog from view.dialogs.notification_dialog import NotificationDialog
NotificationDialog(self, title, msg, voc_id) 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에 위임 # UI 이벤트 처리 (UI Event Handling) - UIManager에 위임
# ======================================================================== # ========================================================================
@ -476,8 +631,19 @@ class AppController:
""" """
설정 변경 사항을 런타임에 반영하고 스케줄러를 재설정합니다. 설정 변경 사항을 런타임에 반영하고 스케줄러를 재설정합니다.
""" """
if self.scheduler: try:
self.scheduler.update_schedule() 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) # 애플리케이션 종료 (Application Shutdown)
@ -496,6 +662,11 @@ class AppController:
if self.scheduler: if self.scheduler:
self.scheduler.stop() self.scheduler.stop()
# 업데이터 백그라운드 중지
if self.update_manager:
self.update_manager.stop_background_check()
self.update_manager.cleanup()
# 크롤러 종료 # 크롤러 종료
if self.model: if self.model:
self.model.close() self.model.close()

View File

@ -144,5 +144,12 @@
"db_check_interval_minutes": 3, "db_check_interval_minutes": 3,
"unchecked_check_interval_minutes": 10, "unchecked_check_interval_minutes": 10,
"unchecked_delay_enabled": true "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"
} }
} }

View File

@ -1168,3 +1168,30 @@ KeyError: 'max_set'
[2026-02-18 10:47:43] [INFO] [StatisticsService] 통계 보고서 생성 완료 [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 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] 애플리케이션 종료 중...

View File

@ -6,13 +6,18 @@ if __name__ == "__main__":
logger = get_logger("Main") logger = get_logger("Main")
parser = argparse.ArgumentParser(description="VOC Notification App") 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", 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() args = parser.parse_args()
mode_str = "[TEST MODE]" if args.test else "[LIVE MODE]" mode_str = "[TEST MODE]" if args.test else "[LIVE MODE]"
logger.info(f"== Application Startup {mode_str} ==") logger.info(f"== Application Startup {mode_str} ==")
try: try:
app = AppController(test_mode=args.test) app = AppController(test_mode=args.test, test_update_now=args.test_update_now)
app.start_background() app.start_background()
except Exception as e: except Exception as e:
logger.critical(f"Application Crashed: {e}", exc_info=True) logger.critical(f"Application Crashed: {e}", exc_info=True)

View File

@ -7,8 +7,16 @@ import os
include_files = [ include_files = [
("assets/", "assets/"), ("assets/", "assets/"),
("data/", "data/"), ("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. 제외할 모듈 (용량 최적화용) # 2. 제외할 모듈 (용량 최적화용)
excludes = ["tkinter.test", "unittest"] excludes = ["tkinter.test", "unittest"]

86
app/update_build_setup.py Normal file
View File

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

View File

@ -35,6 +35,7 @@ from .update_manager import (
VersionInfo, VersionInfo,
compare_versions, compare_versions,
create_update_manager_from_settings, create_update_manager_from_settings,
load_updater_connection_config,
) )
__all__ = [ __all__ = [
@ -53,4 +54,5 @@ __all__ = [
# 함수 # 함수
"compare_versions", "compare_versions",
"create_update_manager_from_settings", "create_update_manager_from_settings",
"load_updater_connection_config",
] ]

8
app/updater/config.json Normal file
View File

@ -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"
}
}

View File

@ -10,7 +10,6 @@ Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
import json import json
import logging import logging
import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
@ -18,14 +17,16 @@ import tempfile
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Any, Callable, Optional
import requests import requests
from .__version__ import APP_NAME, PROGRAM_ID, VERSION from .__version__ import PROGRAM_ID, VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
UPDATER_CONFIG_FILENAME = "config.json"
# ============================================================================ # ============================================================================
# 데이터 클래스 # 데이터 클래스
@ -86,6 +87,167 @@ class ConfigError(UpdateError):
pass 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, supabase_key: str,
program_id: str = PROGRAM_ID, program_id: str = PROGRAM_ID,
current_version: str = VERSION, current_version: str = VERSION,
check_interval: int = 1 check_interval: int = 1,
version_table: str = "program_version",
): ):
""" """
UpdateManager 초기화 UpdateManager 초기화
@ -180,6 +343,7 @@ class UpdateManager:
self.supabase_url = supabase_url.rstrip('/') self.supabase_url = supabase_url.rstrip('/')
self.supabase_key = supabase_key self.supabase_key = supabase_key
self.check_interval = check_interval self.check_interval = check_interval
self.version_table = version_table
self._latest_version: Optional[VersionInfo] = None self._latest_version: Optional[VersionInfo] = None
self._stop_flag = threading.Event() self._stop_flag = threading.Event()
@ -194,6 +358,20 @@ class UpdateManager:
"""임시 updater.exe 경로""" """임시 updater.exe 경로"""
return Path(tempfile.gettempdir()) / self.UPDATER_EXE_NAME 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]: def check_for_updates(self) -> Optional[VersionInfo]:
""" """
Supabase에서 최신 버전 확인 Supabase에서 최신 버전 확인
@ -203,8 +381,12 @@ class UpdateManager:
None: 업데이트 없음 None: 업데이트 없음
""" """
try: try:
if not self.supabase_url:
raise ConfigError("Supabase URL이 비어있습니다.")
if not self.supabase_key:
raise ConfigError("Supabase API 키가 비어있습니다.")
# Supabase REST API 호출 # Supabase REST API 호출
url = f"{self.supabase_url}/rest/v1/program_version"
headers = { headers = {
"apikey": self.supabase_key, "apikey": self.supabase_key,
"Authorization": f"Bearer {self.supabase_key}", "Authorization": f"Bearer {self.supabase_key}",
@ -219,8 +401,26 @@ class UpdateManager:
} }
logger.info("업데이트 확인 중...") 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() data = response.json()
if not data: if not data:
@ -252,6 +452,8 @@ class UpdateManager:
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.warning(f"업데이트 확인 실패: {e}") logger.warning(f"업데이트 확인 실패: {e}")
raise NetworkError(f"네트워크 오류: {e}") raise NetworkError(f"네트워크 오류: {e}")
except ConfigError:
raise
except (KeyError, json.JSONDecodeError) as e: except (KeyError, json.JSONDecodeError) as e:
logger.error(f"버전 정보 파싱 오류: {e}") logger.error(f"버전 정보 파싱 오류: {e}")
return None return None
@ -284,38 +486,41 @@ class UpdateManager:
if not version_info.download_url: if not version_info.download_url:
return False, "다운로드 URL이 없습니다." return False, "다운로드 URL이 없습니다."
# 설치 경로 확인 (실행 파일 위치) install_dir = self.install_dir
exe_path = Path(__file__).parent.parent.parent # app/updater -> app -> project_root restart_exe = Path(sys.executable).name if getattr(sys, "frozen", False) else "voc_noti.exe"
if getattr(os, 'frozen', False):
# cx_freeze로 패킹된 경우
exe_path = Path(sys.executable).parent
# config.json 생성 # config.json 생성
config = UpdateConfig( config = UpdateConfig(
download_url=version_info.download_url, download_url=version_info.download_url,
target_path=str(exe_path), target_path=str(install_dir),
version=version_info.version, version=version_info.version,
restart_exe="voc_noti.exe" restart_exe=restart_exe
) )
with open(self.config_path, 'w', encoding='utf-8') as f: temp_config_path = self.config_path.with_suffix(".tmp")
json.dump({ with open(temp_config_path, 'w', encoding='utf-8') as f:
"download_url": config.download_url, json.dump(
"target_path": config.target_path, {
"version": config.version, "download_url": config.download_url,
"restart_exe": config.restart_exe "target_path": config.target_path,
}, f, ensure_ascii=False, indent=2) "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}") logger.info(f"업데이트 설정 저장: {self.config_path}")
# updater.exe 복사 # updater.exe 복사
updater_src = exe_path / self.UPDATER_EXE_NAME updater_src = install_dir / self.UPDATER_EXE_NAME
if updater_src.exists(): if not updater_src.exists():
shutil.copy2(updater_src, self.temp_updater_path) return False, f"updater.exe를 찾을 수 없습니다: {updater_src}"
logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}")
else: shutil.copy2(updater_src, self.temp_updater_path)
logger.warning(f"updater.exe를 찾을 수 없습니다: {updater_src}") logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}")
# 개발 환경에서는 updater.exe가 없을 수 있음
return True, "업데이트 준비 완료" return True, "업데이트 준비 완료"
@ -342,7 +547,7 @@ class UpdateManager:
subprocess.Popen( subprocess.Popen(
[str(self.temp_updater_path)], [str(self.temp_updater_path)],
cwd=str(self.temp_updater_path.parent), cwd=str(self.temp_updater_path.parent),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP creationflags=self._get_creation_flags(),
) )
logger.info("updater.exe 실행 완료") logger.info("updater.exe 실행 완료")
@ -413,12 +618,28 @@ def create_update_manager_from_settings(settings: dict) -> UpdateManager:
Returns: Returns:
UpdateManager 인스턴스 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( return UpdateManager(
supabase_url=update_settings.get('supabase_url', ''), supabase_url=supabase_url,
supabase_key=update_settings.get('supabase_key', ''), supabase_key=supabase_key,
program_id=PROGRAM_ID, program_id=update_settings.get("program_id", PROGRAM_ID),
current_version=VERSION, current_version=update_settings.get("current_version", VERSION),
check_interval=update_settings.get('check_interval_hours', 1) check_interval=update_settings.get("check_interval_hours", 1),
version_table=update_settings.get("version_table", "program_version"),
) )

View File

@ -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) ## v3.2.0 (2026-02-18)
- 자동 업데이트 시스템 구현 - 자동 업데이트 시스템 구현
- UpdateManager: Supabase 기반 버전 체크 - UpdateManager: Supabase 기반 버전 체크

View File

@ -99,6 +99,7 @@ class UpdaterGUI(ctk.CTk):
""" """
CONFIG_FILENAME = "voc_updater_config.json" CONFIG_FILENAME = "voc_updater_config.json"
EXTRACT_DIRNAME = "voc_update_extract"
def __init__(self): def __init__(self):
"""UpdaterGUI 초기화""" """UpdaterGUI 초기화"""
@ -235,6 +236,13 @@ class UpdaterGUI(ctk.CTk):
restart_exe=data.get('restart_exe', 'voc_noti.exe') 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}") logger.info(f"설정 로드 완료: 버전 {config.version}")
return config return config
@ -310,6 +318,7 @@ class UpdaterGUI(ctk.CTk):
target_path = Path(self.config.target_path) target_path = Path(self.config.target_path)
backup_path: Optional[Path] = None backup_path: Optional[Path] = None
extract_dir = Path(tempfile.gettempdir()) / self.EXTRACT_DIRNAME
self._update_status("설치 중...", "파일 교체 중...") self._update_status("설치 중...", "파일 교체 중...")
self._update_progress(0.75) self._update_progress(0.75)
@ -325,10 +334,33 @@ class UpdaterGUI(ctk.CTk):
shutil.copytree(target_path, backup_path) shutil.copytree(target_path, backup_path)
logger.info(f"백업 생성: {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, "압축 해제 중...") self._update_progress(0.8, "압축 해제 중...")
with zipfile.ZipFile(zip_path, 'r') as zip_ref: 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, "완료 중...") self._update_progress(0.95, "완료 중...")
logger.info(f"압축 해제 완료: {target_path}") logger.info(f"압축 해제 완료: {target_path}")
@ -336,6 +368,8 @@ class UpdaterGUI(ctk.CTk):
# 임시 파일 정리 # 임시 파일 정리
if zip_path.exists(): if zip_path.exists():
zip_path.unlink() zip_path.unlink()
if extract_dir.exists():
shutil.rmtree(extract_dir)
return True, "설치 완료" return True, "설치 완료"
@ -350,6 +384,8 @@ class UpdaterGUI(ctk.CTk):
if target_path.exists(): if target_path.exists():
shutil.rmtree(target_path) shutil.rmtree(target_path)
shutil.move(str(backup_path), str(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}" return False, f"설치 실패: {e}"
def _restart_main_app(self) -> bool: def _restart_main_app(self) -> bool:
@ -370,10 +406,11 @@ class UpdaterGUI(ctk.CTk):
return False return False
try: try:
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
subprocess.Popen( subprocess.Popen(
[str(exe_path)], [str(exe_path)],
cwd=str(target_path), cwd=str(target_path),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP creationflags=creation_flags
) )
logger.info(f"메인 앱 실행: {exe_path}") logger.info(f"메인 앱 실행: {exe_path}")
return True return True

View File

@ -9,7 +9,7 @@ class HistoryDialog(ctk.CTkToplevel):
self.controller = controller self.controller = controller
self.author = " by KH.Choi" self.author = " by KH.Choi"
self.title("VOC 수집 내역" + self.author) self.title("VOC 수집 내역" + self.author)
self.geometry("1100x700") # 너비 약간 확장 self.geometry("1200x700") # 너비 확장
self.all_data = data_rows # 필터링용 원본 데이터 self.all_data = data_rows # 필터링용 원본 데이터
# --- 상단: 툴바 (필터 + 테마 + 새로고침) --- # --- 상단: 툴바 (필터 + 테마 + 새로고침) ---

View File

@ -107,6 +107,8 @@ class SettingsDialog(ctk.CTkToplevel):
self.entry_pw.pack(pady=5) self.entry_pw.pack(pady=5)
self.entry_pw.insert(0, self.controller.settings['login']['pw']) 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): def _init_crawl_tab(self):
font = theme_manager.get_font(12) font = theme_manager.get_font(12)
ctk.CTkLabel(self.tab_crawl, text="수집 주기 (분)", font=font).pack(pady=5) ctk.CTkLabel(self.tab_crawl, text="수집 주기 (분)", font=font).pack(pady=5)
@ -245,6 +247,8 @@ class SettingsDialog(ctk.CTkToplevel):
text_color="gray" text_color="gray"
).pack(anchor="w", padx=45, pady=(5, 0)) ).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): def _select_report_folder(self):
"""보고서 저장 폴더 선택 다이얼로그""" """보고서 저장 폴더 선택 다이얼로그"""
current_path = self.report_path_label.cget("text") current_path = self.report_path_label.cget("text")
@ -316,6 +320,8 @@ class SettingsDialog(ctk.CTkToplevel):
) )
self.lbl_guide.pack(anchor="w", padx=45) 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): def save_all(self):
self.controller.settings['login']['id'] = self.entry_id.get() self.controller.settings['login']['id'] = self.entry_id.get()
self.controller.settings['login']['pw'] = self.entry_pw.get() self.controller.settings['login']['pw'] = self.entry_pw.get()

View File

@ -1,5 +1,4 @@
import pystray import pystray
from PIL import Image
from view.ui_utils import asset_loader from view.ui_utils import asset_loader
class SystemTray: class SystemTray:
@ -15,12 +14,13 @@ class SystemTray:
"""에셋 로더를 통해 Tray 아이콘(PIL Image)을 생성하여 반환합니다.""" """에셋 로더를 통해 Tray 아이콘(PIL Image)을 생성하여 반환합니다."""
return asset_loader.get_tray_icon_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: Args:
on_check (func): '지금 확인' 메뉴 핸들러 on_check (func): '지금 확인' 메뉴 핸들러
on_update (func): '업데이트 확인' 메뉴 핸들러
on_list (func): 'VOC 모니터링 열기' 메뉴 핸들러 on_list (func): 'VOC 모니터링 열기' 메뉴 핸들러
on_settings (func): '설정' 메뉴 핸들러 on_settings (func): '설정' 메뉴 핸들러
on_quit (func): '종료' 메뉴 핸들러 on_quit (func): '종료' 메뉴 핸들러
@ -30,6 +30,7 @@ class SystemTray:
pystray.MenuItem('VOC 모니터링 열기', lambda *args: on_list()), # 메인 기능 pystray.MenuItem('VOC 모니터링 열기', lambda *args: on_list()), # 메인 기능
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem('지금 확인 (수동)', lambda *args: on_check()), pystray.MenuItem('지금 확인 (수동)', lambda *args: on_check()),
pystray.MenuItem('업데이트 확인', lambda *args: on_update()),
pystray.MenuItem('설정', lambda *args: on_settings()), pystray.MenuItem('설정', lambda *args: on_settings()),
pystray.MenuItem('종료', lambda *args: on_quit()), pystray.MenuItem('종료', lambda *args: on_quit()),
) )

View File

@ -149,6 +149,9 @@ class StatisticsOptions(BaseModel):
| `AppController` | `ui_manager.request_detail_popup(voc_id)` | `str` | `None` | 상세 팝업 열기 (UIManager 위임) | | `AppController` | `ui_manager.request_detail_popup(voc_id)` | `str` | `None` | 상세 팝업 열기 (UIManager 위임) |
| `AppController` | `scheduler.run_crawling_cycle()` | 없음 | 없음 | 크롤링 사이클 실행 (SchedulerManager 위임) | | `AppController` | `scheduler.run_crawling_cycle()` | 없음 | 없음 | 크롤링 사이클 실행 (SchedulerManager 위임) |
| `AppController` | `notifier.show_popup(title, msg, voc_id)` | `str, str, str` | 없음 | 팝업 알림 표시 (NotificationManager 위임) | | `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 ### 2.2 Controller ↔ Service
@ -311,6 +314,13 @@ except TrainInfoNotFoundError as e:
"unchecked_delay_enabled": true, "unchecked_delay_enabled": true,
"use_related_filter": 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": { "report": {
"output_path": "D:/Reports" "output_path": "D:/Reports"
}, },
@ -347,11 +357,24 @@ except TrainInfoNotFoundError as e:
| `noti.unchecked_check_interval_minutes` | int | 10 | 미확인 글 재알림 주기 (분) | | `noti.unchecked_check_interval_minutes` | int | 10 | 미확인 글 재알림 주기 (분) |
| `noti.unchecked_delay_enabled` | bool | true | 미확인 글 30분 경과 조건 사용 여부 | | `noti.unchecked_delay_enabled` | bool | true | 미확인 글 30분 경과 조건 사용 여부 |
| `noti.use_related_filter` | bool | true | 관심 조건(키워드/부서) 기반 알림만 사용할지 여부 | | `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`: 관심글 판정 기준 (중복 없이 단일 기준) - `crawling.keywords`, `crawling.target_depts`: 관심글 판정 기준 (중복 없이 단일 기준)
- `noti.*`: 알림 주기/조건 토글/사운드 등 알림 동작 제어 - `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 설명:** **filter_mode 설명:**
- `"OR"`: 키워드 **또는** 부서가 매칭되면 관심글로 표시 (기본값) - `"OR"`: 키워드 **또는** 부서가 매칭되면 관심글로 표시 (기본값)
- `"AND"`: 키워드 **그리고** 부서가 모두 매칭되어야 관심글로 표시 - `"AND"`: 키워드 **그리고** 부서가 모두 매칭되어야 관심글로 표시
@ -413,6 +436,7 @@ CREATE TABLE voc_posts (
|--------|--------|----------| |--------|--------|----------|
| `open_list_view(focus_id)` | 트레이 메뉴 클릭 | `focus_id: str` (선택) | | `open_list_view(focus_id)` | 트레이 메뉴 클릭 | `focus_id: str` (선택) |
| `open_settings()` | 트레이 메뉴 클릭 | 없음 | | `open_settings()` | 트레이 메뉴 클릭 | 없음 |
| `check_updates_manual()` | 트레이 메뉴 클릭(업데이트 확인) | 없음 |
| `request_detail_popup(voc_id)` | 테이블 더블클릭 | `voc_id: str` | | `request_detail_popup(voc_id)` | 테이블 더블클릭 | `voc_id: str` |
### 7.2 View → Controller ### 7.2 View → Controller
@ -497,6 +521,11 @@ class VersionInfo(BaseModel):
| `launch_updater()` | 없음 | `bool` | updater.exe 실행 | | `launch_updater()` | 없음 | `bool` | updater.exe 실행 |
| `start_background_check(callback)` | `Callable` | `None` | 백그라운드 업데이트 체크 시작 | | `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용) #### UpdaterGUI (updater.exe용)
| 메서드 | 입력 | 출력 | 설명 | | 메서드 | 입력 | 출력 | 설명 |

View File

@ -56,6 +56,50 @@
- [x] 수집 키워드 기본값을 `1호선 + 1호선 전체 역사`로 반영, 관심 부서 기본값 `차량` 유지 - [x] 수집 키워드 기본값을 `1호선 + 1호선 전체 역사`로 반영, 관심 부서 기본값 `차량` 유지
- **완료일**: 2026-02-18 - **완료일**: 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) ## 🟡 우선순위: 보통 (In Progress / Pending)

View File

@ -479,6 +479,18 @@ class AutoUpdater:
└── 업데이트 없음 → 로그만 기록 └── 업데이트 없음 → 로그만 기록
``` ```
**통합 동작 (v3.2.2 반영):**
- 앱 시작 시 `UpdateManager` 초기화 후 백그라운드 체크 시작
- 트레이 메뉴에 `업데이트 확인` 수동 항목 제공
- 업데이트 발견 시 사용자 확인 다이얼로그 표시
- 사용자 승인 시 `prepare_update()` -> `launch_updater()` -> 메인 앱 종료
**로그 명세 (필수 기록):**
- 초기화 성공/실패
- 백그라운드 체크 시작/오류
- 수동 확인 수행/결과(최신/업데이트 필요)
- 준비 실패/실행 실패 원인
--- ---
### 10.6 아키텍처: updater.exe 분리 ⭐ 중요 ### 10.6 아키텍처: updater.exe 분리 ⭐ 중요
@ -546,6 +558,13 @@ class AutoUpdater:
| 자기 자신 업데이트 | temp 폴더에 복사 후 별도 프로세스로 실행 | | 자기 자신 업데이트 | temp 폴더에 복사 후 별도 프로세스로 실행 |
| 버전 불일치 | min_required_version 기준 판단 | | 버전 불일치 | min_required_version 기준 판단 |
**추가 안정화 반영 (통합 전 단독 검증 단계)**:
- Supabase URL/KEY 누락 시 사전 설정 오류 반환
- `voc_updater_config.json` 원자적 저장(`.tmp` -> 교체)
- updater.exe 누락 시 준비 단계에서 즉시 실패 반환
- 압축 해제 시 임시 폴더 사용 후 대상 경로에 단계적 복사
- 설치 실패 시 백업 롤백 + 임시 폴더 정리
--- ---
### 10.8 멀티프로젝트 지원 ### 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 참조) ## 11. 향후 개선 사항 (Roadmap 참조)

View File

@ -207,6 +207,8 @@
- UpdaterGUI: 별도 실행파일용 업데이트 GUI - UpdaterGUI: 별도 실행파일용 업데이트 GUI
- updater.exe 분리 아키텍처 적용 - updater.exe 분리 아키텍처 적용
- updatelog.md 위치 변경 (docs/ → app/updater/) - updatelog.md 위치 변경 (docs/ → app/updater/)
- 메인 앱 통합 (트레이 수동 확인 + 백그라운드 체크 + 승인 실행 플로우)
- 빌드 체인 추가 (updater 단독 빌드 + 메인 패키징 포함)
### v3.0 (2026-06-30 목표) ### v3.0 (2026-06-30 목표)
- 다중 호선 지원 - 다중 호선 지원

251
docs/updater_validation.md Normal file
View File

@ -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` 존재 확인

399
test/test_updater_module.py Normal file
View File

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

BIN
updater.exe Normal file

Binary file not shown.