add watchdogs

This commit is contained in:
2026-03-18 11:07:16 +09:00
commit 3e29bc197b
3 changed files with 1114 additions and 0 deletions
+356
View File
@@ -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 '^(?<user>[^@]+)@(?<host>[^:]+):(?<path>.+)$') {
$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 '^(?<user>[^@]+)@(?<host>[^:]+):(?<path>.+)$') {
$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)" }
+129
View File
@@ -0,0 +1,129 @@
<# ci\package.ps1
목적:
- PyInstaller 결과물을 out\ 폴더에 “릴리즈 업로드용” 파일로 패키징
- onefile: dist\<App>.exe -> out\<App>-<Tag>-windows.exe
- onedir : dist\<App>\... -> out\<App>-<Tag>-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\<App>\... 를 zip에 <App>\... 형태로 포함
Compress-Archive -LiteralPath $onedirPath -DestinationPath $zipPath -Force
} else {
# dist\<App>\ 내부만 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"
+629
View File
@@ -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()