diff --git a/watchdog_gui.py b/watchdog_gui.py index 34b3fc8..360c68f 100644 --- a/watchdog_gui.py +++ b/watchdog_gui.py @@ -16,7 +16,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets APP_NAME = "Watchdog GUI" -APP_VERSION = "1.1.2" +APP_VERSION = "1.2.0" BASE_DIR = Path(__file__).resolve().parent CONFIG_PATH = BASE_DIR / "watchdog_config.json" @@ -25,7 +25,7 @@ LOG_FILE = LOG_DIR / "watchdog.log" DEFAULT_CONFIG = { - "target_exe_path": "", + "target_exe_paths": [], "check_interval_sec": 5, "restart_cooldown_sec": 10, "auto_start_monitoring": False, @@ -67,23 +67,24 @@ LOGGER = setup_logger() # ========================= # UI 로그 핸들러 # ========================= -class QTextEditLogger(logging.Handler, QtCore.QObject): +class LogSignalEmitter(QtCore.QObject): log_signal = QtCore.pyqtSignal(str) + +class QTextEditLogger(logging.Handler): def __init__(self, text_edit: QtWidgets.QPlainTextEdit): - logging.Handler.__init__(self) - QtCore.QObject.__init__(self) + super().__init__() self.text_edit = text_edit - self.log_signal.connect(self.append_log) + self.signal_emitter = LogSignalEmitter() + self.signal_emitter.log_signal.connect(self.append_log) def emit(self, record): try: msg = self.format(record) - self.log_signal.emit(msg) + self.signal_emitter.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() @@ -103,6 +104,23 @@ def load_config(): 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("설정 파일 로드 실패, 기본값 사용") @@ -209,8 +227,7 @@ def is_process_running_by_path(target_exe_path: str) -> bool: proc_exe_n = normalize_path(proc_exe) if proc_exe_n == target_exe_path_n: return True - - if proc_name == target_name: + elif proc_name == target_name: return True except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): @@ -251,13 +268,13 @@ class WatchdogWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") - self.resize(560, 300) - self.setMinimumSize(520, 270) + self.resize(700, 430) + self.setMinimumSize(620, 380) self.cfg = load_config() self._is_loading_config = False self.monitoring = False - self.last_restart_time = 0.0 + self.last_restart_times = {} self._really_quit = False self._tray_shown_once = False @@ -303,24 +320,37 @@ class WatchdogWindow(QtWidgets.QMainWindow): self.lbl_watchdog_version = QtWidgets.QLabel(f"Watchdog 버전: {APP_VERSION}") self.lbl_watchdog_version.setStyleSheet("font-weight: bold; color: #1f1f1f;") - self.lbl_target_version = QtWidgets.QLabel("대상 버전: 알 수 없음") - self.lbl_target_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_version, 0, 1) + version_layout.addWidget(self.lbl_target_count, 0, 1) - # 경로 행 - path_layout = QtWidgets.QHBoxLayout() - path_layout.setSpacing(6) + # 감시 대상 목록 + target_group = QtWidgets.QGroupBox("감시 프로그램") + target_layout = QtWidgets.QVBoxLayout(target_group) + target_layout.setContentsMargins(8, 8, 8, 8) - self.edit_exe_path = QtWidgets.QLineEdit() - self.edit_exe_path.setPlaceholderText("감시할 EXE 경로 선택") - self.btn_browse = QtWidgets.QPushButton("찾기") - self.btn_browse.setFixedWidth(70) + 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) - path_layout.addWidget(QtWidgets.QLabel("대상")) - path_layout.addWidget(self.edit_exe_path, 1) - path_layout.addWidget(self.btn_browse) + 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() @@ -403,21 +433,21 @@ class WatchdogWindow(QtWidgets.QMainWindow): log_layout.addWidget(self.btn_clear_log) main_layout.addLayout(version_layout) - main_layout.addLayout(path_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_browse.clicked.connect(self.browse_exe) + 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.edit_exe_path.editingFinished.connect(self.on_path_edited) 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) @@ -430,11 +460,21 @@ class WatchdogWindow(QtWidgets.QMainWindow): 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: - self.edit_exe_path.setText(self.cfg.get("target_exe_path", "")) + 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))) @@ -446,17 +486,20 @@ class WatchdogWindow(QtWidgets.QMainWindow): self.btn_toggle_log.setText("로그 숨기기" if show_log else "로그 보기") if show_log: - self.resize(600, 540) + self.resize(740, 650) else: - self.resize(560, 300) + 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_path"] = self.edit_exe_path.text().strip() + 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()) @@ -465,16 +508,27 @@ class WatchdogWindow(QtWidgets.QMainWindow): self.cfg["show_log_panel"] = bool(self.log_group.isVisible()) save_config(self.cfg) - def on_path_edited(self): - self.save_ui_to_config() - self.update_target_status_label() - self.update_version_labels() - def update_version_labels(self): - exe_path = self.edit_exe_path.text().strip() - target_ver = get_file_version(exe_path) self.lbl_watchdog_version.setText(f"Watchdog 버전: {APP_VERSION}") - self.lbl_target_version.setText(f"대상 버전: {target_ver}") + 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}개") # --------------------- # 트레이 @@ -535,19 +589,47 @@ class WatchdogWindow(QtWidgets.QMainWindow): # --------------------- # 기능 # --------------------- - def browse_exe(self): - path, _ = QtWidgets.QFileDialog.getOpenFileName( + def add_target_programs(self): + paths, _ = QtWidgets.QFileDialog.getOpenFileNames( self, - "감시할 EXE 선택", - self.edit_exe_path.text().strip() or str(BASE_DIR), + "감시할 EXE 선택 (여러 개 선택 가능)", + str(BASE_DIR), "Executable Files (*.exe);;All Files (*.*)" ) - if path: - self.edit_exe_path.setText(path) + 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_version_labels() - LOGGER.info(f"감시 대상 선택: {path}") 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() @@ -555,9 +637,9 @@ class WatchdogWindow(QtWidgets.QMainWindow): self.btn_toggle_log.setText("로그 숨기기" if visible else "로그 보기") if visible: - self.resize(max(self.width(), 600), 540) + self.resize(max(self.width(), 740), 650) else: - self.resize(max(560, self.width()), 300) + self.resize(max(700, self.width()), 430) self.save_ui_to_config() @@ -573,13 +655,19 @@ class WatchdogWindow(QtWidgets.QMainWindow): 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, "경고", "감시할 프로그램을 먼저 선택하세요.") + exe_paths = self.get_target_paths() + if not exe_paths: + QtWidgets.QMessageBox.warning(self, "경고", "감시할 프로그램을 하나 이상 추가하세요.") return - if not os.path.exists(exe_path): - QtWidgets.QMessageBox.warning(self, "경고", "선택한 프로그램 경로가 존재하지 않습니다.") + 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 @@ -597,7 +685,9 @@ class WatchdogWindow(QtWidgets.QMainWindow): self.update_version_labels() - LOGGER.info(f"감시 시작: {exe_path}") + 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() @@ -616,66 +706,96 @@ class WatchdogWindow(QtWidgets.QMainWindow): 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 + def _set_target_row_status(self, row, text, color): + item = self.target_table.item(row, 1) + item.setText(text) + item.setForeground(QtGui.QColor(color)) - if running: - self.lbl_target.setText("대상 상태: 실행 중") - self.lbl_target.setStyleSheet("font-weight: bold; color: #007a1f;") - self.tray_icon.setToolTip(f"{APP_NAME} - 대상 실행 중") + 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: - self.lbl_target.setText("대상 상태: 종료됨") - self.lbl_target.setStyleSheet("font-weight: bold; color: #b00020;") - self.tray_icon.setToolTip(f"{APP_NAME} - 대상 종료됨") + 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_path = self.edit_exe_path.text().strip() - - if not exe_path: - self.update_target_status_label(False) - self.update_version_labels() - LOGGER.warning("감시 대상 경로가 비어 있습니다.") + exe_paths = self.get_target_paths() + if not exe_paths: + self.update_target_status_label({}) + LOGGER.warning("등록된 감시 대상이 없습니다.") return - if not os.path.exists(exe_path): - self.update_target_status_label(False) - self.update_version_labels() - LOGGER.error(f"감시 대상 파일이 존재하지 않습니다: {exe_path}") - return + cooldown = int(self.spin_cooldown.value()) + statuses = {} + restarted_names = [] - running = is_process_running_by_path(exe_path) - self.update_target_status_label(running) + 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 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() + 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) - else: - self.update_target_status_label(False) except Exception: LOGGER.error("감시 루프 오류 발생") @@ -737,4 +857,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main()