KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의)
- Windows 이벤트 뷰어 자동 출퇴근 감지
- 30분 단위 연장근무 적립/사용 시스템
- 1.0/0.5/0.25일 연차·반차·반반차
- 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출
- 한국 공휴일 자동 등록 (음력 포함, holidays 패키지)
- matplotlib 차트 기반 주간/월간/패턴 통계
- 미니 위젯 + 시스템 트레이 통합
- 한국어/English i18n
- 자가 업데이트 (updater.exe + Gitea Releases)

아키텍처:
- core/ (db, time_calculator, notifier, i18n, version, settings_keys)
- ui/ (main_window + 9 dialogs + 3 controllers)
- utils/ (backup, lock_detector, debug_log, updater_client, time_format)
- tests/ (66 pytest 단위) + 통합/i18n GUI 검증

CI/CD:
- .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트
- .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:54:40 +09:00

149 lines
4.5 KiB
Python

"""
독립 자가 업데이터.
Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약을
헬퍼 프로세스로 우회. 메인 앱이 종료된 직후 파일 교체 + 재실행.
사용법 (메인 앱이 호출):
updater.exe --pid <메인_PID> --new <new_exe_path> --target <target_exe_path>
흐름:
1. 메인 앱 종료 대기 (PID 폴링, 최대 30초)
2. target_exe를 .bak으로 백업
3. new_exe → target_exe 이동
4. target_exe 재실행 + 업데이터 자가 종료
실패 시 .bak 복원
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
def is_pid_running(pid: int) -> bool:
"""Windows에서 PID 실행 중인지 확인."""
if sys.platform != 'win32':
try:
os.kill(pid, 0)
return True
except OSError:
return False
try:
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return False
try:
exit_code = wintypes.DWORD()
if kernel32.GetExitCodeProcess(h, ctypes.byref(exit_code)):
return exit_code.value == STILL_ACTIVE
return False
finally:
kernel32.CloseHandle(h)
except Exception:
return False
def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
"""PID가 종료될 때까지 폴링. timeout 시 False."""
deadline = time.time() + timeout_sec
while time.time() < deadline:
if not is_pid_running(pid):
return True
time.sleep(0.5)
return False
def replace_file(new_path: Path, target_path: Path) -> Path | None:
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
Returns:
백업 파일 경로 (롤백용). 실패 시 None.
"""
backup = target_path.with_suffix(target_path.suffix + '.bak')
try:
if backup.exists():
backup.unlink()
if target_path.exists():
shutil.move(str(target_path), str(backup))
shutil.move(str(new_path), str(target_path))
return backup
except OSError as e:
print(f"[updater] replace failed: {e}", file=sys.stderr)
# 롤백 시도
if backup.exists() and not target_path.exists():
try:
shutil.move(str(backup), str(target_path))
except OSError:
pass
return None
def launch(exe_path: Path) -> bool:
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
try:
if sys.platform == 'win32':
DETACHED_PROCESS = 0x00000008
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
else:
subprocess.Popen([str(exe_path)], close_fds=True)
return True
except OSError as e:
print(f"[updater] launch failed: {e}", file=sys.stderr)
return False
def main() -> int:
parser = argparse.ArgumentParser(description="Clock-out Calculator updater")
parser.add_argument('--pid', type=int, required=True, help='메인 앱 PID')
parser.add_argument('--new', required=True, help='새 .exe 경로')
parser.add_argument('--target', required=True, help='교체 대상 .exe 경로')
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
args = parser.parse_args()
new_exe = Path(args.new).resolve()
target_exe = Path(args.target).resolve()
if not new_exe.exists():
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
return 2
if not wait_for_exit(args.pid, timeout_sec=30):
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
return 3
# Windows 파일 핸들 해제 시간 여유
time.sleep(0.5)
backup = replace_file(new_exe, target_exe)
if backup is None:
return 4
if args.no_launch:
return 0
if not launch(target_exe):
# 시작 실패 시 롤백
try:
target_exe.unlink()
shutil.move(str(backup), str(target_exe))
launch(target_exe)
except OSError:
pass
return 5
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
return 0
if __name__ == '__main__':
sys.exit(main())