662. Runs first-time setup wizard if needed (console mode only)
773. Opens the default web browser automatically
884. Starts the uvicorn server
9+ 5. Shows a system tray icon in windowed mode (no console window)
910
1011When run as a Tauri sidecar (legacy, archived):
1112- Setup is handled by Tauri's GUI wizard
1415"""
1516
1617import getpass
18+ import logging
1719import os
1820import secrets
1921import signal
3133DEFAULT_PORT = 8000
3234FALLBACK_PORTS = [8001 , 8080 , 8888 , 9000 ]
3335
36+ # Log file path (set during setup_logging)
37+ _log_file_path = None
38+
3439
3540def 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+
78181def 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.\n Check 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