diff --git a/CHANGELOG.md b/CHANGELOG.md index 245c1c4..8d7aa64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.10.1] — 2026-05-01 + +### Fixed — 업데이트 시 cmd 창 깜빡임 제거 +- **`updater.spec`**: `console=True` → `console=False` (windowed 빌드). + 자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음. +- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환 + — windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch) + 에 타임스탬프 + 결과 기록. +- **`updater.py launch()`**: `subprocess.Popen` 에 `CREATE_NO_WINDOW` 플래그 추가 + (DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음. +- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가. + main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단. + ## [2.10.0] — 2026-05-01 ### Added — 정부 공휴일 API 자동 동기화 diff --git a/core/version.py b/core/version.py index 006e7ec..3040562 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.10.0' +__version__ = '2.10.1' diff --git a/updater.py b/updater.py index 8333dd8..f79081c 100644 --- a/updater.py +++ b/updater.py @@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약 3. new_exe → target_exe 이동 4. target_exe 재실행 + 업데이터 자가 종료 실패 시 .bak 복원 + +빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임. +모든 진단 출력은 ~/.clockout_logs/updater.log 로 append. """ from __future__ import annotations import argparse @@ -21,9 +24,30 @@ import shutil import subprocess import sys import time +from datetime import datetime from pathlib import Path +# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ──────── +_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log' + + +def _log(msg: str) -> None: + """진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음.""" + line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n" + # stderr도 시도 (개발 환경 .py 직접 실행 시 보임) + try: + print(line, end='', file=sys.stderr) + except Exception: + pass + try: + _LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(_LOG_PATH, 'a', encoding='utf-8') as f: + f.write(line) + except OSError: + pass + + def is_pid_running(pid: int) -> bool: """Windows에서 PID 실행 중인지 확인.""" if sys.platform != 'win32': @@ -81,8 +105,7 @@ def replace_file(new_path: Path, target_path: Path, try: backup.unlink() except OSError as e: - print(f"[updater] old backup unlink failed (continuing): {e}", - file=sys.stderr) + _log(f"[updater] old backup unlink failed (continuing): {e}") # 2단계: target → backup 이동 (락 해제 대기 재시도) for attempt in range(max_retries): @@ -94,13 +117,12 @@ def replace_file(new_path: Path, target_path: Path, except OSError as e: last_err = e wait = 0.3 * (2 ** attempt) - print(f"[updater] target move attempt {attempt+1}/{max_retries} " - f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr) + _log(f"[updater] target move attempt {attempt+1}/{max_retries} " + f"failed ({e}); waiting {wait:.1f}s") time.sleep(wait) else: # 모든 재시도 실패 - print(f"[updater] target move failed after {max_retries} attempts: {last_err}", - file=sys.stderr) + _log(f"[updater] target move failed after {max_retries} attempts: {last_err}") return None # 3단계: new → target 이동 @@ -111,19 +133,18 @@ def replace_file(new_path: Path, target_path: Path, except OSError as e: last_err = e wait = 0.3 * (2 ** attempt) - print(f"[updater] new move attempt {attempt+1}/{max_retries} " - f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr) + _log(f"[updater] new move attempt {attempt+1}/{max_retries} " + f"failed ({e}); waiting {wait:.1f}s") time.sleep(wait) # new 이동 실패 → backup으로 롤백 시도 - print(f"[updater] new move failed after {max_retries} attempts: {last_err}", - file=sys.stderr) + _log(f"[updater] new move failed after {max_retries} attempts: {last_err}") if backup.exists() and not target_path.exists(): try: shutil.move(str(backup), str(target_path)) - print("[updater] rolled back from backup", file=sys.stderr) + _log("[updater] rolled back from backup") except OSError as e: - print(f"[updater] rollback also failed: {e}", file=sys.stderr) + _log(f"[updater] rollback also failed: {e}") return None @@ -131,13 +152,20 @@ def launch(exe_path: Path) -> bool: """새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기.""" try: if sys.platform == 'win32': + # CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008) + # — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해. DETACHED_PROCESS = 0x00000008 - subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True) + CREATE_NO_WINDOW = 0x08000000 + subprocess.Popen( + [str(exe_path)], + creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW, + close_fds=True, + ) else: subprocess.Popen([str(exe_path)], close_fds=True) return True except OSError as e: - print(f"[updater] launch failed: {e}", file=sys.stderr) + _log(f"[updater] launch failed: {e}") return False @@ -149,15 +177,17 @@ def main() -> int: parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함') args = parser.parse_args() + _log(f"[updater] start pid={args.pid} new={args.new} target={args.target}") + new_exe = Path(args.new).resolve() target_exe = Path(args.target).resolve() if not new_exe.exists(): - print(f"[updater] new exe not found: {new_exe}", file=sys.stderr) + _log(f"[updater] new exe not found: {new_exe}") return 2 if not wait_for_exit(args.pid, timeout_sec=30): - print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr) + _log(f"[updater] timeout waiting for PID {args.pid}") return 3 # Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음. @@ -166,13 +196,15 @@ def main() -> int: backup = replace_file(new_exe, target_exe) if backup is None: + _log("[updater] replace_file failed — aborting") return 4 if args.no_launch: + _log("[updater] --no-launch set, exiting after replace") return 0 if not launch(target_exe): - # 시작 실패 시 롤백 + _log("[updater] launch failed — rolling back") try: target_exe.unlink() shutil.move(str(backup), str(target_exe)) @@ -181,7 +213,7 @@ def main() -> int: pass return 5 - # 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink. + _log("[updater] update complete, new app launched") return 0 diff --git a/updater.spec b/updater.spec index 9984ef7..2af1a65 100644 --- a/updater.spec +++ b/updater.spec @@ -33,7 +33,7 @@ exe = EXE( upx=True, upx_exclude=[], runtime_tmpdir=None, - console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지 + console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백 disable_windowed_traceback=False, argv_emulation=False, target_arch=None, diff --git a/utils/updater_client.py b/utils/updater_client.py index 619dfa9..db1df65 100644 --- a/utils/updater_client.py +++ b/utils/updater_client.py @@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool: pid = os.getpid() try: - DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0 - creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0 + # CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라 + # 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서 + # 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록. + if sys.platform == 'win32': + DETACHED_PROCESS = 0x00000008 + CREATE_NO_WINDOW = 0x08000000 + creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW + else: + creationflags = 0 subprocess.Popen( [str(updater_exe), '--pid', str(pid),