From 3e29bc197bfb8bdcb215266cbc34582ee7639fef Mon Sep 17 00:00:00 2001 From: DH K Date: Wed, 18 Mar 2026 11:07:16 +0900 Subject: [PATCH] add watchdogs --- .gitea/workflows/windows-build.yml | 356 ++++++++++++++++ ci/package.ps1 | 129 ++++++ watchdog_gui.py | 629 +++++++++++++++++++++++++++++ 3 files changed, 1114 insertions(+) create mode 100644 .gitea/workflows/windows-build.yml create mode 100644 ci/package.ps1 create mode 100644 watchdog_gui.py diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml new file mode 100644 index 0000000..eb564ae --- /dev/null +++ b/.gitea/workflows/windows-build.yml @@ -0,0 +1,356 @@ +name: windows-exe-build + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: [window, pyinstaller] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python venv + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + python -m venv .venv + .\.venv\Scripts\python -m pip install --upgrade pip + + if (Test-Path requirements.txt) { + .\.venv\Scripts\pip install -r requirements.txt + } + .\.venv\Scripts\pip install pyinstaller + + - name: Build (onedir) with optional --add-data + shell: powershell + env: + APP: ${{ vars.APP_NAME }} + ENTRY: ${{ vars.ENTRY_FILE }} + EXTRA: ${{ vars.PYINSTALLER_ARGS }} + ADD_DATA: ${{ vars.ADD_DATA }} # 예: "config.ini -> .\n dashboard/templates/dashboard -> templates/dashboard" + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:APP)) { throw "Missing var: APP_NAME" } + if ([string]::IsNullOrWhiteSpace($env:ENTRY)) { throw "Missing var: ENTRY_FILE" } + + # ------------------------- + # 1) EXTRA (단순 split) + # ------------------------- + $extraArgs = @() + + if (-not [string]::IsNullOrWhiteSpace($env:EXTRA)) { + $extraArgs = $env:EXTRA -split "\s+" + } + + # ------------------------- + # 2) ADD_DATA: "src -> dest" 를 "--add-data src;dest" 로 변환 (Windows는 ;) + # - 비어있으면 아무 것도 추가 안 함 + # - 여러 줄/쉼표 구분 모두 허용 + # ------------------------- + $addArgs = @() + + if (-not [string]::IsNullOrWhiteSpace($env:ADD_DATA)) { + + # 줄바꿈/쉼표로 분리 (사용자가 어느 방식으로 넣어도 되게) + $rules = $env:ADD_DATA ` + -split "(\r?\n|,)" ` + | ForEach-Object { $_.Trim() } ` + | Where-Object { $_ -and $_ -ne "-" } + + foreach ($r in $rules) { + # 지원 포맷: "src -> dest" 또는 "src=dest" 또는 "src|dest" + $src = $null + $dst = $null + + if ($r -match "^\s*(.+?)\s*->\s*(.+?)\s*$") { + $src = $Matches[1].Trim() + $dst = $Matches[2].Trim() + } + elseif ($r -match "^\s*(.+?)\s*[=|]\s*(.+?)\s*$") { + $src = $Matches[1].Trim() + $dst = $Matches[2].Trim() + } + else { + throw "ADD_DATA rule format invalid: '$r' (use 'src -> dest')" + } + + if ([string]::IsNullOrWhiteSpace($src) -or [string]::IsNullOrWhiteSpace($dst)) { + throw "ADD_DATA rule has empty src/dest: '$r'" + } + + # PyInstaller Windows delimiter is ';' + $pair = "$src;$dst" + + # 인자 추가: --add-data "src;dest" + $addArgs += @("--add-data", $pair) + } + } + + Write-Host "APP : $env:APP" + Write-Host "ENTRY : $env:ENTRY" + Write-Host "EXTRA : $env:EXTRA" + Write-Host "ADD_DATA : $env:ADD_DATA" + Write-Host "AddArgs : $($addArgs -join ' ')" + Write-Host "ExtraArgs: $($extraArgs -join ' ')" + + .\.venv\Scripts\pyinstaller ` + --name "$env:APP" ` + @addArgs ` + @extraArgs ` + "$env:ENTRY" + + - name: Package zip (onedir dist -> out) + shell: powershell + env: + APP: ${{ vars.APP_NAME }} + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:APP)) { throw "Missing var: APP_NAME" } + + $distDir = Join-Path $PWD "dist\$($env:APP)" + if (!(Test-Path $distDir)) { throw "Dist folder not found: $distDir" } + + New-Item -ItemType Directory -Force -Path "out" | Out-Null + + $zipName = "$($env:APP)-$($env:GITHUB_REF_NAME).zip" + $zipPath = Join-Path $PWD "out\$zipName" + + if (Test-Path $zipPath) { Remove-Item -Force $zipPath } + + Compress-Archive -Path "$distDir\*" -DestinationPath $zipPath -Force + + if (!(Test-Path $zipPath)) { throw "Zip not created: $zipPath" } + + Write-Host "Created: $zipPath" + $zipName | Out-File -FilePath zip_name.txt -Encoding ascii + + # ------------------------- + # Gitea Release 생성/업로드 + # ------------------------- + - name: Create Gitea release (if not exists) + shell: powershell + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO: ${{ gitea.repository }} + TAG: ${{ gitea.ref_name }} + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:TOKEN)) { throw "Missing secret: GITEA_TOKEN" } + if ([string]::IsNullOrWhiteSpace($env:REPO)) { throw "Missing context: gitea.repository" } + if ([string]::IsNullOrWhiteSpace($env:TAG)) { throw "Missing tag name" } + + $remote = (git config --get remote.origin.url).Trim() + if ([string]::IsNullOrWhiteSpace($remote)) { throw "Cannot determine remote.origin.url" } + + $baseUrl = $null + + if ($remote -match '^(https?)://') { + $u = [uri]$remote + $path = $u.AbsolutePath -replace '\.git$','' + $basePath = ($path -replace '/[^/]+/[^/]+$','') + if ([string]::IsNullOrWhiteSpace($basePath) -or $basePath -eq '/') { + $baseUrl = "{0}://{1}" -f $u.Scheme, $u.Authority + } else { + $baseUrl = "{0}://{1}{2}" -f $u.Scheme, $u.Authority, $basePath.TrimEnd('/') + } + } + elseif ($remote -match '^(?[^@]+)@(?[^:]+):(?.+)$') { + $host = $Matches.host + $path = ($Matches.path -replace '\.git$','') -replace '\\','/' + $basePath = ($path -replace '/[^/]+/[^/]+$','') + $basePath = $basePath.Trim('/') + + if ([string]::IsNullOrWhiteSpace($basePath)) { + $baseUrl = "https://$host" + } else { + $baseUrl = "https://$host/$basePath" + } + } + else { + throw "Unsupported remote URL format: $remote" + } + + Write-Host "Derived Gitea baseUrl: $baseUrl" + + $apiBase = "$($baseUrl.TrimEnd('/'))/api/v1" + $headers = @{ Authorization = "token $env:TOKEN" } + + $rel = $null + try { + $rel = Invoke-RestMethod -Method Get -Headers $headers -Uri "$apiBase/repos/$env:REPO/releases/tags/$env:TAG" + } catch { + $rel = $null + } + + if (-not $rel) { + $body = @{ + tag_name = $env:TAG + name = $env:TAG + body = "Automated build for $($env:TAG)" + draft = $false + prerelease = $false + } | ConvertTo-Json -Depth 5 + + $rel = Invoke-RestMethod -Method Post -Headers $headers -ContentType "application/json" -Body $body -Uri "$apiBase/repos/$env:REPO/releases" + } + + if (-not $rel.id) { throw "Failed to get/create release id" } + ($rel.id.ToString().Trim()) | Out-File -FilePath release_id.txt -Encoding ascii + Write-Host "Release id: $($rel.id)" + + - name: Upload release asset (zip) - replace if exists + shell: powershell + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO: ${{ gitea.repository }} + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:TOKEN)) { throw "Missing secret: GITEA_TOKEN" } + if ([string]::IsNullOrWhiteSpace($env:REPO)) { throw "Missing context: gitea.repository" } + + if (!(Test-Path "release_id.txt")) { throw "release_id.txt not found (Create release step failed?)" } + if (!(Test-Path "zip_name.txt")) { throw "zip_name.txt not found (Zip step failed?)" } + + $releaseId = (Get-Content release_id.txt | Out-String).Trim() + $zipName = (Get-Content zip_name.txt | Out-String).Trim() + + if ([string]::IsNullOrWhiteSpace($releaseId)) { throw "releaseId is empty after reading release_id.txt" } + if ([string]::IsNullOrWhiteSpace($zipName)) { throw "zipName is empty after reading zip_name.txt" } + + if (!(Test-Path "out\$zipName")) { throw "Zip file not found: out\$zipName" } + + $remote = (git config --get remote.origin.url).Trim() + if ([string]::IsNullOrWhiteSpace($remote)) { throw "Cannot determine remote.origin.url" } + + $baseUrl = $null + + if ($remote -match '^(https?)://') { + $u = [uri]$remote + $path = $u.AbsolutePath -replace '\.git$','' + $basePath = ($path -replace '/[^/]+/[^/]+$','') + if ([string]::IsNullOrWhiteSpace($basePath) -or $basePath -eq '/') { + $baseUrl = "{0}://{1}" -f $u.Scheme, $u.Authority + } else { + $baseUrl = "{0}://{1}{2}" -f $u.Scheme, $u.Authority, $basePath.TrimEnd('/') + } + } + elseif ($remote -match '^(?[^@]+)@(?[^:]+):(?.+)$') { + $host = $Matches.host + $path = ($Matches.path -replace '\.git$','') -replace '\\','/' + $basePath = ($path -replace '/[^/]+/[^/]+$','') + $basePath = $basePath.Trim('/') + + if ([string]::IsNullOrWhiteSpace($basePath)) { + $baseUrl = "https://$host" + } else { + $baseUrl = "https://$host/$basePath" + } + } + else { + throw "Unsupported remote URL format: $remote" + } + + Write-Host "Derived Gitea baseUrl: $baseUrl" + + $apiBase = "$($baseUrl.TrimEnd('/'))/api/v1" + $headers = @{ Authorization = "token $env:TOKEN" } + + $assets = Invoke-RestMethod -Method Get -Headers $headers -Uri "$apiBase/repos/$env:REPO/releases/$releaseId/assets" + foreach ($a in $assets) { + if ($a.name -eq $zipName) { + Write-Host "Deleting existing asset: $zipName (id=$($a.id))" + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$apiBase/repos/$env:REPO/releases/$releaseId/assets/$($a.id)" + } + } + + $escaped = [uri]::EscapeDataString($zipName) + $uploadUrl = "{0}/repos/{1}/releases/{2}/assets?name={3}" -f ` + $apiBase.TrimEnd('/'), $env:REPO, $releaseId, $escaped + + Write-Host ("Uploading to: [{0}]" -f $uploadUrl) + + & curl.exe -L --fail -X POST ` + -H "Authorization: token $env:TOKEN" ` + -H "Content-Type: application/octet-stream" ` + --data-binary "@out/$zipName" ` + "$uploadUrl" + + if ($LASTEXITCODE -ne 0) { throw "curl upload failed: $zipName (exit=$LASTEXITCODE)" } + + # ------------------------- + # Nextcloud(WebDAV) 업로드: 버전 폴더 + latest + # ------------------------- + - name: Upload zip to Nextcloud Releases (WebDAV) - version folder + latest + shell: powershell + env: + NC_USER: ${{ secrets.NEXTCLOUD_USER }} + NC_PASS: ${{ secrets.NEXTCLOUD_PASS }} + NC_BASE: ${{ secrets.NEXTCLOUD_BASE }} # .../Releases + APP: ${{ vars.APP_NAME }} # pop_dis + TAG: ${{ gitea.ref_name }} # v2.5.10 + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:NC_USER)) { throw "Missing secret: NEXTCLOUD_USER" } + if ([string]::IsNullOrWhiteSpace($env:NC_PASS)) { throw "Missing secret: NEXTCLOUD_PASS" } + if ([string]::IsNullOrWhiteSpace($env:NC_BASE)) { throw "Missing secret: NEXTCLOUD_BASE" } + if ([string]::IsNullOrWhiteSpace($env:APP)) { throw "Missing var: APP_NAME" } + if ([string]::IsNullOrWhiteSpace($env:TAG)) { throw "Missing tag name" } + + if (!(Test-Path "zip_name.txt")) { throw "zip_name.txt not found (Zip step failed?)" } + $zipName = (Get-Content zip_name.txt | Out-String).Trim() + + if ([string]::IsNullOrWhiteSpace($zipName)) { throw "zipName is empty after reading zip_name.txt" } + if (!(Test-Path "out\$zipName")) { throw "Zip file not found: out\$zipName" } + + $base = $env:NC_BASE.TrimEnd('/') + + $appPath = $env:APP.Trim('/') + $tagPath = $env:TAG.Trim('/') + $verDirUrl = "$base/$appPath/$tagPath" + $latestDirUrl = "$base/$appPath/latest" + + $latestName = "$($env:APP).zip" + $verFileUrl = "$verDirUrl/$zipName" + $latestUrl = "$latestDirUrl/$latestName" + + Write-Host "Nextcloud base : $base" + Write-Host "Version dir URL : $verDirUrl" + Write-Host "Latest dir URL : $latestDirUrl" + Write-Host "Version file URL : $verFileUrl" + Write-Host "Latest file URL : $latestUrl" + + function MkcolIgnoreFail([string]$url) { + & curl.exe -L -sS -o NUL -w "%{http_code}" -X MKCOL ` + -u "$($env:NC_USER):$($env:NC_PASS)" ` + "$url" | ForEach-Object { + Write-Host "MKCOL $url -> HTTP $_" + } + } + + MkcolIgnoreFail "$base/$appPath" + MkcolIgnoreFail "$base/$appPath/$tagPath" + MkcolIgnoreFail "$base/$appPath/latest" + + Write-Host "Uploading versioned zip -> $verFileUrl" + & curl.exe -L --fail -X PUT ` + -u "$($env:NC_USER):$($env:NC_PASS)" ` + --data-binary "@out/$zipName" ` + "$verFileUrl" + if ($LASTEXITCODE -ne 0) { throw "Nextcloud upload failed (versioned) (exit=$LASTEXITCODE)" } + + Write-Host "Uploading latest zip -> $latestUrl" + & curl.exe -L --fail -X PUT ` + -u "$($env:NC_USER):$($env:NC_PASS)" ` + --data-binary "@out/$zipName" ` + "$latestUrl" + if ($LASTEXITCODE -ne 0) { throw "Nextcloud upload failed (latest) (exit=$LASTEXITCODE)" } diff --git a/ci/package.ps1 b/ci/package.ps1 new file mode 100644 index 0000000..baf0857 --- /dev/null +++ b/ci/package.ps1 @@ -0,0 +1,129 @@ +<# ci\package.ps1 + 목적: + - PyInstaller 결과물을 out\ 폴더에 “릴리즈 업로드용” 파일로 패키징 + - onefile: dist\.exe -> out\--windows.exe + - onedir : dist\\... -> out\--windows.zip + + 사용: + powershell -NoProfile -ExecutionPolicy Bypass -File .\ci\package.ps1 -App "myapp" -Tag "v0.1.11" +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$App, + + [Parameter(Mandatory = $true)] + [string]$Tag, + + # 작업 루트(기본: 현재 워크스페이스) + [string]$Root = ".", + + # 결과물을 넣을 폴더 + [string]$OutDir = "out", + + # PyInstaller 기본 출력 폴더 + [string]$DistDir = "dist", + + # zip 생성 시 include 루트 폴더 자체를 포함할지 여부(기본: 포함) + [switch]$IncludeRootFolder, + + # 기존 out\ 파일 삭제 여부(기본: true) + [switch]$CleanOut = $true, + + # sha256 체크섬 파일 생성 여부 + [switch]$WriteChecksum = $true +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-FullPath([string]$p) { + return (Resolve-Path -LiteralPath $p).Path +} + +function Ensure-Dir([string]$p) { + if (-not (Test-Path -LiteralPath $p)) { + New-Item -ItemType Directory -Path $p | Out-Null + } +} + +function Write-Sha256([string]$filePath, [string]$outPath) { + $h = Get-FileHash -Algorithm SHA256 -LiteralPath $filePath + # "SHA256 filename" 형태 + "$($h.Hash) $([IO.Path]::GetFileName($filePath))" | Out-File -LiteralPath $outPath -Encoding ascii +} + +# ---- paths ---- +$rootPath = Resolve-FullPath $Root +$distPath = Join-Path $rootPath $DistDir +$outPath = Join-Path $rootPath $OutDir + +Ensure-Dir $outPath + +if ($CleanOut) { + Get-ChildItem -LiteralPath $outPath -File -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue +} + +if (-not (Test-Path -LiteralPath $distPath)) { + throw "Dist folder not found: $distPath (PyInstaller output missing?)" +} + +# ---- decide output type ---- +$exePath = Join-Path $distPath "$App.exe" +$onedirPath = Join-Path $distPath $App + +# 파일명 규칙(윈도우 릴리즈 자산명) +# 예: parameter_editor-v0.1.11-windows.exe / .zip +$baseName = "$App-$Tag-windows" + +Write-Host "[PACKAGE] App=$App Tag=$Tag" +Write-Host "[PACKAGE] dist=$distPath" +Write-Host "[PACKAGE] out =$outPath" + +# ---- onefile ---- +if (Test-Path -LiteralPath $exePath) { + $destExe = Join-Path $outPath "$baseName.exe" + Copy-Item -LiteralPath $exePath -Destination $destExe -Force + Write-Host "[PACKAGE] Copied onefile exe -> $destExe" + + if ($WriteChecksum) { + $shaPath = Join-Path $outPath "$baseName.exe.sha256" + Write-Sha256 $destExe $shaPath + Write-Host "[PACKAGE] Wrote checksum -> $shaPath" + } + + exit 0 +} + +# ---- onedir ---- +if (Test-Path -LiteralPath $onedirPath) { + $zipPath = Join-Path $outPath "$baseName.zip" + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + + if ($IncludeRootFolder) { + # dist\\... 를 zip에 \... 형태로 포함 + Compress-Archive -LiteralPath $onedirPath -DestinationPath $zipPath -Force + } else { + # dist\\ 내부만 zip에 담기 (루트 폴더 없이) + $items = Get-ChildItem -LiteralPath $onedirPath + if (-not $items -or $items.Count -eq 0) { + throw "Onedir folder is empty: $onedirPath" + } + Compress-Archive -LiteralPath $items.FullName -DestinationPath $zipPath -Force + } + + Write-Host "[PACKAGE] Created onedir zip -> $zipPath" + + if ($WriteChecksum) { + $shaPath = Join-Path $outPath "$baseName.zip.sha256" + Write-Sha256 $zipPath $shaPath + Write-Host "[PACKAGE] Wrote checksum -> $shaPath" + } + + exit 0 +} + +throw "No PyInstaller output found. Expected one of:`n- $exePath`n- $onedirPath" diff --git a/watchdog_gui.py b/watchdog_gui.py new file mode 100644 index 0000000..d627e69 --- /dev/null +++ b/watchdog_gui.py @@ -0,0 +1,629 @@ +# -*- 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() \ No newline at end of file