68893236+KINDNICK@users.noreply.github.com 3db4ed2351 v2.10.1: 업데이트 시 cmd 창 깜빡임 제거 (hotfix)
- updater.spec: console=True → console=False (windowed 빌드)
- updater.py: stderr 출력을 ~/.clockout_logs/updater.log 파일 폴백으로 전환
  (windowed 모드라도 진단 로그 보존). 모든 단계 타임스탬프 기록.
- updater.py launch(): subprocess.Popen에 CREATE_NO_WINDOW 플래그 추가
- utils/updater_client.py apply_update(): 같은 패턴으로 CREATE_NO_WINDOW 추가
  main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:33:55 +09:00

254 lines
8.3 KiB
Python

"""
업데이트 클라이언트 — Gitea/GitHub Releases 호환.
자체 호스팅 Gitea 인스턴스 사용. Gitea API가 GitHub과 호환되어 endpoint URL만
바꾸면 동일 로직 동작.
흐름:
1. `check_for_update()`: Releases API → 최신 태그 조회 → 현재 버전과 비교
2. `download_update(asset_url)`: 새 .exe를 임시 폴더에 다운로드
3. `apply_update(new_exe)`: updater.exe 실행 + 메인 앱 종료
환경변수로 URL 오버라이드 가능 (테스트/마이그레이션 용도):
- CLOCKOUT_RELEASES_API: 전체 API endpoint URL
- CLOCKOUT_ASSET_NAME: 다운로드할 자산 파일명 (기본 main.exe)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
# 자체 호스팅 Gitea 인스턴스 (GitHub 호환 API)
GIT_HOST = 'https://kindnick-git.duckdns.org'
GIT_OWNER = 'kindnick'
GIT_REPO = 'Clock_out_Time_Calculator'
# Gitea API endpoint: /api/v1/repos/{owner}/{repo}/releases/latest
# (GitHub의 경우: https://api.github.com/repos/{owner}/{repo}/releases/latest)
DEFAULT_RELEASES_API = f'{GIT_HOST}/api/v1/repos/{GIT_OWNER}/{GIT_REPO}/releases/latest'
RELEASES_API = os.environ.get('CLOCKOUT_RELEASES_API', DEFAULT_RELEASES_API)
# 다운로드할 .exe 자산 이름 (Releases 첨부 파일명과 일치해야 함)
ASSET_NAME = os.environ.get('CLOCKOUT_ASSET_NAME', 'main.exe')
USER_AGENT = 'ClockOutCalculator-Updater/1.0'
@dataclass
class ReleaseInfo:
version: str # 'v2.1.0' or '2.1.0'
asset_url: str # main.exe 다운로드 URL
notes: str # 릴리스 노트 (markdown)
published_at: str
@property
def version_clean(self) -> str:
"""'v2.1.0''2.1.0'"""
return self.version.lstrip('vV')
def _parse_version(s: str) -> tuple:
"""semver-ish 파싱. 'v2.1.0' / '2.1' → (2,1,0). 비교용."""
s = s.lstrip('vV').strip()
parts = s.split('.')
out = []
for p in parts:
# 'rc1' 등 접미사 제거
digits = ''
for c in p:
if c.isdigit():
digits += c
else:
break
out.append(int(digits) if digits else 0)
while len(out) < 3:
out.append(0)
return tuple(out[:3])
def is_newer(remote: str, local: str) -> bool:
"""remote가 local보다 최신이면 True."""
return _parse_version(remote) > _parse_version(local)
# 업데이트 체크 결과 상수 (info=None 일 때 reason 식별)
UP_TO_DATE = 'up_to_date'
NETWORK_ERROR = 'network_error'
NO_RELEASE = 'no_release' # latest 응답 자체가 없음 (404 등)
NO_ASSET = 'no_asset' # release는 있으나 main.exe 자산 없음
def check_for_update(current_version: str, timeout: int = 5):
"""Releases API 조회.
Returns:
(ReleaseInfo, None) — 새 버전 있음
(None, UP_TO_DATE) — 이미 최신
(None, NETWORK_ERROR) — 네트워크/타임아웃 실패
(None, NO_RELEASE) — 저장소 비공개/리소스 없음 (404)
(None, NO_ASSET) — 새 버전이지만 main.exe 자산 미첨부
"""
try:
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None, NO_RELEASE
return None, NETWORK_ERROR
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError):
return None, NETWORK_ERROR
tag = data.get('tag_name', '')
if not tag:
return None, NO_RELEASE
if not is_newer(tag, current_version):
return None, UP_TO_DATE
asset_url = None
for asset in data.get('assets', []):
if asset.get('name') == ASSET_NAME:
asset_url = asset.get('browser_download_url')
break
if not asset_url:
return None, NO_ASSET
info = ReleaseInfo(
version=tag,
asset_url=asset_url,
notes=data.get('body', ''),
published_at=data.get('published_at', ''),
)
return info, None
def download_update(asset_url: str, dest_dir: Optional[Path] = None,
progress_cb=None) -> Optional[Path]:
"""새 .exe를 임시 폴더에 다운로드.
Args:
asset_url: GitHub Releases asset URL
dest_dir: 저장 위치 (기본: 메인 .exe 옆에 main_new.exe)
progress_cb: callable(downloaded_bytes, total_bytes) — 진행률 콜백
Returns:
다운로드된 파일 경로, 실패 시 None.
"""
if dest_dir is None:
dest_dir = _exe_dir()
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / 'main_new.exe'
if dest.exists():
try:
dest.unlink()
except OSError:
return None
try:
req = urllib.request.Request(asset_url, headers={'User-Agent': USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
total = int(resp.headers.get('Content-Length', 0))
downloaded = 0
chunk_size = 64 * 1024
with open(dest, 'wb') as f:
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_cb:
progress_cb(downloaded, total)
return dest
except (urllib.error.URLError, OSError, TimeoutError):
if dest.exists():
try:
dest.unlink()
except OSError:
pass
return None
def apply_update(new_exe: Path) -> bool:
"""updater.exe를 실행하여 파일 교체 + 재시작 트리거.
호출 직후 메인 앱은 종료되어야 함 (Qt: QApplication.quit()).
"""
target_exe = _current_exe()
updater_exe = _find_updater()
if not updater_exe or not target_exe:
return False
pid = os.getpid()
try:
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
if sys.platform == 'win32':
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
else:
creationflags = 0
subprocess.Popen(
[str(updater_exe),
'--pid', str(pid),
'--new', str(new_exe),
'--target', str(target_exe)],
creationflags=creationflags,
close_fds=True,
)
return True
except OSError:
return False
def _exe_dir() -> Path:
"""현재 .exe가 있는 폴더 (PyInstaller frozen) 또는 main.py 위치."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).parent
return Path(__file__).resolve().parent.parent
def _current_exe() -> Optional[Path]:
"""현재 실행 중인 메인 .exe. 개발 환경(.py)에선 None."""
if getattr(sys, 'frozen', False):
return Path(sys.executable)
return None
def _find_updater() -> Optional[Path]:
"""updater.exe 찾기.
1) 메인 .exe와 같은 폴더 (정상 배포 상태)
2) PyInstaller _MEIPASS — main.exe에 내장된 fallback
(main.py 시작 시 _ensure_updater_extracted()가 #1로 복사하므로 평소엔 미사용)
"""
candidate = _exe_dir() / 'updater.exe'
if candidate.exists():
return candidate
# _MEIPASS fallback — main.py 시작 추출이 권한 부족 등으로 실패한 경우
if getattr(sys, 'frozen', False):
meipass = getattr(sys, '_MEIPASS', None)
if meipass:
bundled = Path(meipass) / 'updater.exe'
if bundled.exists():
# 사용자 TEMP 폴더에 복사 (메인 종료 후에도 살아남도록)
import tempfile, shutil
tmp = Path(tempfile.gettempdir()) / 'clockout_updater.exe'
try:
shutil.copy2(str(bundled), str(tmp))
return tmp
except OSError:
pass
return None