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/
__pycache__
app/build
app/updater_build/

View File

@ -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()
@ -131,6 +140,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()
self.root.withdraw() # 메인 창은 숨김
@ -149,6 +168,11 @@ class AppController:
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):
"""데이터 디렉토리가 존재하는지 확인하고 없으면 생성합니다."""
data_dir = self.base_dir / "data"
@ -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")
}
@ -295,10 +333,25 @@ 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
@ -333,6 +386,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,9 +631,20 @@ class AppController:
"""
설정 변경 사항을 런타임에 반영하고 스케줄러를 재설정합니다.
"""
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)
# ========================================================================
@ -496,6 +662,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:
self.model.close()

View File

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

View File

@ -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] 애플리케이션 종료 중...

View File

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

View File

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

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,
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",
]

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 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()
@ -194,6 +358,20 @@ class UpdateManager:
"""임시 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]:
"""
Supabase에서 최신 버전 확인
@ -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("업데이트 확인 중...")
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
@ -284,38 +486,41 @@ class UpdateManager:
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({
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)
"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():
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}")
else:
logger.warning(f"updater.exe를 찾을 수 없습니다: {updater_src}")
# 개발 환경에서는 updater.exe가 없을 수 있음
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"),
)

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)
- 자동 업데이트 시스템 구현
- UpdateManager: Supabase 기반 버전 체크

View File

@ -99,6 +99,7 @@ class UpdaterGUI(ctk.CTk):
"""
CONFIG_FILENAME = "voc_updater_config.json"
EXTRACT_DIRNAME = "voc_update_extract"
def __init__(self):
"""UpdaterGUI 초기화"""
@ -235,6 +236,13 @@ class UpdaterGUI(ctk.CTk):
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)
@ -325,10 +334,33 @@ class UpdaterGUI(ctk.CTk):
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

View File

@ -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 # 필터링용 원본 데이터
# --- 상단: 툴바 (필터 + 테마 + 새로고침) ---

View File

@ -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)
@ -245,6 +247,8 @@ class SettingsDialog(ctk.CTkToplevel):
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):
"""보고서 저장 폴더 선택 다이얼로그"""
current_path = self.report_path_label.cget("text")
@ -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()

View File

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

View File

@ -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용)
| 메서드 | 입력 | 출력 | 설명 |

View File

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

View File

@ -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 참조)

View File

@ -207,6 +207,8 @@
- UpdaterGUI: 별도 실행파일용 업데이트 GUI
- updater.exe 분리 아키텍처 적용
- updatelog.md 위치 변경 (docs/ → app/updater/)
- 메인 앱 통합 (트레이 수동 확인 + 백그라운드 체크 + 승인 실행 플로우)
- 빌드 체인 추가 (updater 단독 빌드 + 메인 패키징 포함)
### 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.