Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b91b229731 | ||
|
|
7661afd33a | ||
|
|
d1f6791b96 | ||
|
|
63c4c955c8 |
@@ -4,6 +4,41 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [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
|
## [2.2.0] — 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.2.0'
|
__version__ = '2.2.3'
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ from ui.main_window import MainWindow
|
|||||||
from core.database import Database
|
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():
|
def check_requirements():
|
||||||
"""필수 요구사항 확인"""
|
"""필수 요구사항 확인"""
|
||||||
try:
|
try:
|
||||||
@@ -29,6 +58,9 @@ def check_requirements():
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""메인 함수"""
|
"""메인 함수"""
|
||||||
|
# PyInstaller frozen exe에 내장된 updater.exe를 옆 폴더에 자동 추출
|
||||||
|
_ensure_updater_extracted()
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
# 애플리케이션 정보
|
# 애플리케이션 정보
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- 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(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[
|
datas=[('3d-alarm.png', '.')] + _extra_datas,
|
||||||
('3d-alarm.png', '.'),
|
|
||||||
# updater.exe는 main과 같은 폴더에 배포되어야 함 (배포 시 ZIP에 함께 포함)
|
|
||||||
# PyInstaller datas로 안고 가지 않고 별도 파일로 유지 — 자가 교체 가능하도록
|
|
||||||
],
|
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'holidays', 'holidays.countries.south_korea',
|
'holidays', 'holidays.countries.south_korea',
|
||||||
'win32evtlog', 'win32evtlogutil',
|
'win32evtlog', 'win32evtlogutil',
|
||||||
|
|||||||
+21
-6
@@ -114,16 +114,29 @@ if (-not $SkipTests) {
|
|||||||
# ====== 3. Build ======
|
# ====== 3. Build ======
|
||||||
Step "3/7 PyInstaller build"
|
Step "3/7 PyInstaller build"
|
||||||
|
|
||||||
Info "Building main.exe..."
|
# Build updater FIRST so main.spec can embed it as data
|
||||||
$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" }
|
|
||||||
|
|
||||||
Info "Building updater.exe..."
|
Info "Building updater.exe..."
|
||||||
$rc = Invoke-Native python -m PyInstaller --clean updater.spec
|
$rc = Invoke-Native python -m PyInstaller --clean updater.spec
|
||||||
if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" }
|
if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" }
|
||||||
if (-not (Test-Path 'dist/updater.exe')) { Fail "dist/updater.exe missing" }
|
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)
|
$mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB)
|
||||||
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
|
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
|
||||||
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
|
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
|
||||||
@@ -174,9 +187,11 @@ if ($DryRun) {
|
|||||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
|
||||||
# Extract notes from CHANGELOG.md
|
# Extract notes from CHANGELOG.md
|
||||||
|
# IMPORTANT: PowerShell 5.1 Get-Content defaults to ANSI; use .NET API for UTF-8 (Korean).
|
||||||
$notes = "Release $Version"
|
$notes = "Release $Version"
|
||||||
if (Test-Path CHANGELOG.md) {
|
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)"
|
$pattern = "## \[$([regex]::Escape($VersionRaw))\][\s\S]*?(?=`n## \[|\z)"
|
||||||
$regexMatch = [regex]::Match($changelog, $pattern)
|
$regexMatch = [regex]::Match($changelog, $pattern)
|
||||||
if ($regexMatch.Success) { $notes = $regexMatch.Value.Trim() }
|
if ($regexMatch.Success) { $notes = $regexMatch.Value.Trim() }
|
||||||
|
|||||||
+2
-1
@@ -176,7 +176,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""UI 초기화"""
|
"""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.setGeometry(100, 100, 500, 620)
|
||||||
self.setMinimumSize(480, 520)
|
self.setMinimumSize(480, 520)
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -200,8 +200,29 @@ def _current_exe() -> Optional[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_updater() -> 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'
|
candidate = _exe_dir() / 'updater.exe'
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
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
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user