Compare commits

...
5 Commits
Author SHA1 Message Date
KINDNICKandClaude Opus 4.7 14d88656fe v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect):
- auto_overtime: when OFF, prompt user on clock-out before banking
- overtime_unit: 15/30/60-min truncation choice now actually applied
- notification_before_minutes: 30-min hardcode -> user-configurable 1~120

Removed dead keys (no readers in business logic):
- auto_detect_boot, notification_enabled, annual_leave_used

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:29:44 +09:00
KINDNICKandClaude Opus 4.7 b91b229731 v2.2.3: stage updater.exe outside dist/ to survive main.spec --clean
main.spec's --clean flag wipes dist/ at the start of main.exe build,
which deleted dist/updater.exe before it could be embedded as data
(v2.2.2 main.exe shipped without embedded updater).

Fix: release.ps1 copies dist/updater.exe to build/staging/ after
the updater build, and main.spec datas references that staged path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:15:51 +09:00
KINDNICKandClaude Opus 4.7 7661afd33a v2.2.2: dummy release for auto-update test
- Show app version in window title for visual confirmation after update
- No functional changes — purely a test bump to verify the
  single-file (main.exe alone) auto-update flow end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:12:25 +09:00
KINDNICKandClaude Opus 4.7 d1f6791b96 Embed updater.exe into main.exe (single-file deployment)
- main.spec: include dist/updater.exe in datas (when present)
- main.py: extract embedded updater.exe next to main.exe on launch
- updater_client.py: _MEIPASS fallback to TEMP if extraction fails
- release.ps1: build updater FIRST so main.spec can embed it

