# -*- coding: utf-8 -*- import os import sys import json import time import traceback import subprocess from pathlib import Path from logging.handlers import RotatingFileHandler import logging import psutil from PyQt5 import QtCore, QtGui, QtWidgets APP_NAME = "Watchdog GUI" 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_path": "", "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 QTextEditLogger(logging.Handler, QtCore.QObject): log_signal = QtCore.pyqtSignal(str) def __init__(self, text_edit: QtWidgets.QPlainTextEdit): logging.Handler.__init__(self) QtCore.QObject.__init__(self) self.text_edit = text_edit self.log_signal.connect(self.append_log) def emit(self, record): try: msg = self.format(record) self.log_signal.emit(msg) except Exception: pass @QtCore.pyqtSlot(str) 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) 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 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 if 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(APP_NAME) self.resize(520, 250) self.setMinimumSize(500, 230) self.cfg = load_config() self.monitoring = False self.last_restart_time = 0.0 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("Watchdog GUI 시작") LOGGER.info(f"로그 파일: {LOG_FILE}") LOGGER.info("=" * 60) if self.cfg.get("auto_start_monitoring", False): self.start_monitoring() self.update_target_status_label() 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) # 경로 행 path_layout = QtWidgets.QHBoxLayout() path_layout.setSpacing(6) self.edit_exe_path = QtWidgets.QLineEdit() self.edit_exe_path.setPlaceholderText("감시할 EXE 경로 선택") self.btn_browse = QtWidgets.QPushButton("찾기") self.btn_browse.setFixedWidth(70) path_layout.addWidget(QtWidgets.QLabel("대상")) path_layout.addWidget(self.edit_exe_path, 1) path_layout.addWidget(self.btn_browse) # 옵션 행 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(path_layout) 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_browse.clicked.connect(self.browse_exe) 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.edit_exe_path.editingFinished.connect(self.save_ui_to_config) 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) def _load_config_to_ui(self): self.edit_exe_path.setText(self.cfg.get("target_exe_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(560, 500) else: self.resize(520, 250) def save_ui_to_config(self): self.cfg["target_exe_path"] = self.edit_exe_path.text().strip() 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 _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 browse_exe(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "감시할 EXE 선택", self.edit_exe_path.text().strip() or str(BASE_DIR), "Executable Files (*.exe);;All Files (*.*)" ) if path: self.edit_exe_path.setText(path) self.save_ui_to_config() LOGGER.info(f"감시 대상 선택: {path}") 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(), 560), 500) else: self.resize(max(520, self.width()), 250) 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_path = self.edit_exe_path.text().strip() if not exe_path: QtWidgets.QMessageBox.warning(self, "경고", "감시할 프로그램을 먼저 선택하세요.") return if not os.path.exists(exe_path): QtWidgets.QMessageBox.warning(self, "경고", "선택한 프로그램 경로가 존재하지 않습니다.") 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;") 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 update_target_status_label(self, running=None): if running is None: exe_path = self.edit_exe_path.text().strip() running = is_process_running_by_path(exe_path) if exe_path else False if running: self.lbl_target.setText("대상 상태: 실행 중") self.lbl_target.setStyleSheet("font-weight: bold; color: #007a1f;") self.tray_icon.setToolTip(f"{APP_NAME} - 대상 실행 중") else: self.lbl_target.setText("대상 상태: 종료됨") self.lbl_target.setStyleSheet("font-weight: bold; color: #b00020;") self.tray_icon.setToolTip(f"{APP_NAME} - 대상 종료됨") def check_target_once(self): try: exe_path = self.edit_exe_path.text().strip() if not exe_path: self.update_target_status_label(False) LOGGER.warning("감시 대상 경로가 비어 있습니다.") return if not os.path.exists(exe_path): self.update_target_status_label(False) LOGGER.error(f"감시 대상 파일이 존재하지 않습니다: {exe_path}") return running = is_process_running_by_path(exe_path) self.update_target_status_label(running) if running: return now = time.time() cooldown = int(self.spin_cooldown.value()) elapsed = now - self.last_restart_time LOGGER.warning("대상 프로그램이 종료된 것으로 감지됨") if elapsed < cooldown: remain = int(cooldown - elapsed) LOGGER.warning(f"재실행 쿨다운 중: {remain}초 남음") return ok = start_target_program(exe_path) if ok: self.last_restart_time = time.time() self.tray_icon.showMessage( APP_NAME, "대상 프로그램이 종료되어 자동 재실행했습니다.", QtWidgets.QSystemTrayIcon.Warning, 2500 ) QtCore.QTimer.singleShot(1500, self.update_target_status_label) else: self.update_target_status_label(False) except Exception: LOGGER.error("감시 루프 오류 발생") LOGGER.error(traceback.format_exc()) # --------------------- # 종료 / 최소화 # --------------------- 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()