# -*- coding: utf-8 -*- import os import sys import json import time import traceback import subprocess import ctypes from ctypes import wintypes from pathlib import Path from logging.handlers import RotatingFileHandler import logging import psutil from PyQt5 import QtCore, QtGui, QtWidgets APP_NAME = "Watchdog GUI" APP_VERSION = "1.2.0" BASE_DIR = Path(__file__).resolve().parent CONFIG_PATH = BASE_DIR / "watchdog_config.json" LOG_DIR = BASE_DIR / "watchdog_logs" LOG_FILE = LOG_DIR / "watchdog.log" DEFAULT_CONFIG = { "target_exe_paths": [], "check_interval_sec": 5, "restart_cooldown_sec": 10, "auto_start_monitoring": False, "start_minimized_to_tray": False, "hide_on_close": True, "show_log_panel": False } # ========================= # 로거 # ========================= def setup_logger(): LOG_DIR.mkdir(parents=True, exist_ok=True) logger = logging.getLogger("watchdog_gui") logger.setLevel(logging.INFO) if logger.handlers: return logger formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") file_handler = RotatingFileHandler( str(LOG_FILE), maxBytes=2 * 1024 * 1024, backupCount=5, encoding="utf-8" ) file_handler.setFormatter(formatter) logger.addHandler(file_handler) return logger LOGGER = setup_logger() # ========================= # UI 로그 핸들러 # ========================= class LogSignalEmitter(QtCore.QObject): log_signal = QtCore.pyqtSignal(str) class QTextEditLogger(logging.Handler): def __init__(self, text_edit: QtWidgets.QPlainTextEdit): super().__init__() self.text_edit = text_edit self.signal_emitter = LogSignalEmitter() self.signal_emitter.log_signal.connect(self.append_log) def emit(self, record): try: msg = self.format(record) self.signal_emitter.log_signal.emit(msg) except Exception: pass def append_log(self, msg: str): self.text_edit.appendPlainText(msg) sb = self.text_edit.verticalScrollBar() sb.setValue(sb.maximum()) # ========================= # 설정 # ========================= def load_config(): if not CONFIG_PATH.exists(): save_config(DEFAULT_CONFIG) return DEFAULT_CONFIG.copy() try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = json.load(f) cfg = DEFAULT_CONFIG.copy() cfg.update(data) # 1.1.x 이하의 단일 대상 설정을 다중 대상 목록으로 자동 변환한다. paths = data.get("target_exe_paths") if not isinstance(paths, list): legacy_path = str(data.get("target_exe_path", "")).strip() paths = [legacy_path] if legacy_path else [] unique_paths = [] seen = set() for path in paths: path = str(path).strip() key = normalize_path(path) if path else "" if path and key not in seen: seen.add(key) unique_paths.append(path) cfg["target_exe_paths"] = unique_paths cfg.pop("target_exe_path", None) return cfg except Exception: LOGGER.exception("설정 파일 로드 실패, 기본값 사용") return DEFAULT_CONFIG.copy() def save_config(cfg: dict): try: with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=4, ensure_ascii=False) except Exception: LOGGER.exception("설정 파일 저장 실패") # ========================= # 버전 유틸 # ========================= def get_file_version(file_path: str) -> str: """ Windows EXE/DLL 파일 버전 읽기 실패 시 '알 수 없음' 반환 """ try: if not file_path or not os.path.exists(file_path): return "알 수 없음" size = ctypes.windll.version.GetFileVersionInfoSizeW(file_path, None) if not size: return "알 수 없음" res = ctypes.create_string_buffer(size) ctypes.windll.version.GetFileVersionInfoW(file_path, 0, size, res) u_len = wintypes.UINT() u_ptr = ctypes.c_void_p() ok = ctypes.windll.version.VerQueryValueW( res, "\\", ctypes.byref(u_ptr), ctypes.byref(u_len) ) if not ok: return "알 수 없음" class VS_FIXEDFILEINFO(ctypes.Structure): _fields_ = [ ("dwSignature", wintypes.DWORD), ("dwStrucVersion", wintypes.DWORD), ("dwFileVersionMS", wintypes.DWORD), ("dwFileVersionLS", wintypes.DWORD), ("dwProductVersionMS", wintypes.DWORD), ("dwProductVersionLS", wintypes.DWORD), ("dwFileFlagsMask", wintypes.DWORD), ("dwFileFlags", wintypes.DWORD), ("dwFileOS", wintypes.DWORD), ("dwFileType", wintypes.DWORD), ("dwFileSubtype", wintypes.DWORD), ("dwFileDateMS", wintypes.DWORD), ("dwFileDateLS", wintypes.DWORD), ] ffi = ctypes.cast(u_ptr, ctypes.POINTER(VS_FIXEDFILEINFO)).contents major = (ffi.dwFileVersionMS >> 16) & 0xFFFF minor = ffi.dwFileVersionMS & 0xFFFF build = (ffi.dwFileVersionLS >> 16) & 0xFFFF revision = ffi.dwFileVersionLS & 0xFFFF return f"{major}.{minor}.{build}.{revision}" except Exception: LOGGER.exception("파일 버전 읽기 실패") return "알 수 없음" # ========================= # 감시 유틸 # ========================= def normalize_path(path: str) -> str: try: return os.path.normcase(os.path.abspath(path)) except Exception: return path def get_target_process_name(target_exe_path: str) -> str: return os.path.basename(target_exe_path).lower().strip() def is_process_running_by_path(target_exe_path: str) -> bool: if not target_exe_path: return False target_exe_path_n = normalize_path(target_exe_path) target_name = get_target_process_name(target_exe_path) for proc in psutil.process_iter(["pid", "name", "exe"]): try: proc_name = (proc.info.get("name") or "").lower().strip() proc_exe = proc.info.get("exe") or "" if proc_exe: proc_exe_n = normalize_path(proc_exe) if proc_exe_n == target_exe_path_n: return True elif proc_name == target_name: return True except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): continue except Exception: LOGGER.exception("프로세스 확인 중 오류") return False def start_target_program(target_exe_path: str) -> bool: if not target_exe_path: LOGGER.error("실행할 대상 프로그램 경로가 비어 있습니다.") return False if not os.path.exists(target_exe_path): LOGGER.error(f"대상 프로그램이 존재하지 않습니다: {target_exe_path}") return False try: work_dir = os.path.dirname(target_exe_path) or None subprocess.Popen( [target_exe_path], cwd=work_dir, shell=False ) LOGGER.info(f"프로그램 재실행 성공: {target_exe_path}") return True except Exception: LOGGER.exception(f"프로그램 재실행 실패: {target_exe_path}") return False # ========================= # 메인 윈도우 # ========================= class WatchdogWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") self.resize(700, 430) self.setMinimumSize(620, 380) self.cfg = load_config() self._is_loading_config = False self.monitoring = False self.last_restart_times = {} self._really_quit = False self._tray_shown_once = False self._build_ui() self._connect_signals() self._setup_log_to_ui() self._load_config_to_ui() self._create_tray_icon() self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self.check_target_once) LOGGER.info("=" * 60) LOGGER.info(f"{APP_NAME} 시작 - 버전 {APP_VERSION}") LOGGER.info(f"로그 파일: {LOG_FILE}") LOGGER.info("=" * 60) if self.cfg.get("auto_start_monitoring", False): self.start_monitoring() self.update_target_status_label() self.update_version_labels() if self.cfg.get("start_minimized_to_tray", False): QtCore.QTimer.singleShot(300, self.hide_to_tray) # --------------------- # UI 구성 # --------------------- def _build_ui(self): cw = QtWidgets.QWidget() self.setCentralWidget(cw) main_layout = QtWidgets.QVBoxLayout(cw) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(8) # 버전 표시 version_layout = QtWidgets.QGridLayout() version_layout.setHorizontalSpacing(8) version_layout.setVerticalSpacing(4) self.lbl_watchdog_version = QtWidgets.QLabel(f"Watchdog 버전: {APP_VERSION}") self.lbl_watchdog_version.setStyleSheet("font-weight: bold; color: #1f1f1f;") self.lbl_target_count = QtWidgets.QLabel("감시 대상: 0개") self.lbl_target_count.setStyleSheet("font-weight: bold; color: #1f1f1f;") version_layout.addWidget(self.lbl_watchdog_version, 0, 0) version_layout.addWidget(self.lbl_target_count, 0, 1) # 감시 대상 목록 target_group = QtWidgets.QGroupBox("감시 프로그램") target_layout = QtWidgets.QVBoxLayout(target_group) target_layout.setContentsMargins(8, 8, 8, 8) self.target_table = QtWidgets.QTableWidget(0, 3) self.target_table.setHorizontalHeaderLabels(["프로그램 경로", "상태", "버전"]) self.target_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.target_table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.target_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.target_table.verticalHeader().setVisible(False) self.target_table.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) self.target_table.horizontalHeader().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) self.target_table.horizontalHeader().setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) self.target_table.setMinimumHeight(125) target_btn_layout = QtWidgets.QHBoxLayout() self.btn_add_target = QtWidgets.QPushButton("프로그램 추가") self.btn_remove_target = QtWidgets.QPushButton("선택 항목 삭제") target_btn_layout.addWidget(self.btn_add_target) target_btn_layout.addWidget(self.btn_remove_target) target_btn_layout.addStretch(1) target_layout.addWidget(self.target_table) target_layout.addLayout(target_btn_layout) # 옵션 행 option_layout = QtWidgets.QGridLayout() option_layout.setHorizontalSpacing(8) option_layout.setVerticalSpacing(6) self.spin_interval = QtWidgets.QSpinBox() self.spin_interval.setRange(1, 3600) self.spin_interval.setSuffix(" 초") self.spin_interval.setFixedWidth(90) self.spin_cooldown = QtWidgets.QSpinBox() self.spin_cooldown.setRange(1, 3600) self.spin_cooldown.setSuffix(" 초") self.spin_cooldown.setFixedWidth(90) self.chk_auto_start = QtWidgets.QCheckBox("실행 시 감시 자동 시작") self.chk_start_minimized = QtWidgets.QCheckBox("실행 시 트레이로 숨김") self.chk_hide_on_close = QtWidgets.QCheckBox("닫기(X) 시 종료하지 않고 트레이로 숨김") option_layout.addWidget(QtWidgets.QLabel("체크 주기"), 0, 0) option_layout.addWidget(self.spin_interval, 0, 1) option_layout.addWidget(QtWidgets.QLabel("재실행 대기"), 0, 2) option_layout.addWidget(self.spin_cooldown, 0, 3) option_layout.addWidget(self.chk_auto_start, 1, 0, 1, 2) option_layout.addWidget(self.chk_start_minimized, 1, 2, 1, 2) option_layout.addWidget(self.chk_hide_on_close, 2, 0, 1, 4) # 버튼/상태 행 top_btn_layout = QtWidgets.QHBoxLayout() top_btn_layout.setSpacing(6) self.btn_start = QtWidgets.QPushButton("감시 시작") self.btn_stop = QtWidgets.QPushButton("감시 중지") self.btn_stop.setEnabled(False) self.btn_toggle_log = QtWidgets.QPushButton("로그 보기") self.btn_open_log_file = QtWidgets.QPushButton("로그 파일 열기") self.btn_start.setFixedHeight(30) self.btn_stop.setFixedHeight(30) self.btn_toggle_log.setFixedHeight(30) self.btn_open_log_file.setFixedHeight(30) top_btn_layout.addWidget(self.btn_start) top_btn_layout.addWidget(self.btn_stop) top_btn_layout.addWidget(self.btn_toggle_log) top_btn_layout.addWidget(self.btn_open_log_file) # 상태 표시 status_layout = QtWidgets.QVBoxLayout() status_layout.setSpacing(4) self.lbl_monitor = QtWidgets.QLabel("감시 상태: 중지") self.lbl_monitor.setStyleSheet("font-weight: bold; color: #b00020;") self.lbl_target = QtWidgets.QLabel("대상 상태: 미확인") self.lbl_target.setStyleSheet("font-weight: bold; color: #444;") self.lbl_hint = QtWidgets.QLabel("안내: 창을 닫아도 트레이로 숨길 수 있습니다.") self.lbl_hint.setStyleSheet("color: #666; font-size: 11px;") status_layout.addWidget(self.lbl_monitor) status_layout.addWidget(self.lbl_target) status_layout.addWidget(self.lbl_hint) # 로그 패널 self.log_group = QtWidgets.QGroupBox("로그") log_layout = QtWidgets.QVBoxLayout(self.log_group) log_layout.setContentsMargins(8, 8, 8, 8) self.text_log = QtWidgets.QPlainTextEdit() self.text_log.setReadOnly(True) self.text_log.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) self.text_log.setMinimumHeight(180) self.btn_clear_log = QtWidgets.QPushButton("로그창 비우기") self.btn_clear_log.setFixedHeight(28) log_layout.addWidget(self.text_log) log_layout.addWidget(self.btn_clear_log) main_layout.addLayout(version_layout) main_layout.addWidget(target_group) main_layout.addLayout(option_layout) main_layout.addLayout(top_btn_layout) main_layout.addLayout(status_layout) main_layout.addWidget(self.log_group) def _connect_signals(self): self.btn_add_target.clicked.connect(self.add_target_programs) self.btn_remove_target.clicked.connect(self.remove_selected_targets) self.btn_start.clicked.connect(self.start_monitoring) self.btn_stop.clicked.connect(self.stop_monitoring) self.btn_toggle_log.clicked.connect(self.toggle_log_panel) self.btn_open_log_file.clicked.connect(self.open_log_file) self.btn_clear_log.clicked.connect(self.text_log.clear) self.spin_interval.valueChanged.connect(self.save_ui_to_config) self.spin_cooldown.valueChanged.connect(self.save_ui_to_config) self.chk_auto_start.stateChanged.connect(self.save_ui_to_config) self.chk_start_minimized.stateChanged.connect(self.save_ui_to_config) self.chk_hide_on_close.stateChanged.connect(self.save_ui_to_config) def _setup_log_to_ui(self): self.ui_log_handler = QTextEditLogger(self.text_log) self.ui_log_handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") ) LOGGER.addHandler(self.ui_log_handler) app = QtWidgets.QApplication.instance() if app is not None: app.aboutToQuit.connect(self._remove_ui_log_handler) def _remove_ui_log_handler(self): handler = getattr(self, "ui_log_handler", None) if handler is not None and handler in LOGGER.handlers: LOGGER.removeHandler(handler) handler.close() def _load_config_to_ui(self): self._is_loading_config = True try: for path in self.cfg.get("target_exe_paths", []): self._append_target_row(path) self.spin_interval.setValue(int(self.cfg.get("check_interval_sec", 5))) self.spin_cooldown.setValue(int(self.cfg.get("restart_cooldown_sec", 10))) self.chk_auto_start.setChecked(bool(self.cfg.get("auto_start_monitoring", False))) self.chk_start_minimized.setChecked(bool(self.cfg.get("start_minimized_to_tray", False))) self.chk_hide_on_close.setChecked(bool(self.cfg.get("hide_on_close", True))) show_log = bool(self.cfg.get("show_log_panel", False)) self.log_group.setVisible(show_log) self.btn_toggle_log.setText("로그 숨기기" if show_log else "로그 보기") if show_log: self.resize(740, 650) else: self.resize(700, 430) finally: self._is_loading_config = False self._update_target_count() def save_ui_to_config(self): if getattr(self, "_is_loading_config", False): return self.cfg["target_exe_paths"] = self.get_target_paths() self.cfg.pop("target_exe_path", None) self.cfg["check_interval_sec"] = int(self.spin_interval.value()) self.cfg["restart_cooldown_sec"] = int(self.spin_cooldown.value()) self.cfg["auto_start_monitoring"] = bool(self.chk_auto_start.isChecked()) self.cfg["start_minimized_to_tray"] = bool(self.chk_start_minimized.isChecked()) self.cfg["hide_on_close"] = bool(self.chk_hide_on_close.isChecked()) self.cfg["show_log_panel"] = bool(self.log_group.isVisible()) save_config(self.cfg) def update_version_labels(self): self.lbl_watchdog_version.setText(f"Watchdog 버전: {APP_VERSION}") for row, exe_path in enumerate(self.get_target_paths()): self.target_table.item(row, 2).setText(get_file_version(exe_path)) def get_target_paths(self): return [ self.target_table.item(row, 0).text() for row in range(self.target_table.rowCount()) ] def _append_target_row(self, path): row = self.target_table.rowCount() self.target_table.insertRow(row) self.target_table.setItem(row, 0, QtWidgets.QTableWidgetItem(path)) self.target_table.setItem(row, 1, QtWidgets.QTableWidgetItem("미확인")) self.target_table.setItem(row, 2, QtWidgets.QTableWidgetItem("알 수 없음")) def _update_target_count(self): count = self.target_table.rowCount() self.lbl_target_count.setText(f"감시 대상: {count}개") # --------------------- # 트레이 # --------------------- def _create_tray_icon(self): self.tray_icon = QtWidgets.QSystemTrayIcon(self) icon = self.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon) self.setWindowIcon(icon) self.tray_icon.setIcon(icon) menu = QtWidgets.QMenu() self.action_show = menu.addAction("창 열기") self.action_hide = menu.addAction("트레이로 숨기기") menu.addSeparator() self.action_start = menu.addAction("감시 시작") self.action_stop = menu.addAction("감시 중지") menu.addSeparator() self.action_quit = menu.addAction("종료") self.action_show.triggered.connect(self.show_normal_window) self.action_hide.triggered.connect(self.hide_to_tray) self.action_start.triggered.connect(self.start_monitoring) self.action_stop.triggered.connect(self.stop_monitoring) self.action_quit.triggered.connect(self.quit_application) self.tray_icon.setContextMenu(menu) self.tray_icon.activated.connect(self.on_tray_icon_activated) self.tray_icon.show() def on_tray_icon_activated(self, reason): if reason == QtWidgets.QSystemTrayIcon.DoubleClick: self.show_normal_window() def show_normal_window(self): self.show() self.raise_() self.activateWindow() def hide_to_tray(self): self.hide() if not self._tray_shown_once: self.tray_icon.showMessage( APP_NAME, "프로그램이 트레이로 숨겨졌습니다. 트레이 아이콘에서 다시 열 수 있습니다.", QtWidgets.QSystemTrayIcon.Information, 3000 ) self._tray_shown_once = True def quit_application(self): self._really_quit = True self.save_ui_to_config() LOGGER.info("사용자 요청으로 Watchdog GUI 종료") QtWidgets.QApplication.quit() # --------------------- # 기능 # --------------------- def add_target_programs(self): paths, _ = QtWidgets.QFileDialog.getOpenFileNames( self, "감시할 EXE 선택 (여러 개 선택 가능)", str(BASE_DIR), "Executable Files (*.exe);;All Files (*.*)" ) if not paths: return existing = {normalize_path(path) for path in self.get_target_paths()} added = 0 for path in paths: if normalize_path(path) in existing: continue self._append_target_row(path) existing.add(normalize_path(path)) LOGGER.info(f"감시 대상 추가: {path}") added += 1 if added: self._update_target_count() self.save_ui_to_config() self.update_target_status_label() self.update_version_labels() def remove_selected_targets(self): rows = sorted({index.row() for index in self.target_table.selectedIndexes()}, reverse=True) if not rows: QtWidgets.QMessageBox.information(self, "안내", "삭제할 프로그램을 선택하세요.") return for row in rows: path = self.target_table.item(row, 0).text() self.target_table.removeRow(row) self.last_restart_times.pop(normalize_path(path), None) LOGGER.info(f"감시 대상 삭제: {path}") self._update_target_count() self.save_ui_to_config() self.update_target_status_label() def toggle_log_panel(self): visible = not self.log_group.isVisible() self.log_group.setVisible(visible) self.btn_toggle_log.setText("로그 숨기기" if visible else "로그 보기") if visible: self.resize(max(self.width(), 740), 650) else: self.resize(max(700, self.width()), 430) self.save_ui_to_config() def open_log_file(self): try: if not LOG_FILE.exists(): LOGGER.warning("열 로그 파일이 아직 없습니다.") return os.startfile(str(LOG_FILE)) except Exception: LOGGER.exception("로그 파일 열기 실패") def start_monitoring(self): self.save_ui_to_config() exe_paths = self.get_target_paths() if not exe_paths: QtWidgets.QMessageBox.warning(self, "경고", "감시할 프로그램을 하나 이상 추가하세요.") return missing_paths = [path for path in exe_paths if not os.path.exists(path)] if missing_paths: names = "\n".join(os.path.basename(path) or path for path in missing_paths[:5]) QtWidgets.QMessageBox.warning( self, "경고", f"다음 프로그램 경로가 존재하지 않습니다.\n\n{names}" ) return interval_ms = int(self.spin_interval.value()) * 1000 self.timer.start(interval_ms) self.monitoring = True self.btn_start.setEnabled(False) self.btn_stop.setEnabled(True) self.action_start.setEnabled(False) self.action_stop.setEnabled(True) self.lbl_monitor.setText("감시 상태: 실행 중") self.lbl_monitor.setStyleSheet("font-weight: bold; color: #007a1f;") self.update_version_labels() LOGGER.info(f"감시 시작: {len(exe_paths)}개 프로그램") for exe_path in exe_paths: LOGGER.info(f"감시 대상: {exe_path}") LOGGER.info(f"체크 주기: {self.spin_interval.value()}초 / 쿨다운: {self.spin_cooldown.value()}초") self.check_target_once() def stop_monitoring(self): self.timer.stop() self.monitoring = False self.btn_start.setEnabled(True) self.btn_stop.setEnabled(False) self.action_start.setEnabled(True) self.action_stop.setEnabled(False) self.lbl_monitor.setText("감시 상태: 중지") self.lbl_monitor.setStyleSheet("font-weight: bold; color: #b00020;") LOGGER.info("감시 중지") def _set_target_row_status(self, row, text, color): item = self.target_table.item(row, 1) item.setText(text) item.setForeground(QtGui.QColor(color)) def update_target_status_label(self, statuses=None): paths = self.get_target_paths() if statuses is None: statuses = { normalize_path(path): is_process_running_by_path(path) for path in paths if os.path.exists(path) } running_count = 0 problem_count = 0 for row, path in enumerate(paths): if not os.path.exists(path): self._set_target_row_status(row, "파일 없음", "#b00020") problem_count += 1 elif statuses.get(normalize_path(path), False): self._set_target_row_status(row, "실행 중", "#007a1f") running_count += 1 else: self._set_target_row_status(row, "종료됨", "#b00020") problem_count += 1 total = len(paths) if not total: summary = "대상 상태: 등록된 프로그램 없음" color = "#444" elif problem_count: summary = f"대상 상태: 실행 {running_count} / 점검 필요 {problem_count}" color = "#b00020" else: summary = f"대상 상태: {running_count}개 모두 실행 중" color = "#007a1f" self.lbl_target.setText(summary) self.lbl_target.setStyleSheet(f"font-weight: bold; color: {color};") self.tray_icon.setToolTip(f"{APP_NAME} - {summary.replace('대상 상태: ', '')}") def check_target_once(self): try: exe_paths = self.get_target_paths() if not exe_paths: self.update_target_status_label({}) LOGGER.warning("등록된 감시 대상이 없습니다.") return cooldown = int(self.spin_cooldown.value()) statuses = {} restarted_names = [] for exe_path in exe_paths: key = normalize_path(exe_path) if not os.path.exists(exe_path): LOGGER.error(f"감시 대상 파일이 존재하지 않습니다: {exe_path}") statuses[key] = False continue running = is_process_running_by_path(exe_path) statuses[key] = running if running: continue LOGGER.warning(f"대상 프로그램이 종료된 것으로 감지됨: {exe_path}") now = time.time() elapsed = now - self.last_restart_times.get(key, 0.0) if elapsed < cooldown: remain = max(0, int(cooldown - elapsed)) LOGGER.warning(f"재실행 쿨다운 중 ({os.path.basename(exe_path)}): {remain}초 남음") continue if start_target_program(exe_path): self.last_restart_times[key] = time.time() restarted_names.append(os.path.basename(exe_path)) self.update_target_status_label(statuses) self.update_version_labels() if restarted_names: names = ", ".join(restarted_names) self.tray_icon.showMessage( APP_NAME, f"종료된 프로그램을 자동 재실행했습니다: {names}", QtWidgets.QSystemTrayIcon.Warning, 2500 ) QtCore.QTimer.singleShot(1500, self._refresh_after_restart) except Exception: LOGGER.error("감시 루프 오류 발생") LOGGER.error(traceback.format_exc()) def _refresh_after_restart(self): self.update_target_status_label() self.update_version_labels() # --------------------- # 종료 / 최소화 # --------------------- def changeEvent(self, event): if event.type() == QtCore.QEvent.WindowStateChange: if self.isMinimized(): QtCore.QTimer.singleShot(0, self._handle_minimize) super().changeEvent(event) def _handle_minimize(self): if self.chk_hide_on_close.isChecked(): self.hide_to_tray() def closeEvent(self, event: QtGui.QCloseEvent): try: self.save_ui_to_config() except Exception: pass if self._really_quit: LOGGER.info("Watchdog GUI 종료") event.accept() return if self.chk_hide_on_close.isChecked(): event.ignore() self.hide_to_tray() return LOGGER.info("Watchdog GUI 종료") event.accept() # ========================= # 메인 # ========================= def main(): app = QtWidgets.QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) app.setApplicationName(APP_NAME) font = QtGui.QFont("맑은 고딕", 9) app.setFont(font) win = WatchdogWindow() if not win.cfg.get("start_minimized_to_tray", False): win.show() sys.exit(app.exec_()) if __name__ == "__main__": main()