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>
This commit is contained in:
KINDNICK 2026-04-30 13:53:31 +09:00
parent 63c4c955c8
commit d1f6791b96
6 changed files with 86 additions and 12 deletions

View File

@ -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

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 .
"""
__version__ = '2.2.0'
__version__ = '2.2.1'

32
main.py
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)
# 애플리케이션 정보

View File

@ -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',

View File

@ -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)"

View File

@ -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