From d1f6791b96848cdbcd9b410963d33e65e01d689d Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Thu, 30 Apr 2026 13:53:31 +0900 Subject: [PATCH] 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) --- CHANGELOG.md | 17 +++++++++++++++++ core/version.py | 2 +- main.py | 32 ++++++++++++++++++++++++++++++++ main.spec | 13 ++++++++----- release.ps1 | 11 ++++++----- utils/updater_client.py | 23 ++++++++++++++++++++++- 6 files changed, 86 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1907a4..87311af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.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 diff --git a/core/version.py b/core/version.py index b96780e..2901c8c 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.2.0' +__version__ = '2.2.1' diff --git a/main.py b/main.py index cc57af6..9898b4f 100644 --- a/main.py +++ b/main.py @@ -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) # 애플리케이션 정보 diff --git a/main.spec b/main.spec index 4f11f95..299adb2 100644 --- a/main.spec +++ b/main.spec @@ -1,15 +1,18 @@ # -*- 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 +_extra_datas = [] +if os.path.exists('dist/updater.exe'): + _extra_datas.append(('dist/updater.exe', '.')) 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', diff --git a/release.ps1 b/release.ps1 index 1e5b2e6..d5a56f3 100644 --- a/release.ps1 +++ b/release.ps1 @@ -114,16 +114,17 @@ 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" } +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" } + $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)" diff --git a/utils/updater_client.py b/utils/updater_client.py index baaf8bf..e044ff6 100644 --- a/utils/updater_client.py +++ b/utils/updater_client.py @@ -200,8 +200,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