2026-03-18 11:07:16 +09:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
import traceback
|
|
|
|
|
import subprocess
|
2026-03-18 11:08:20 +09:00
|
|
|
import ctypes
|
|
|
|
|
from ctypes import wintypes
|
2026-03-18 11:07:16 +09:00
|
|
|
from pathlib import Path
|
|
|
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
import psutil
|
|
|
|
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
APP_NAME = "Watchdog GUI"
|
2026-08-25 10:41:44 +09:00
|
|
|
APP_VERSION = "1.2.0"
|
2026-03-18 11:08:20 +09:00
|
|
|
|
2026-03-18 11:07:16 +09:00
|
|
|
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 = {
|
2026-08-25 10:41:44 +09:00
|
|
|
"target_exe_paths": [],
|
2026-03-18 11:07:16 +09:00
|
|
|
"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 로그 핸들러
|
|
|
|
|
# =========================
|
2026-08-25 10:41:44 +09:00
|
|
|
class LogSignalEmitter(QtCore.QObject):
|
2026-03-18 11:07:16 +09:00
|
|
|
log_signal = QtCore.pyqtSignal(str)
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
|
|
|
|
|
class QTextEditLogger(logging.Handler):
|
2026-03-18 11:07:16 +09:00
|
|
|
def __init__(self, text_edit: QtWidgets.QPlainTextEdit):
|
2026-08-25 10:41:44 +09:00
|
|
|
super().__init__()
|
2026-03-18 11:07:16 +09:00
|
|
|
self.text_edit = text_edit
|
2026-08-25 10:41:44 +09:00
|
|
|
self.signal_emitter = LogSignalEmitter()
|
|
|
|
|
self.signal_emitter.log_signal.connect(self.append_log)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
def emit(self, record):
|
|
|
|
|
try:
|
|
|
|
|
msg = self.format(record)
|
2026-08-25 10:41:44 +09:00
|
|
|
self.signal_emitter.log_signal.emit(msg)
|
2026-03-18 11:07:16 +09:00
|
|
|
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)
|
2026-08-25 10:41:44 +09:00
|
|
|
|
|
|
|
|
# 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)
|
2026-03-18 11:07:16 +09:00
|
|
|
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("설정 파일 저장 실패")
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 11:08:20 +09:00
|
|
|
# =========================
|
|
|
|
|
# 버전 유틸
|
|
|
|
|
# =========================
|
|
|
|
|
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 "알 수 없음"
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 11:07:16 +09:00
|
|
|
# =========================
|
|
|
|
|
# 감시 유틸
|
|
|
|
|
# =========================
|
|
|
|
|
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
|
2026-08-25 10:41:44 +09:00
|
|
|
elif proc_name == target_name:
|
2026-03-18 11:07:16 +09:00
|
|
|
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__()
|
2026-03-18 11:08:20 +09:00
|
|
|
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
|
2026-08-25 10:41:44 +09:00
|
|
|
self.resize(700, 430)
|
|
|
|
|
self.setMinimumSize(620, 380)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
self.cfg = load_config()
|
2026-03-18 11:56:09 +09:00
|
|
|
self._is_loading_config = False
|
2026-03-18 11:07:16 +09:00
|
|
|
self.monitoring = False
|
2026-08-25 10:41:44 +09:00
|
|
|
self.last_restart_times = {}
|
2026-03-18 11:07:16 +09:00
|
|
|
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)
|
2026-03-18 11:08:20 +09:00
|
|
|
LOGGER.info(f"{APP_NAME} 시작 - 버전 {APP_VERSION}")
|
2026-03-18 11:07:16 +09:00
|
|
|
LOGGER.info(f"로그 파일: {LOG_FILE}")
|
|
|
|
|
LOGGER.info("=" * 60)
|
|
|
|
|
|
|
|
|
|
if self.cfg.get("auto_start_monitoring", False):
|
|
|
|
|
self.start_monitoring()
|
|
|
|
|
|
|
|
|
|
self.update_target_status_label()
|
2026-03-18 11:08:20 +09:00
|
|
|
self.update_version_labels()
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-18 11:08:20 +09:00
|
|
|
# 버전 표시
|
|
|
|
|
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;")
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
self.lbl_target_count = QtWidgets.QLabel("감시 대상: 0개")
|
|
|
|
|
self.lbl_target_count.setStyleSheet("font-weight: bold; color: #1f1f1f;")
|
2026-03-18 11:08:20 +09:00
|
|
|
|
|
|
|
|
version_layout.addWidget(self.lbl_watchdog_version, 0, 0)
|
2026-08-25 10:41:44 +09:00
|
|
|
version_layout.addWidget(self.lbl_target_count, 0, 1)
|
2026-03-18 11:08:20 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
# 감시 대상 목록
|
|
|
|
|
target_group = QtWidgets.QGroupBox("감시 프로그램")
|
|
|
|
|
target_layout = QtWidgets.QVBoxLayout(target_group)
|
|
|
|
|
target_layout.setContentsMargins(8, 8, 8, 8)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
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)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
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)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
# 옵션 행
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-18 11:08:20 +09:00
|
|
|
main_layout.addLayout(version_layout)
|
2026-08-25 10:41:44 +09:00
|
|
|
main_layout.addWidget(target_group)
|
2026-03-18 11:07:16 +09:00
|
|
|
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):
|
2026-08-25 10:41:44 +09:00
|
|
|
self.btn_add_target.clicked.connect(self.add_target_programs)
|
|
|
|
|
self.btn_remove_target.clicked.connect(self.remove_selected_targets)
|
2026-03-18 11:07:16 +09:00
|
|
|
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)
|
2026-08-25 10:41:44 +09:00
|
|
|
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()
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
def _load_config_to_ui(self):
|
2026-03-18 11:56:09 +09:00
|
|
|
self._is_loading_config = True
|
|
|
|
|
try:
|
2026-08-25 10:41:44 +09:00
|
|
|
for path in self.cfg.get("target_exe_paths", []):
|
|
|
|
|
self._append_target_row(path)
|
2026-03-18 11:56:09 +09:00
|
|
|
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)))
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-03-18 11:56:09 +09:00
|
|
|
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 "로그 보기")
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-03-18 11:56:09 +09:00
|
|
|
if show_log:
|
2026-08-25 10:41:44 +09:00
|
|
|
self.resize(740, 650)
|
2026-03-18 11:56:09 +09:00
|
|
|
else:
|
2026-08-25 10:41:44 +09:00
|
|
|
self.resize(700, 430)
|
2026-03-18 11:56:09 +09:00
|
|
|
finally:
|
|
|
|
|
self._is_loading_config = False
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
self._update_target_count()
|
|
|
|
|
|
2026-03-18 11:07:16 +09:00
|
|
|
def save_ui_to_config(self):
|
2026-03-18 11:56:09 +09:00
|
|
|
if getattr(self, "_is_loading_config", False):
|
|
|
|
|
return
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
self.cfg["target_exe_paths"] = self.get_target_paths()
|
|
|
|
|
self.cfg.pop("target_exe_path", None)
|
2026-03-18 11:07:16 +09:00
|
|
|
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)
|
|
|
|
|
|
2026-03-18 11:08:20 +09:00
|
|
|
def update_version_labels(self):
|
|
|
|
|
self.lbl_watchdog_version.setText(f"Watchdog 버전: {APP_VERSION}")
|
2026-08-25 10:41:44 +09:00
|
|
|
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}개")
|
2026-03-18 11:08:20 +09:00
|
|
|
|
2026-03-18 11:07:16 +09:00
|
|
|
# ---------------------
|
|
|
|
|
# 트레이
|
|
|
|
|
# ---------------------
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
# ---------------------
|
|
|
|
|
# 기능
|
|
|
|
|
# ---------------------
|
2026-08-25 10:41:44 +09:00
|
|
|
def add_target_programs(self):
|
|
|
|
|
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(
|
2026-03-18 11:07:16 +09:00
|
|
|
self,
|
2026-08-25 10:41:44 +09:00
|
|
|
"감시할 EXE 선택 (여러 개 선택 가능)",
|
|
|
|
|
str(BASE_DIR),
|
2026-03-18 11:07:16 +09:00
|
|
|
"Executable Files (*.exe);;All Files (*.*)"
|
|
|
|
|
)
|
2026-08-25 10:41:44 +09:00
|
|
|
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()
|
2026-03-18 11:07:16 +09:00
|
|
|
self.save_ui_to_config()
|
|
|
|
|
self.update_target_status_label()
|
2026-08-25 10:41:44 +09:00
|
|
|
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()
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
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:
|
2026-08-25 10:41:44 +09:00
|
|
|
self.resize(max(self.width(), 740), 650)
|
2026-03-18 11:07:16 +09:00
|
|
|
else:
|
2026-08-25 10:41:44 +09:00
|
|
|
self.resize(max(700, self.width()), 430)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
exe_paths = self.get_target_paths()
|
|
|
|
|
if not exe_paths:
|
|
|
|
|
QtWidgets.QMessageBox.warning(self, "경고", "감시할 프로그램을 하나 이상 추가하세요.")
|
2026-03-18 11:07:16 +09:00
|
|
|
return
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
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}"
|
|
|
|
|
)
|
2026-03-18 11:07:16 +09:00
|
|
|
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;")
|
|
|
|
|
|
2026-03-18 11:08:20 +09:00
|
|
|
self.update_version_labels()
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
LOGGER.info(f"감시 시작: {len(exe_paths)}개 프로그램")
|
|
|
|
|
for exe_path in exe_paths:
|
|
|
|
|
LOGGER.info(f"감시 대상: {exe_path}")
|
2026-03-18 11:07:16 +09:00
|
|
|
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("감시 중지")
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
def _set_target_row_status(self, row, text, color):
|
|
|
|
|
item = self.target_table.item(row, 1)
|
|
|
|
|
item.setText(text)
|
|
|
|
|
item.setForeground(QtGui.QColor(color))
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
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"
|
2026-03-18 11:07:16 +09:00
|
|
|
else:
|
2026-08-25 10:41:44 +09:00
|
|
|
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('대상 상태: ', '')}")
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
def check_target_once(self):
|
|
|
|
|
try:
|
2026-08-25 10:41:44 +09:00
|
|
|
exe_paths = self.get_target_paths()
|
|
|
|
|
if not exe_paths:
|
|
|
|
|
self.update_target_status_label({})
|
|
|
|
|
LOGGER.warning("등록된 감시 대상이 없습니다.")
|
2026-03-18 11:07:16 +09:00
|
|
|
return
|
|
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
cooldown = int(self.spin_cooldown.value())
|
|
|
|
|
statuses = {}
|
|
|
|
|
restarted_names = []
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
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)
|
2026-03-18 11:08:20 +09:00
|
|
|
self.update_version_labels()
|
2026-03-18 11:07:16 +09:00
|
|
|
|
2026-08-25 10:41:44 +09:00
|
|
|
if restarted_names:
|
|
|
|
|
names = ", ".join(restarted_names)
|
2026-03-18 11:07:16 +09:00
|
|
|
self.tray_icon.showMessage(
|
|
|
|
|
APP_NAME,
|
2026-08-25 10:41:44 +09:00
|
|
|
f"종료된 프로그램을 자동 재실행했습니다: {names}",
|
2026-03-18 11:07:16 +09:00
|
|
|
QtWidgets.QSystemTrayIcon.Warning,
|
|
|
|
|
2500
|
|
|
|
|
)
|
2026-03-18 11:08:20 +09:00
|
|
|
QtCore.QTimer.singleShot(1500, self._refresh_after_restart)
|
2026-03-18 11:07:16 +09:00
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
LOGGER.error("감시 루프 오류 발생")
|
|
|
|
|
LOGGER.error(traceback.format_exc())
|
|
|
|
|
|
2026-03-18 11:08:20 +09:00
|
|
|
def _refresh_after_restart(self):
|
|
|
|
|
self.update_target_status_label()
|
|
|
|
|
self.update_version_labels()
|
|
|
|
|
|
2026-03-18 11:07:16 +09:00
|
|
|
# ---------------------
|
|
|
|
|
# 종료 / 최소화
|
|
|
|
|
# ---------------------
|
|
|
|
|
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__":
|
2026-08-25 10:41:44 +09:00
|
|
|
main()
|