add watchdogs
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user