Skip to content

Commit b0abef8

Browse files
committed
Improve Windows build for standalone app feel
Replace console window with system tray icon, add windowed mode, version info, log file redirection, and single instance protection. - Add pystray-based system tray icon (Open in Browser, View Log, Quit) - Switch PyInstaller from console=True to console=False (no black window) - Embed Windows version info in exe (file properties show app name/version) - Redirect stdout/stderr to chitchats.log in windowed mode - Add PID-based single instance check with MessageBox on duplicate launch - Clean up lock file on exit via atexit + signal handlers https://claude.ai/code/session_01XVToZ5QmJMYD5Qu3m7W4Kc
1 parent 17fe490 commit b0abef8

6 files changed

Lines changed: 377 additions & 8 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ Thumbs.db
4242
logs/
4343
debug.txt
4444

45+
# Standalone app lock file
46+
.chitchats.lock
47+
4548
# Simulation outputs
4649
chatroom_*.txt
4750

ChitChats.spec

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,52 @@ hiddenimports = [
122122
'PIL',
123123
'PIL.Image',
124124
'PIL.WebPImagePlugin',
125+
# System tray (standalone mode)
126+
'pystray',
127+
'pystray._win32',
125128
]
126129

130+
# Windows version info
131+
version_info = None
132+
if os.name == 'nt' or True: # Always generate (cross-compile friendly)
133+
from PyInstaller.utils.win32.versioninfo import (
134+
FixedFileInfo,
135+
StringFileInfo,
136+
StringStruct,
137+
StringTable,
138+
VarFileInfo,
139+
VarStruct,
140+
VSVersionInfo,
141+
)
142+
version_info = VSVersionInfo(
143+
ffi=FixedFileInfo(
144+
filevers=(1, 0, 0, 0),
145+
prodvers=(1, 0, 0, 0),
146+
mask=0x3f,
147+
flags=0x0,
148+
OS=0x40004, # VOS_NT_WINDOWS32
149+
fileType=0x1, # VFT_APP
150+
subtype=0x0,
151+
),
152+
kids=[
153+
StringFileInfo([
154+
StringTable(
155+
'040904B0', # lang=US English, charset=Unicode
156+
[
157+
StringStruct('CompanyName', 'ChitChats'),
158+
StringStruct('FileDescription', 'ChitChats - Multi-Agent Chat Room'),
159+
StringStruct('FileVersion', '1.0.0'),
160+
StringStruct('InternalName', 'ChitChats'),
161+
StringStruct('OriginalFilename', 'ChitChats.exe'),
162+
StringStruct('ProductName', 'ChitChats'),
163+
StringStruct('ProductVersion', '1.0.0'),
164+
],
165+
),
166+
]),
167+
VarFileInfo([VarStruct('Translation', [0x0409, 0x04B0])]),
168+
],
169+
)
170+
127171
a = Analysis(
128172
[str(backend_dir / 'launcher.py')],
129173
pathex=[str(backend_dir)],
@@ -160,11 +204,12 @@ exe = EXE(
160204
upx=True,
161205
upx_exclude=[],
162206
runtime_tmpdir=None,
163-
console=True, # Console window for debugging
207+
console=False, # Windowed mode - no console window, uses system tray instead
164208
disable_windowed_traceback=False,
165209
argv_emulation=False,
166210
target_arch=None,
167211
codesign_identity=None,
168212
entitlements_file=None,
169213
icon=str(project_root / 'frontend' / 'public' / 'chitchats.ico'),
214+
version=version_info,
170215
)

backend/launcher.py

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
2. Runs first-time setup wizard if needed (console mode only)
77
3. Opens the default web browser automatically
88
4. Starts the uvicorn server
9+
5. Shows a system tray icon in windowed mode (no console window)
910
1011
When run as a Tauri sidecar (legacy, archived):
1112
- Setup is handled by Tauri's GUI wizard
@@ -14,6 +15,7 @@
1415
"""
1516

1617
import getpass
18+
import logging
1719
import os
1820
import secrets
1921
import signal
@@ -31,6 +33,9 @@
3133
DEFAULT_PORT = 8000
3234
FALLBACK_PORTS = [8001, 8080, 8888, 9000]
3335

36+
# Log file path (set during setup_logging)
37+
_log_file_path = None
38+
3439

3540
def find_available_port(preferred_port: int = DEFAULT_PORT) -> int:
3641
"""Find an available port, starting with the preferred port.
@@ -75,6 +80,104 @@ def get_work_dir() -> Path:
7580
return Path(__file__).parent.parent
7681

7782

83+
def is_windowed_mode() -> bool:
84+
"""Check if running in windowed mode (no console attached).
85+
86+
In windowed mode (PyInstaller with console=False), there is no console
87+
window. We use a system tray icon and log file instead.
88+
"""
89+
if not getattr(sys, "frozen", False):
90+
return False
91+
# sys.stderr is None when running in windowed mode (--noconsole)
92+
return sys.stderr is None or not hasattr(sys.stderr, "write")
93+
94+
95+
def setup_log_file() -> str | None:
96+
"""Redirect stdout/stderr to a log file when running in windowed mode.
97+
98+
Returns the log file path, or None if not in windowed mode.
99+
"""
100+
global _log_file_path
101+
102+
if not is_windowed_mode():
103+
return None
104+
105+
work_dir = get_work_dir()
106+
log_path = work_dir / "chitchats.log"
107+
_log_file_path = str(log_path)
108+
109+
try:
110+
# Open log file in append mode with UTF-8 encoding
111+
log_file = open(log_path, "a", encoding="utf-8", buffering=1)
112+
113+
# Redirect stdout and stderr to log file
114+
sys.stdout = log_file
115+
sys.stderr = log_file
116+
117+
# Also configure Python's root logger to write to the file
118+
logging.basicConfig(
119+
stream=log_file,
120+
level=logging.INFO,
121+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
122+
)
123+
124+
return _log_file_path
125+
except Exception:
126+
# If we can't set up logging, continue without it
127+
return None
128+
129+
130+
def check_single_instance() -> bool:
131+
"""Check if another instance is already running using a lock file.
132+
133+
Returns True if this is the only instance, False if another is running.
134+
"""
135+
if not getattr(sys, "frozen", False):
136+
return True # Skip in development mode
137+
138+
work_dir = get_work_dir()
139+
lock_file = work_dir / ".chitchats.lock"
140+
141+
try:
142+
if lock_file.exists():
143+
# Check if the PID in the lock file is still running
144+
try:
145+
pid = int(lock_file.read_text().strip())
146+
# On Windows, check if process exists
147+
if sys.platform == "win32":
148+
import ctypes
149+
kernel32 = ctypes.windll.kernel32
150+
handle = kernel32.OpenProcess(0x100000, False, pid) # SYNCHRONIZE
151+
if handle:
152+
kernel32.CloseHandle(handle)
153+
return False # Process is still running
154+
else:
155+
os.kill(pid, 0) # Signal 0 = check if process exists
156+
return False
157+
except (ValueError, OSError, ProcessLookupError):
158+
pass # Stale lock file, remove it
159+
160+
# Write our PID
161+
lock_file.write_text(str(os.getpid()))
162+
return True
163+
except Exception:
164+
return True # If we can't check, allow running
165+
166+
167+
def cleanup_lock_file():
168+
"""Remove the lock file on exit."""
169+
if not getattr(sys, "frozen", False):
170+
return
171+
172+
work_dir = get_work_dir()
173+
lock_file = work_dir / ".chitchats.lock"
174+
try:
175+
if lock_file.exists():
176+
lock_file.unlink()
177+
except Exception:
178+
pass
179+
180+
78181
def setup_paths():
79182
"""Set up Python paths for imports."""
80183
base_path = get_base_path()
@@ -315,14 +418,46 @@ def main():
315418
# Set up paths first
316419
setup_paths()
317420

421+
# Check for single instance (bundled mode only)
422+
if not check_single_instance():
423+
# Another instance is running - try to open the browser to the existing one
424+
# and exit quietly
425+
if is_windowed_mode():
426+
try:
427+
import ctypes
428+
ctypes.windll.user32.MessageBoxW(
429+
0,
430+
"ChitChats is already running.\nCheck the system tray icon.",
431+
"ChitChats",
432+
0x40, # MB_ICONINFORMATION
433+
)
434+
except Exception:
435+
pass
436+
else:
437+
print("ChitChats is already running.")
438+
sys.exit(0)
439+
440+
# Set up log file for windowed mode (no console)
441+
log_file = setup_log_file()
442+
318443
# Set up signal handlers for graceful shutdown
319444
def signal_handler(signum, frame):
320445
print("\n서버를 종료합니다...")
446+
cleanup_lock_file()
447+
try:
448+
from tray import stop_tray
449+
stop_tray()
450+
except Exception:
451+
pass
321452
sys.exit(0)
322453

323454
signal.signal(signal.SIGTERM, signal_handler)
324455
signal.signal(signal.SIGINT, signal_handler)
325456

457+
# Register lock file cleanup at exit
458+
import atexit
459+
atexit.register(cleanup_lock_file)
460+
326461
# Check if running as Tauri sidecar
327462
sidecar_mode = is_tauri_sidecar()
328463

@@ -336,20 +471,30 @@ def signal_handler(signum, frame):
336471

337472
# Find an available port
338473
port = find_available_port(DEFAULT_PORT)
474+
server_url = f"http://localhost:{port}"
339475

340476
print("=" * 60)
341477
print("ChitChats")
342478
print("=" * 60)
343479
print()
344480
if port != DEFAULT_PORT:
345481
print(f"포트 {DEFAULT_PORT}을(를) 사용할 수 없어 포트 {port}을(를) 사용합니다.")
346-
print(f"서버 시작 중: http://localhost:{port}")
482+
print(f"서버 시작 중: {server_url}")
347483
if not sidecar_mode:
348484
print("서버를 중지하려면 Ctrl+C를 누르세요")
349485
print()
350486
# Open browser automatically (standalone mode only)
351-
open_browser_delayed(f"http://localhost:{port}")
487+
open_browser_delayed(server_url)
352488
print("브라우저를 자동으로 엽니다...")
489+
490+
# Start system tray icon (windowed mode)
491+
if is_windowed_mode():
492+
try:
493+
from tray import start_tray
494+
start_tray(server_url, log_file)
495+
print("시스템 트레이 아이콘이 활성화되었습니다.")
496+
except Exception as e:
497+
print(f"시스템 트레이 아이콘을 시작할 수 없습니다: {e}")
353498
print()
354499

355500
# Import the app directly instead of using string path

0 commit comments

Comments
 (0)