Files
watchdogs/watchdog_gui.py
T
elake 6f36e846d4
windows-exe-build / build (push) Successful in 3m10s
add multi-program monitoring
2026-08-25 10:41:44 +09:00

861 lines
30 KiB
Python

# -*- 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()