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>
188 lines
5.8 KiB
Python
188 lines
5.8 KiB
Python
"""
|
|
Clock-out Time Calculator
|
|
퇴근시간 계산 프로그램 - 메인 실행 파일
|
|
"""
|
|
import sys
|
|
import os
|
|
|
|
# PyQt5 임포트
|
|
from PyQt5.QtWidgets import QApplication, QMessageBox
|
|
from PyQt5.QtGui import QFont
|
|
from PyQt5.QtCore import QLockFile, QDir
|
|
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
|
|
|
|
# 프로젝트 루트를 경로에 추가
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from ui.main_window import MainWindow
|
|
from core.database import Database
|
|
|
|
|
|
def _ensure_updater_extracted():
|
|
"""main.exe에 내장된 updater.exe를 같은 폴더에 추출.
|
|
|
|
main.exe만 단독 배포해도 자동 업데이트가 동작하도록, 시작 시 한 번
|
|
내장본을 같은 폴더로 복사. 이미 같은 사이즈로 있으면 건너뜀.
|
|
개발 환경(.py 실행)에서는 별도 빌드된 updater.exe를 사용해야 하므로 no-op.
|
|
"""
|
|
if not getattr(sys, 'frozen', False):
|
|
return
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
main_dir = Path(sys.executable).parent
|
|
target = main_dir / 'updater.exe'
|
|
bundled = Path(getattr(sys, '_MEIPASS', '')) / 'updater.exe'
|
|
|
|
if not bundled.exists():
|
|
return # 빌드 시 포함 안 됨
|
|
|
|
needs_extract = (not target.exists()
|
|
or target.stat().st_size != bundled.stat().st_size)
|
|
if needs_extract:
|
|
try:
|
|
shutil.copy2(str(bundled), str(target))
|
|
except OSError:
|
|
pass # 권한 부족 등은 silent — 자동 업데이트만 안 됨
|
|
|
|
|
|
def check_requirements():
|
|
"""필수 요구사항 확인"""
|
|
try:
|
|
import win32evtlog
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def main():
|
|
"""메인 함수"""
|
|
# PyInstaller frozen exe에 내장된 updater.exe를 옆 폴더에 자동 추출
|
|
_ensure_updater_extracted()
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
# 애플리케이션 정보
|
|
app.setApplicationName("Clock-out Time Calculator")
|
|
app.setOrganizationName("DevUtil")
|
|
app.setApplicationVersion("1.0.0")
|
|
|
|
# 중복 실행 방지 - 로컬 서버로 체크
|
|
server_name = "ClockOutCalculatorInstance"
|
|
|
|
# 이미 실행 중인지 확인
|
|
socket = QLocalSocket()
|
|
socket.connectToServer(server_name)
|
|
|
|
if socket.waitForConnected(500):
|
|
# 이미 실행 중 - 기존 인스턴스에 "show" 신호 전송
|
|
socket.write(b"show")
|
|
socket.flush()
|
|
socket.waitForBytesWritten(1000)
|
|
socket.disconnectFromServer()
|
|
return 0
|
|
|
|
# 새로운 인스턴스 - 서버 시작
|
|
server = QLocalServer()
|
|
# 기존 서버가 남아있을 수 있으므로 제거
|
|
QLocalServer.removeServer(server_name)
|
|
|
|
if not server.listen(server_name):
|
|
QMessageBox.warning(
|
|
None,
|
|
"서버 오류",
|
|
"프로그램 인스턴스 서버를 시작할 수 없습니다."
|
|
)
|
|
return 1
|
|
|
|
# 폰트 설정
|
|
app.setFont(QFont("Segoe UI", 9))
|
|
|
|
# 필수 패키지 확인
|
|
if not check_requirements():
|
|
QMessageBox.critical(
|
|
None,
|
|
"요구사항 오류",
|
|
"필수 패키지가 설치되지 않았습니다.\n\n"
|
|
"다음 명령어를 실행하세요:\n"
|
|
"pip install -r requirements.txt"
|
|
)
|
|
return 1
|
|
|
|
# 데이터베이스 초기화 — db_path_override 설정 시 그 경로 사용 (클라우드 폴더 등)
|
|
# 부트스트랩: 기본 DB로 한 번 열어 override 키 확인
|
|
from core.settings_keys import DB_PATH_OVERRIDE
|
|
bootstrap = Database()
|
|
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
|
|
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
|
|
db = Database(override_path)
|
|
else:
|
|
db = bootstrap
|
|
|
|
# 1일 1회 자동 백업 (조용히 실패 — 백업 실패가 앱 실행을 막으면 안 됨)
|
|
try:
|
|
from utils.backup import backup_db_if_needed
|
|
backup_db_if_needed(db)
|
|
except Exception as e:
|
|
from utils.debug_log import dlog
|
|
dlog(f"backup failed: {e}")
|
|
|
|
# 전역 예외 후킹 (crash report)
|
|
try:
|
|
from utils.crash_handler import install_global_handler
|
|
from core.version import __version__
|
|
install_global_handler(db, app_version=__version__)
|
|
except Exception as e:
|
|
from utils.debug_log import dlog
|
|
dlog(f"crash handler install failed: {e}")
|
|
|
|
# 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시
|
|
try:
|
|
from ui.onboarding_view import maybe_show_onboarding
|
|
maybe_show_onboarding(db)
|
|
except Exception as e:
|
|
from utils.debug_log import dlog
|
|
dlog(f"onboarding skipped: {e}")
|
|
|
|
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
|
|
try:
|
|
window = MainWindow(db=db)
|
|
|
|
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
|
|
def on_new_connection():
|
|
client_socket = server.nextPendingConnection()
|
|
if client_socket:
|
|
client_socket.waitForReadyRead(1000)
|
|
data = client_socket.readAll().data()
|
|
if data == b"show":
|
|
# 창 표시
|
|
window.show()
|
|
window.raise_()
|
|
window.activateWindow()
|
|
client_socket.disconnectFromServer()
|
|
|
|
server.newConnection.connect(on_new_connection)
|
|
|
|
window.show()
|
|
|
|
result = app.exec_()
|
|
|
|
# 서버 종료
|
|
server.close()
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(
|
|
None,
|
|
"오류",
|
|
f"프로그램 실행 중 오류가 발생했습니다:\n\n{str(e)}"
|
|
)
|
|
server.close()
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|