Added — 도전과제 시스템 (153개 자동 평가) - core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제 - ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿) - 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push - achievements 테이블 확장 (code/category/tier/is_secret/progress/target) - hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키 Changed — 다크 테마 디자인 리뉴얼 - ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress) - 통계/도움말/도전과제 다이얼로그 일관 다크 톤 - matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend) - 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드) Fixed — 안정성·일관성 - 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch) - DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환 - DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자) - crash_handler 다단계 폴백 (DB → 파일 → stderr) - updater PID race: 지수 backoff 재시도 (총 ~9초) - Discord URL 형식 검증 (snowflake regex) - 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증 - check_dinner_reminder 신규, 알림 임계값 5개 설정화 - closeEvent timer/notifier 정리 (aboutToQuit hook) - 마이그레이션 12개 모두 _conn() + try/finally - DB 인덱스 5개 추가 (break/overtime/leave date) Tests - pytest 116/116 PASS, 통합 시나리오 48/48 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
6.5 KiB
Python
190 lines
6.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,
|
|
max_retries: int = 5) -> Path | None:
|
|
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
|
|
|
|
Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
|
|
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
|
|
있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).
|
|
|
|
Returns:
|
|
백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
|
|
"""
|
|
backup = target_path.with_suffix(target_path.suffix + '.bak')
|
|
last_err: Exception | None = None
|
|
|
|
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
|
|
if backup.exists():
|
|
try:
|
|
backup.unlink()
|
|
except OSError as e:
|
|
print(f"[updater] old backup unlink failed (continuing): {e}",
|
|
file=sys.stderr)
|
|
|
|
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
|
for attempt in range(max_retries):
|
|
if not target_path.exists():
|
|
break # 첫 설치 등 — target 없으면 그냥 다음 단계로
|
|
try:
|
|
shutil.move(str(target_path), str(backup))
|
|
break
|
|
except OSError as e:
|
|
last_err = e
|
|
wait = 0.3 * (2 ** attempt)
|
|
print(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
|
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
|
time.sleep(wait)
|
|
else:
|
|
# 모든 재시도 실패
|
|
print(f"[updater] target move failed after {max_retries} attempts: {last_err}",
|
|
file=sys.stderr)
|
|
return None
|
|
|
|
# 3단계: new → target 이동
|
|
for attempt in range(max_retries):
|
|
try:
|
|
shutil.move(str(new_path), str(target_path))
|
|
return backup
|
|
except OSError as e:
|
|
last_err = e
|
|
wait = 0.3 * (2 ** attempt)
|
|
print(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
|
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
|
time.sleep(wait)
|
|
|
|
# new 이동 실패 → backup으로 롤백 시도
|
|
print(f"[updater] new move failed after {max_retries} attempts: {last_err}",
|
|
file=sys.stderr)
|
|
if backup.exists() and not target_path.exists():
|
|
try:
|
|
shutil.move(str(backup), str(target_path))
|
|
print("[updater] rolled back from backup", file=sys.stderr)
|
|
except OSError as e:
|
|
print(f"[updater] rollback also failed: {e}", file=sys.stderr)
|
|
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에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
|
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
|
|
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())
|