v2.10.1: 업데이트 시 cmd 창 깜빡임 제거 (hotfix)
- updater.spec: console=True → console=False (windowed 빌드) - updater.py: stderr 출력을 ~/.clockout_logs/updater.log 파일 폴백으로 전환 (windowed 모드라도 진단 로그 보존). 모든 단계 타임스탬프 기록. - updater.py launch(): subprocess.Popen에 CREATE_NO_WINDOW 플래그 추가 - utils/updater_client.py apply_update(): 같은 패턴으로 CREATE_NO_WINDOW 추가 main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
97dd4e39f7
commit
3db4ed2351
13
CHANGELOG.md
13
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/).
|
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
|
## [2.10.0] — 2026-05-01
|
||||||
|
|
||||||
### Added — 정부 공휴일 API 자동 동기화
|
### Added — 정부 공휴일 API 자동 동기화
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.10.0'
|
__version__ = '2.10.1'
|
||||||
|
|||||||
68
updater.py
68
updater.py
@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
|
|||||||
3. new_exe → target_exe 이동
|
3. new_exe → target_exe 이동
|
||||||
4. target_exe 재실행 + 업데이터 자가 종료
|
4. target_exe 재실행 + 업데이터 자가 종료
|
||||||
실패 시 .bak 복원
|
실패 시 .bak 복원
|
||||||
|
|
||||||
|
빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임.
|
||||||
|
모든 진단 출력은 ~/.clockout_logs/updater.log 로 append.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
@ -21,9 +24,30 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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:
|
def is_pid_running(pid: int) -> bool:
|
||||||
"""Windows에서 PID 실행 중인지 확인."""
|
"""Windows에서 PID 실행 중인지 확인."""
|
||||||
if sys.platform != 'win32':
|
if sys.platform != 'win32':
|
||||||
@ -81,8 +105,7 @@ def replace_file(new_path: Path, target_path: Path,
|
|||||||
try:
|
try:
|
||||||
backup.unlink()
|
backup.unlink()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"[updater] old backup unlink failed (continuing): {e}",
|
_log(f"[updater] old backup unlink failed (continuing): {e}")
|
||||||
file=sys.stderr)
|
|
||||||
|
|
||||||
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
@ -94,13 +117,12 @@ def replace_file(new_path: Path, target_path: Path,
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
wait = 0.3 * (2 ** attempt)
|
wait = 0.3 * (2 ** attempt)
|
||||||
print(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||||
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
f"failed ({e}); waiting {wait:.1f}s")
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
else:
|
else:
|
||||||
# 모든 재시도 실패
|
# 모든 재시도 실패
|
||||||
print(f"[updater] target move failed after {max_retries} attempts: {last_err}",
|
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||||
file=sys.stderr)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 3단계: new → target 이동
|
# 3단계: new → target 이동
|
||||||
@ -111,19 +133,18 @@ def replace_file(new_path: Path, target_path: Path,
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
wait = 0.3 * (2 ** attempt)
|
wait = 0.3 * (2 ** attempt)
|
||||||
print(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||||
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
f"failed ({e}); waiting {wait:.1f}s")
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
|
|
||||||
# new 이동 실패 → backup으로 롤백 시도
|
# new 이동 실패 → backup으로 롤백 시도
|
||||||
print(f"[updater] new move failed after {max_retries} attempts: {last_err}",
|
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
|
||||||
file=sys.stderr)
|
|
||||||
if backup.exists() and not target_path.exists():
|
if backup.exists() and not target_path.exists():
|
||||||
try:
|
try:
|
||||||
shutil.move(str(backup), str(target_path))
|
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:
|
except OSError as e:
|
||||||
print(f"[updater] rollback also failed: {e}", file=sys.stderr)
|
_log(f"[updater] rollback also failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -131,13 +152,20 @@ def launch(exe_path: Path) -> bool:
|
|||||||
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
||||||
try:
|
try:
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
|
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
|
||||||
|
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
|
||||||
DETACHED_PROCESS = 0x00000008
|
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:
|
else:
|
||||||
subprocess.Popen([str(exe_path)], close_fds=True)
|
subprocess.Popen([str(exe_path)], close_fds=True)
|
||||||
return True
|
return True
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"[updater] launch failed: {e}", file=sys.stderr)
|
_log(f"[updater] launch failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -149,15 +177,17 @@ def main() -> int:
|
|||||||
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
|
||||||
|
|
||||||
new_exe = Path(args.new).resolve()
|
new_exe = Path(args.new).resolve()
|
||||||
target_exe = Path(args.target).resolve()
|
target_exe = Path(args.target).resolve()
|
||||||
|
|
||||||
if not new_exe.exists():
|
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
|
return 2
|
||||||
|
|
||||||
if not wait_for_exit(args.pid, timeout_sec=30):
|
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
|
return 3
|
||||||
|
|
||||||
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
||||||
@ -166,13 +196,15 @@ def main() -> int:
|
|||||||
|
|
||||||
backup = replace_file(new_exe, target_exe)
|
backup = replace_file(new_exe, target_exe)
|
||||||
if backup is None:
|
if backup is None:
|
||||||
|
_log("[updater] replace_file failed — aborting")
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
if args.no_launch:
|
if args.no_launch:
|
||||||
|
_log("[updater] --no-launch set, exiting after replace")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if not launch(target_exe):
|
if not launch(target_exe):
|
||||||
# 시작 실패 시 롤백
|
_log("[updater] launch failed — rolling back")
|
||||||
try:
|
try:
|
||||||
target_exe.unlink()
|
target_exe.unlink()
|
||||||
shutil.move(str(backup), str(target_exe))
|
shutil.move(str(backup), str(target_exe))
|
||||||
@ -181,7 +213,7 @@ def main() -> int:
|
|||||||
pass
|
pass
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
|
_log("[updater] update complete, new app launched")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,7 @@ exe = EXE(
|
|||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
|
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
|
|||||||
@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
|
|||||||
|
|
||||||
pid = os.getpid()
|
pid = os.getpid()
|
||||||
try:
|
try:
|
||||||
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
|
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
|
||||||
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
|
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
|
||||||
|
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
DETACHED_PROCESS = 0x00000008
|
||||||
|
CREATE_NO_WINDOW = 0x08000000
|
||||||
|
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
|
||||||
|
else:
|
||||||
|
creationflags = 0
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
[str(updater_exe),
|
[str(updater_exe),
|
||||||
'--pid', str(pid),
|
'--pid', str(pid),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user