Now users can deploy main.exe alone; auto-update still works because
main.exe self-extracts updater.exe on first launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:53:31 +09:00
KINDNICKandClaude Opus 4.7 63c4c955c8 Fix Korean encoding in CHANGELOG extraction (release.ps1)
PowerShell 5.1 Get-Content defaults to system ANSI (cp949 in KR locale),
which corrupted UTF-8 Korean text in release notes. Use .NET API
[System.IO.File]::ReadAllText with explicit UTF-8 encoding instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:48:20 +09:00
13 changed files with 298 additions and 55 deletions
+55
View File
@@ -4,6 +4,61 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.2.4] — 2026-04-30
### Added
- **`auto_overtime` 옵션 실제 동작** — 기존엔 UI만 있고 동작 안 했음
- **OFF**: 퇴근 시 적립 가능한 분이 있으면 "적립할까요?" 다이얼로그 표시 (Y/N)
- **ON** (기본): 자동 적립 (이전과 동일)
- **`overtime_unit` 실제 동작** — 30/15/60분 단위 적립 선택 반영
- 기존엔 무조건 30분 절삭. 이제 설정값 사용 (`time_calculator.calculate_overtime` `unit_minutes` 파라미터)
- **퇴근 알림 시점 사용자 설정** (`notification_before_minutes`)
- 기존 30분 하드코드 → 1~120분 SpinBox로 사용자 지정
- 설정 → 알림 그룹에 "퇴근 알림 시점" SpinBox
### Removed (죽은 옵션 정리)
- `auto_detect_boot` — 부팅 감지 비활성화 모드. 어디서도 안 쓰임
- `notification_enabled` — 마스터 스위치. 4개 개별 NOTIF_* 키로 충분
- `annual_leave_used` — `leave_balance`로 대체된 레거시 카운터
### Fixed
- 죽은 옵션이 사용자에게 "동작하는 것처럼" 보이던 신뢰성 문제 일괄 해소
## [2.2.3] — 2026-04-30
### Fixed
- **`updater.exe` 임베딩 빌드 안정화**
- v2.2.2에서 `main.spec --clean`이 빌드 시작 시 `dist/updater.exe`를 지우면서
임베딩이 누락되던 문제 수정
- `release.ps1`이 `dist/updater.exe`를 `build/staging/`로 복사 후 main.spec이 그걸 참조
- main.exe 사이즈가 다시 ~78MB (updater 내장) 로 정상화
## [2.2.2] — 2026-04-30
### Added
- 윈도우 제목에 현재 버전 표시 — 자동 업데이트 후 신/구 버전 시각적 구분
- 예: `⏰ 퇴근시간 계산기 v2.2.2`
### Test
- 자동 업데이트 더미 릴리스 — 단독 main.exe 배포 + 자가 업데이트 흐름 검증용
## [2.2.1] — 2026-04-30
### Added
- **`updater.exe` 내장 (Single-file 배포)**
- `main.exe` 안에 `updater.exe`를 PyInstaller `datas`로 임베드
- 시작 시 `main.exe`가 같은 폴더에 `updater.exe`를 자동 추출 (없거나 사이즈 다르면 갱신)
- 사용자는 이제 **`main.exe` 하나만 받아도** 자동 업데이트 동작
- 업데이트로 `main.exe`가 교체되면 새 main 실행 시 새 updater.exe도 자동 갱신
- `utils/updater_client.py`: `_MEIPASS` fallback — 권한 부족 등으로 추출 실패 시 TEMP에서 동작
### Changed
- `release.ps1` 빌드 순서: updater 먼저 → main 나중 (datas 의존성)
- `main.spec`: `dist/updater.exe` 존재 시 자동 임베드 (조건부 datas)
### Fixed
- 한국어 릴리스 노트 인코딩 (`Get-Content` ANSI → `[System.IO.File]::ReadAllText` UTF-8)
## [2.2.0] — 2026-04-30
### Added
+3 -5
View File
@@ -520,21 +520,19 @@ class Database:
'work_minutes': '480',
'lunch_duration_minutes': '60',
'dinner_duration_minutes': '60',
'auto_detect_boot': 'true',
'auto_lunch': 'false',
'auto_overtime': 'true',
'theme': 'light',
'notification_enabled': 'true',
'notification_before_minutes': '30',
'notification_clock_out': 'true',
'notification_lunch': 'true',
'notification_overtime': 'true',
'notification_health': 'true',
'annual_leave_total': '15',
'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화)
'annual_leave_used': '0',
'annual_leave_days': '15', # annual_leave_total과 자동 동기화
'workday_boundary_hour': '6',
'overtime_unit': '30',
'time_format': '24'
'time_format': '24',
}
conn = self.get_connection()
+11 -2
View File
@@ -65,8 +65,17 @@ class Notifier(QObject):
return
time_diff = clock_out_time - current_time
# 30분 이내, 아직 알림 안 했으면
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
# 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
threshold_min = 30
if self.db is not None:
try:
threshold_min = int(self.db.get_setting('notification_before_minutes', '30') or '30')
threshold_min = max(1, min(120, threshold_min))
except (ValueError, TypeError):
threshold_min = 30
threshold_sec = threshold_min * 60
if 0 < time_diff.total_seconds() <= threshold_sec and not self.notified_30min:
minutes_left = int(time_diff.total_seconds() / 60)
self.notification_signal.emit(
tr('notif.clock_out_soon.title'),
-3
View File
@@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
# 자동화
AUTO_DETECT_BOOT = 'auto_detect_boot'
AUTO_LUNCH = 'auto_lunch'
AUTO_OVERTIME = 'auto_overtime'
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
# 알림
NOTIFICATION_ENABLED = 'notification_enabled'
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
NOTIF_CLOCK_OUT = 'notification_clock_out'
NOTIF_LUNCH = 'notification_lunch'
@@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health'
# 연차
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
ANNUAL_LEAVE_USED = 'annual_leave_used'
LEAVE_BALANCE = 'leave_balance'
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
+7 -5
View File
@@ -135,17 +135,19 @@ class TimeCalculator:
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
include_lunch: bool = False, include_dinner: bool = False,
break_minutes: int = 0) -> Tuple[int, int]:
break_minutes: int = 0,
unit_minutes: int = 30) -> Tuple[int, int]:
"""
연장근무 시간 계산 (실제 시간, 30분 단위 적립)
연장근무 시간 계산 (실제 시간, unit_minutes 단위 적립)
Args:
clock_in: 출근 시간
clock_out: 퇴근 시간
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT.
Returns:
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분)
Tuple[int, int]: (실제 연장근무 분, unit_minutes 단위 적립 분)
"""
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
@@ -155,8 +157,8 @@ class TimeCalculator:
overtime_duration = clock_out - expected_clock_out
overtime_minutes = int(overtime_duration.total_seconds() / 60)
# 30분 단위로 절삭
overtime_earned = (overtime_minutes // 30) * 30
unit = unit_minutes if unit_minutes > 0 else 30
overtime_earned = (overtime_minutes // unit) * unit
return overtime_minutes, overtime_earned
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
__version__ = '2.2.0'
__version__ = '2.2.4'
+32
View File
@@ -18,6 +18,35 @@ 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:
@@ -29,6 +58,9 @@ def check_requirements():
def main():
"""메인 함수"""
# PyInstaller frozen exe에 내장된 updater.exe를 옆 폴더에 자동 추출
_ensure_updater_extracted()
app = QApplication(sys.argv)
# 애플리케이션 정보
+14 -5
View File
@@ -1,15 +1,24 @@
# -*- mode: python ; coding: utf-8 -*-
# Build order: updater.spec FIRST, then main.spec.
# main.exe embeds dist/updater.exe so users can deploy main.exe alone;
# the embedded updater is auto-extracted next to main.exe on first launch.
import os
# release.ps1 stages updater.exe at build/staging/updater.exe
# (avoids PyInstaller --clean wiping dist/updater.exe before main build).
_extra_datas = []
_staged = 'build/staging/updater.exe'
_fallback = 'dist/updater.exe'
if os.path.exists(_staged):
_extra_datas.append((_staged, '.'))
elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.'))
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('3d-alarm.png', '.'),
# updater.exe는 main과 같은 폴더에 배포되어야 함 (배포 시 ZIP에 함께 포함)
# PyInstaller datas로 안고 가지 않고 별도 파일로 유지 — 자가 교체 가능하도록
],
datas=[('3d-alarm.png', '.')] + _extra_datas,
hiddenimports=[
'holidays', 'holidays.countries.south_korea',
'win32evtlog', 'win32evtlogutil',
+21 -6
View File
@@ -114,16 +114,29 @@ if (-not $SkipTests) {
# ====== 3. Build ======
Step "3/7 PyInstaller build"
Info "Building main.exe..."
$rc = Invoke-Native python -m PyInstaller --clean main.spec
if ($rc -ne 0) { Fail "main.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/main.exe')) { Fail "dist/main.exe missing" }
# Build updater FIRST so main.spec can embed it as data
Info "Building updater.exe..."
$rc = Invoke-Native python -m PyInstaller --clean updater.spec
if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/updater.exe')) { Fail "dist/updater.exe missing" }
# Stage updater.exe outside dist/ so main.spec --clean does not wipe it
$stagingDir = 'build/staging'
if (-not (Test-Path $stagingDir)) { New-Item -ItemType Directory -Path $stagingDir | Out-Null }
Copy-Item 'dist/updater.exe' "$stagingDir/updater.exe" -Force
Info "Staged updater.exe -> $stagingDir/updater.exe"
Info "Building main.exe (with embedded updater)..."
$rc = Invoke-Native python -m PyInstaller --clean main.spec
if ($rc -ne 0) { Fail "main.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/main.exe')) { Fail "dist/main.exe missing" }
# Restore updater.exe to dist/ for separate Release asset upload
if (-not (Test-Path 'dist/updater.exe')) {
Copy-Item "$stagingDir/updater.exe" 'dist/updater.exe' -Force
Info "Restored updater.exe to dist/"
}
$mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB)
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
@@ -174,9 +187,11 @@ if ($DryRun) {
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# Extract notes from CHANGELOG.md
# IMPORTANT: PowerShell 5.1 Get-Content defaults to ANSI; use .NET API for UTF-8 (Korean).
$notes = "Release $Version"
if (Test-Path CHANGELOG.md) {
$changelog = Get-Content CHANGELOG.md -Raw
$changelogPath = (Resolve-Path CHANGELOG.md).Path
$changelog = [System.IO.File]::ReadAllText($changelogPath, [System.Text.Encoding]::UTF8)
$pattern = "## \[$([regex]::Escape($VersionRaw))\][\s\S]*?(?=`n## \[|\z)"
$regexMatch = [regex]::Match($changelog, $pattern)
if ($regexMatch.Success) { $notes = $regexMatch.Value.Trim() }
+29 -1
View File
@@ -8,7 +8,10 @@ import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.updater_client import _parse_version, is_newer, RELEASES_API
from utils.updater_client import (
_parse_version, is_newer, RELEASES_API,
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
)
class TestVersionParsing:
@@ -59,6 +62,31 @@ class TestApiUrl:
importlib.reload(updater_client)
class TestCheckForUpdate:
"""check_for_update returns (info, reason) tuple."""
def test_returns_tuple(self, monkeypatch):
# 네트워크 호출이 실패하도록 잘못된 URL
monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'http://127.0.0.1:1/nope')
import importlib
from utils import updater_client
importlib.reload(updater_client)
try:
result = updater_client.check_for_update('1.0.0', timeout=1)
assert isinstance(result, tuple) and len(result) == 2
info, reason = result
assert info is None
assert reason == updater_client.NETWORK_ERROR
finally:
monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False)
importlib.reload(updater_client)
def test_constants_distinct(self):
# 4개 상수가 모두 서로 다른 값
values = {UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET}
assert len(values) == 4
class TestUpdaterScript:
"""updater.py 자체 로직."""
+52 -16
View File
@@ -176,7 +176,8 @@ class MainWindow(QMainWindow):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("⏰ " + tr('window.main_title'))
from core.version import __version__
self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
self.setGeometry(100, 100, 500, 620)
self.setMinimumSize(480, 520)
@@ -1080,18 +1081,21 @@ class MainWindow(QMainWindow):
# 오늘의 외출 시간 가져오기
break_minutes = self.db.get_total_break_minutes_today()
# 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60.
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
if is_non_working_day:
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
work_minutes = int(total_hours * 60)
if self.lunch_break_enabled:
work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외
work_minutes -= self.time_calc.lunch_duration_minutes
if self.dinner_break_enabled:
work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외
work_minutes -= break_minutes # 외출시간 제외
# 음수 방지
work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes
work_minutes = max(0, work_minutes)
# 30분 단위로 절삭
overtime_earned = (work_minutes // 30) * 30
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
overtime_actual = work_minutes
else:
# 평일: 정상 연장근무 계산 (외출 시간 포함)
@@ -1099,9 +1103,27 @@ class MainWindow(QMainWindow):
self.clock_in_time, now,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
if not auto_overtime and overtime_earned > 0:
from utils.time_format import format_hours_minutes
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
ask = QMessageBox.question(
self,
"연장근무 적립 확인",
f"연장근무 {actual_str} 발생, {time_str} 적립 대상입니다.\n\n"
f"적립하시겠습니까?\n"
f"(아니오 선택 시 이번 퇴근분은 적립되지 않습니다)",
QMessageBox.Yes | QMessageBox.No,
)
if ask != QMessageBox.Yes:
overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지)
# DB 업데이트
today = datetime.now().date().isoformat()
clock_out_str = now.strftime("%H:%M:%S")
@@ -1600,16 +1622,30 @@ class MainWindow(QMainWindow):
def check_for_updates(self, silent: bool = False):
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
from core.version import __version__
from utils.updater_client import check_for_update, download_update, apply_update
from utils.updater_client import (
check_for_update, download_update, apply_update,
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
)
info = check_for_update(__version__)
info, reason = check_for_update(__version__)
if info is None:
if not silent:
QMessageBox.information(
self,
"업데이트 확인",
f"현재 최신 버전입니다 (v{__version__})."
)
if silent:
return
# 사용자가 명시적으로 트리거한 경우만 메시지 표시
messages = {
UP_TO_DATE: ("업데이트 확인", f"현재 최신 버전입니다 (v{__version__})."),
NETWORK_ERROR: ("연결 실패",
"업데이트 서버에 연결할 수 없습니다.\n"
"네트워크 상태를 확인해 주세요."),
NO_RELEASE: ("릴리스 없음",
"업데이트 저장소에서 릴리스를 찾을 수 없습니다.\n"
"(저장소 비공개 또는 첫 릴리스 전)"),
NO_ASSET: ("자산 누락",
"새 버전은 있지만 다운로드 가능한 main.exe 자산이 없습니다.\n"
"관리자에게 문의하세요."),
}
title, body = messages.get(reason, ("업데이트 확인", "알 수 없는 응답입니다."))
QMessageBox.information(self, title, body)
return
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
+23
View File
@@ -18,6 +18,7 @@ from core.settings_keys import (
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIFICATION_BEFORE_MINUTES,
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
@@ -275,6 +276,22 @@ class SettingsView(QDialog):
check_row2.addWidget(self.health_notification_check)
layout.addLayout(check_row2)
# 퇴근 N분 전 알림 시점 설정
before_row = QHBoxLayout()
before_label = QLabel("퇴근 알림 시점:")
before_label.setFixedWidth(110)
self.notif_before_spin = QSpinBox()
self.notif_before_spin.setRange(1, 120)
self.notif_before_spin.setSingleStep(5)
self.notif_before_spin.setValue(30)
self.notif_before_spin.setSuffix(" 분 전")
self.notif_before_spin.setFixedWidth(110)
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
before_row.addWidget(before_label)
before_row.addWidget(self.notif_before_spin)
before_row.addStretch()
layout.addLayout(before_row)
# 시간 형식 + 테마 한 줄에
format_row = QHBoxLayout()
time_format_label = QLabel("시간 형식:")
@@ -796,6 +813,11 @@ class SettingsView(QDialog):
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
if hasattr(self, 'notif_before_spin'):
try:
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
except (ValueError, TypeError):
self.notif_before_spin.setValue(30)
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
time_format = settings.get(TIME_FORMAT, '24')
@@ -879,6 +901,7 @@ class SettingsView(QDialog):
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
NOTIF_HEALTH: self.health_notification_check.isChecked(),
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
TIME_FORMAT: self.time_format_combo.currentData(),
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
+50 -11
View File
@@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool:
return _parse_version(remote) > _parse_version(local)
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]:
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None.
# 업데이트 체크 결과 상수 (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 자산 없음
네트워크 오류 시 None 반환 (앱 시작을 막지 않음).
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.URLError, json.JSONDecodeError, TimeoutError):
return None
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 or not is_newer(tag, current_version):
return None
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
return None, NO_ASSET
return ReleaseInfo(
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,
@@ -200,8 +218,29 @@ def _current_exe() -> Optional[Path]:
def _find_updater() -> Optional[Path]:
"""updater.exe 찾기. 메인 .exe와 같은 폴더에 있어야 함."""
"""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