핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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:
@@ -0,0 +1 @@
|
||||
# utils 모듈
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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_())
|
||||
@@ -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}분"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user