Initial release v2.2.0
CI / test (push) Has been cancelled

핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (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>
This commit is contained in:
KINDNICK
2026-04-30 12:54:40 +09:00
co-authored by Claude Opus 4.7
commit bedbb1e9ec
63 changed files with 13353 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# utils 모듈
+75
View File
@@ -0,0 +1,75 @@
"""
DB 자동 백업 유틸리티.
전략:
- 앱 시작 시 1일 1회만 백업 (last_backup_date 설정 키로 가드)
- 사용자 폴더(`~/.clockout_backups/`)에 회전 보관 (기본 7개)
- SQLite의 안전한 백업 API(sqlite3.Connection.backup) 사용 — 락 안전
"""
from __future__ import annotations
import os
import sqlite3
from datetime import date
from pathlib import Path
from typing import Optional
from core.settings_keys import LAST_BACKUP_DATE
DEFAULT_BACKUP_DIR = Path.home() / '.clockout_backups'
DEFAULT_KEEP = 7
def backup_db_if_needed(db, source_path: str = "database.db",
backup_dir: Optional[Path] = None,
keep: int = DEFAULT_KEEP) -> Optional[Path]:
"""오늘 첫 실행이면 백업 1개 만들고 오래된 것 회전.
Args:
db: Database 인스턴스 (set_setting/get_setting 사용)
source_path: 원본 DB 파일 경로
backup_dir: 백업 저장 디렉토리 (기본 ~/.clockout_backups)
keep: 보관할 백업 개수
Returns:
생성된 백업 파일 경로, 또는 이미 오늘 백업했으면 None
"""
today = date.today().isoformat()
if db.get_setting(LAST_BACKUP_DATE, '') == today:
return None
src = Path(source_path)
if not src.exists():
return None
target_dir = backup_dir or DEFAULT_BACKUP_DIR
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"database-{today}.db"
# SQLite 백업 API: WAL/락 환경에서도 안전한 일관성 있는 복사
src_conn = sqlite3.connect(str(src))
try:
dest_conn = sqlite3.connect(str(target))
try:
src_conn.backup(dest_conn)
finally:
dest_conn.close()
finally:
src_conn.close()
db.set_setting(LAST_BACKUP_DATE, today)
_rotate(target_dir, keep)
return target
def _rotate(directory: Path, keep: int) -> None:
"""오래된 백업 제거 — 최신 keep개만 유지"""
files = sorted(
(p for p in directory.glob('database-*.db') if p.is_file()),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for old in files[keep:]:
try:
old.unlink()
except OSError:
pass
+157
View File
@@ -0,0 +1,157 @@
"""
CSV 내보내기 유틸리티
"""
import csv
from datetime import datetime
from typing import List, Dict
import os
class CSVExporter:
"""CSV 내보내기 클래스"""
@staticmethod
def export_work_records(records: List[Dict], filename: str = None) -> str:
"""
근무 기록을 CSV로 내보내기
Args:
records: 근무 기록 리스트
filename: 저장할 파일명 (None이면 자동 생성)
Returns:
str: 저장된 파일 경로
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_{timestamp}.csv"
# CSV 파일 작성
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = [
'날짜', '출근시간', '퇴근시간', '점심시간',
'총근무시간', '연장근무(분)', '적립(분)', '메모'
]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
row = {
'날짜': record.get('date', ''),
'출근시간': record.get('clock_in', ''),
'퇴근시간': record.get('clock_out', '미기록'),
'점심시간': '사용' if record.get('lunch_break') else '미사용',
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
'연장근무(분)': record.get('overtime_minutes', 0),
'적립(분)': record.get('overtime_earned', 0),
'메모': record.get('memo', '')
}
writer.writerow(row)
return filename
@staticmethod
def export_overtime_summary(db, filename: str = None) -> str:
"""
연장근무 요약 내보내기
Args:
db: Database 인스턴스
filename: 저장할 파일명
Returns:
str: 저장된 파일 경로
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"overtime_summary_{timestamp}.csv"
# 연장근무 내역 가져오기
overtime_history = db.get_overtime_history(limit=100)
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = ['유형', '날짜', '시간(분)', '출근', '퇴근']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for item in overtime_history:
row = {
'유형': '적립' if item['type'] == 'earned' else '사용',
'날짜': item.get('date', ''),
'시간(분)': item.get('minutes', 0),
'출근': item.get('clock_in', ''),
'퇴근': item.get('clock_out', '')
}
writer.writerow(row)
return filename
@staticmethod
def export_monthly_summary(db, year: int, month: int, filename: str = None) -> str:
"""
월간 요약 내보내기
Args:
db: Database 인스턴스
year: 년도
month: 월
filename: 저장할 파일명
Returns:
str: 저장된 파일 경로
"""
if filename is None:
filename = f"monthly_summary_{year}{month:02d}.csv"
stats = db.get_monthly_stats(year, month)
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.writer(csvfile)
# 요약 정보
writer.writerow([f"{year}년 {month}월 근무 요약"])
writer.writerow([])
writer.writerow(['항목', '값'])
writer.writerow(['총 근무시간', f"{stats['total_hours']:.1f}시간"])
writer.writerow(['근무일수', f"{stats['work_days']}일"])
if stats['work_days'] > 0:
avg = stats['total_hours'] / stats['work_days']
writer.writerow(['평균 근무시간', f"{avg:.1f}시간"])
overtime_hours = stats['total_overtime_minutes'] // 60
overtime_mins = stats['total_overtime_minutes'] % 60
writer.writerow(['총 연장근무', f"{overtime_hours}시간 {overtime_mins}분"])
writer.writerow([])
writer.writerow([])
# 상세 기록
writer.writerow(['날짜', '출근', '퇴근', '총근무시간', '연장근무'])
for record in stats['records']:
overtime_min = record.get('overtime_earned', 0)
overtime_h = overtime_min // 60
overtime_m = overtime_min % 60
overtime_str = f"{overtime_h}시간 {overtime_m}분" if overtime_min > 0 else "-"
writer.writerow([
record.get('date', ''),
record.get('clock_in', ''),
record.get('clock_out', '미기록'),
f"{record.get('total_hours', 0):.1f}시간",
overtime_str
])
return filename
# 테스트 코드
if __name__ == "__main__":
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
db = Database()
# 테스트: 이번 달 기록 내보내기
now = datetime.now()
filename = CSVExporter.export_monthly_summary(db, now.year, now.month)
print(f"Exported to: {filename}")
+33
View File
@@ -0,0 +1,33 @@
"""
환경변수 게이트 디버그 로깅.
`CLOCKOUT_DEBUG=1` 환경변수가 설정된 경우에만 stderr/파일로 출력.
프로덕션 빌드(PyInstaller)에서 항상 켜두지 않도록 게이트 처리.
"""
import os
import sys
from datetime import datetime
from pathlib import Path
_DEBUG_ENV = os.environ.get('CLOCKOUT_DEBUG', '').strip() in ('1', 'true', 'yes')
_LOG_DIR = Path(os.environ.get('CLOCKOUT_DEBUG_DIR') or Path.home() / '.clockout_logs')
_LOG_FILE = _LOG_DIR / 'debug.log'
def is_debug() -> bool:
return _DEBUG_ENV
def dlog(*args, file: str = None) -> None:
"""디버그 모드에서만 stderr + 파일로 기록. 비활성화 시 no-op."""
if not _DEBUG_ENV:
return
msg = f"[{datetime.now().strftime('%H:%M:%S')}] " + ' '.join(str(a) for a in args)
print(msg, file=sys.stderr)
try:
target = Path(file) if file else _LOG_FILE
target.parent.mkdir(parents=True, exist_ok=True)
with open(target, 'a', encoding='utf-8') as f:
f.write(msg + '\n')
except OSError:
pass
+45
View File
@@ -0,0 +1,45 @@
"""
Windows 화면 잠금 감지.
`OpenInputDesktop`/`GetUserObjectInformation`을 사용해 현재 활성 데스크톱이
"Winlogon"(잠금/사용자 전환 화면)인지 확인. 5초 간격 polling으로 충분 —
노이즈 적고 가벼운 방식.
"""
from __future__ import annotations
try:
import ctypes
from ctypes import wintypes
_WIN_AVAILABLE = True
except ImportError:
_WIN_AVAILABLE = False
def is_screen_locked() -> bool:
"""현재 화면이 잠금 상태(또는 사용자 전환 중)이면 True.
Windows 외 플랫폼이거나 권한 부족 시 False (안전한 기본값).
"""
if not _WIN_AVAILABLE:
return False
user32 = ctypes.windll.user32
DESKTOP_SWITCHDESKTOP = 0x0100
UOI_NAME = 2
# 현재 입력 받는 데스크탑 핸들
handle = user32.OpenInputDesktop(0, False, DESKTOP_SWITCHDESKTOP)
if not handle:
return False
try:
buf = ctypes.create_unicode_buffer(256)
needed = wintypes.DWORD(0)
ok = user32.GetUserObjectInformationW(
handle, UOI_NAME, buf, ctypes.sizeof(buf), ctypes.byref(needed)
)
if not ok:
return False
# 잠금/사용자 전환 시 "Winlogon", 보통은 "Default"
return buf.value.lower() != 'default'
finally:
user32.CloseDesktop(handle)
+161
View File
@@ -0,0 +1,161 @@
"""
리소스 매니저
아이콘, 사운드 등의 리소스 관리
"""
import os
from pathlib import Path
from typing import Optional
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtCore import QSize
class ResourceManager:
"""리소스 관리 클래스"""
def __init__(self):
# 프로젝트 루트 경로
self.root_path = Path(__file__).parent.parent
self.resources_path = self.root_path / "resources"
self.icons_path = self.resources_path / "icons"
self.sounds_path = self.resources_path / "sounds"
# 경로 생성
self.icons_path.mkdir(parents=True, exist_ok=True)
self.sounds_path.mkdir(parents=True, exist_ok=True)
def get_icon_path(self, icon_name: str) -> Optional[Path]:
"""
아이콘 파일 경로 반환
Args:
icon_name: 아이콘 파일명 (확장자 포함)
Returns:
Path: 아이콘 파일 경로
"""
icon_path = self.icons_path / icon_name
if icon_path.exists():
return icon_path
return None
def get_icon(self, icon_name: str, size: int = 64) -> Optional[QIcon]:
"""
QIcon 객체 반환
Args:
icon_name: 아이콘 파일명
size: 아이콘 크기 (픽셀)
Returns:
QIcon: 아이콘 객체, 없으면 None
"""
icon_path = self.get_icon_path(icon_name)
if icon_path:
pixmap = QPixmap(str(icon_path))
scaled_pixmap = pixmap.scaled(
QSize(size, size),
aspectRatioMode=1, # KeepAspectRatio
transformMode=1 # SmoothTransformation
)
return QIcon(scaled_pixmap)
return None
def get_sound_path(self, sound_name: str) -> Optional[Path]:
"""
사운드 파일 경로 반환
Args:
sound_name: 사운드 파일명 (확장자 포함)
Returns:
Path: 사운드 파일 경로
"""
sound_path = self.sounds_path / sound_name
if sound_path.exists():
return sound_path
return None
def play_sound(self, sound_name: str):
"""
사운드 재생
Args:
sound_name: 사운드 파일명
"""
sound_path = self.get_sound_path(sound_name)
if sound_path:
try:
# Windows 기본 사운드 재생
import winsound
winsound.PlaySound(
str(sound_path),
winsound.SND_FILENAME | winsound.SND_ASYNC
)
except Exception as e:
print(f"사운드 재생 실패: {e}")
def has_resources(self) -> bool:
"""리소스가 존재하는지 확인"""
icon_count = len(list(self.icons_path.glob("*.png")))
sound_count = len(list(self.sounds_path.glob("*.wav")))
return icon_count > 0 or sound_count > 0
def list_resources(self):
"""설치된 리소스 목록 출력"""
print("=== 설치된 리소스 ===\n")
print("아이콘:")
icons = list(self.icons_path.glob("*.png")) + list(self.icons_path.glob("*.ico"))
if icons:
for icon in icons:
print(f" - {icon.name}")
else:
print(" (없음)")
print("\n사운드:")
sounds = list(self.sounds_path.glob("*.wav")) + list(self.sounds_path.glob("*.mp3"))
if sounds:
for sound in sounds:
print(f" - {sound.name}")
else:
print(" (없음)")
if not icons and not sounds:
print("\n리소스가 없습니다!")
print("resources/resource_links.md 파일을 참고하여 리소스를 다운로드하세요.")
# 기본 이모지 아이콘 생성 (리소스 없을 때 대체용)
def create_emoji_icon(emoji: str, size: int = 64) -> QIcon:
"""
이모지를 아이콘으로 변환
Args:
emoji: 이모지 문자
size: 아이콘 크기
Returns:
QIcon: 아이콘 객체
"""
from PyQt5.QtGui import QPixmap, QPainter, QFont
from PyQt5.QtCore import Qt
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
font = QFont("Segoe UI Emoji", int(size * 0.7))
painter.setFont(font)
painter.drawText(pixmap.rect(), Qt.AlignCenter, emoji)
painter.end()
return QIcon(pixmap)
# 테스트 코드
if __name__ == "__main__":
manager = ResourceManager()
manager.list_resources()
print("\n=== 경로 정보 ===")
print(f"아이콘 폴더: {manager.icons_path}")
print(f"사운드 폴더: {manager.sounds_path}")
+190
View File
@@ -0,0 +1,190 @@
"""
시스템 트레이 아이콘
"""
from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QFont, QColor
from PyQt5.QtCore import Qt, QSize
from core.i18n import tr
class SystemTrayIcon(QSystemTrayIcon):
"""시스템 트레이 아이콘 클래스"""
def __init__(self, parent=None):
# 기본 아이콘 생성
icon = self.create_icon("⏰")
super().__init__(icon, parent)
self.parent_window = parent
self.setup_menu()
# 클릭 이벤트
self.activated.connect(self.on_tray_activated)
def create_icon(self, text: str, color: QColor = None) -> QIcon:
"""
텍스트로 아이콘 생성
Args:
text: 아이콘에 표시할 텍스트 (이모지 또는 시간)
color: 배경색
Returns:
QIcon: 생성된 아이콘
"""
pixmap = QPixmap(64, 64)
if color:
pixmap.fill(color)
else:
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
# 텍스트 그리기
if len(text) <= 2:
# 이모지
font = QFont("Segoe UI Emoji", 40)
else:
# 시간 표시
font = QFont("Consolas", 12, QFont.Bold)
painter.fillRect(pixmap.rect(), QColor(255, 255, 255))
painter.setFont(font)
painter.setPen(QColor(0, 0, 0))
painter.drawText(pixmap.rect(), Qt.AlignCenter, text)
painter.end()
return QIcon(pixmap)
def setup_menu(self):
"""트레이 메뉴 설정"""
menu = QMenu()
show_action = QAction(tr('tray.open'), self)
show_action.triggered.connect(self.show_window)
menu.addAction(show_action)
mini_action = QAction(tr('tray.mini_widget'), self)
mini_action.triggered.connect(self._open_mini_widget)
menu.addAction(mini_action)
menu.addSeparator()
lunch_action = QAction(tr('tray.toggle_lunch'), self)
lunch_action.triggered.connect(self._toggle_lunch)
menu.addAction(lunch_action)
break_out_action = QAction(tr('btn.break_out'), self)
break_out_action.triggered.connect(self._break_out)
menu.addAction(break_out_action)
break_in_action = QAction(tr('btn.break_in'), self)
break_in_action.triggered.connect(self._break_in)
menu.addAction(break_in_action)
menu.addSeparator()
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
clock_out_action.triggered.connect(self.quick_clock_out)
menu.addAction(clock_out_action)
menu.addSeparator()
stats_action = QAction("📊 " + tr('menu.stats'), self)
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
menu.addAction(stats_action)
cal_action = QAction("📅 " + tr('menu.calendar'), self)
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)
menu.addSeparator()
quit_action = QAction(tr('tray.quit'), self)
quit_action.triggered.connect(self.quit_app)
menu.addAction(quit_action)
self.setContextMenu(menu)
def _call_parent(self, method_name: str):
if self.parent_window and hasattr(self.parent_window, method_name):
getattr(self.parent_window, method_name)()
def _toggle_lunch(self):
if self.parent_window and hasattr(self.parent_window, 'lunch_button'):
self.parent_window.lunch_button.click()
def _break_out(self):
if self.parent_window and hasattr(self.parent_window, 'break_out'):
self.parent_window.break_out()
def _break_in(self):
if self.parent_window and hasattr(self.parent_window, 'break_in'):
self.parent_window.break_in()
def _open_mini_widget(self):
self._call_parent('show_mini_widget')
def on_tray_activated(self, reason):
"""트레이 아이콘 클릭 시"""
if reason == QSystemTrayIcon.DoubleClick:
self.show_window()
def show_window(self):
"""메인 윈도우 표시"""
if self.parent_window:
self.parent_window.show()
self.parent_window.activateWindow()
def quick_clock_out(self):
"""빠른 퇴근"""
if self.parent_window and hasattr(self.parent_window, 'clock_out'):
self.parent_window.clock_out()
def quit_app(self):
"""앱 종료"""
from PyQt5.QtWidgets import QApplication
QApplication.quit()
def update_time_display(self, remaining_str: str):
"""
남은 시간 표시 업데이트
Args:
remaining_str: "HH:MM" 형식의 시간
"""
# 간단한 시간 표시로 아이콘 업데이트
if remaining_str.startswith('+'):
icon = self.create_icon("🔥")
self.setToolTip(tr('tray.tooltip_overtime', time=remaining_str))
elif remaining_str.startswith('-'):
icon = self.create_icon("⏰")
self.setToolTip(tr('tray.tooltip_remaining', time='--'))
else:
parts = remaining_str.split(':')
display_time = f"{parts[0]}:{parts[1]}" if len(parts) >= 2 else remaining_str
icon = self.create_icon("⏰")
self.setToolTip(tr('tray.tooltip_remaining', time=display_time))
self.setIcon(icon)
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication, QMainWindow
import sys
app = QApplication(sys.argv)
window = QMainWindow()
window.setWindowTitle("Main Window")
tray = SystemTrayIcon(window)
tray.show()
window.show()
sys.exit(app.exec_())
+33
View File
@@ -0,0 +1,33 @@
"""
시간 표시 헬퍼.
여러 모듈에서 `f"{h}시간 {m}분"` 패턴을 중복하던 것을 단일 함수로.
"""
from __future__ import annotations
def format_hours_minutes(total_minutes: int, *, omit_zero_minutes: bool = False) -> str:
"""분을 "X시간 Y분" 형식으로.
Args:
total_minutes: 총 분 (음수 가능 — 절댓값 표시)
omit_zero_minutes: True면 "X시간"처럼 0분 생략
Examples:
>>> format_hours_minutes(90)
'1시간 30분'
>>> format_hours_minutes(60, omit_zero_minutes=True)
'1시간'
>>> format_hours_minutes(45)
'0시간 45분'
>>> format_hours_minutes(45, omit_zero_minutes=True)
'45분'
"""
minutes = abs(int(total_minutes))
h, m = divmod(minutes, 60)
if omit_zero_minutes:
if h == 0:
return f"{m}분"
if m == 0:
return f"{h}시간"
return f"{h}시간 {m}분"
+207
View File
@@ -0,0 +1,207 @@
"""
업데이트 클라이언트 — 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)
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]:
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None.
네트워크 오류 시 None 반환 (앱 시작을 막지 않음).
"""
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.URLError, json.JSONDecodeError, TimeoutError):
return None
tag = data.get('tag_name', '')
if not tag or not is_newer(tag, current_version):
return None
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
return ReleaseInfo(
version=tag,
asset_url=asset_url,
notes=data.get('body', ''),
published_at=data.get('published_at', ''),
)
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:
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 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 찾기. 메인 .exe와 같은 폴더에 있어야 함."""
candidate = _exe_dir() / 'updater.exe'
if candidate.exists():
return candidate
return None