""" ================================================================================ Quantum Terminal Consumer — Launcher ================================================================================ Single entry point for the packaged .exe. Starts data server + serves React frontend + opens browser. Auto-creates consumer_config.ini on first run. Usage: python launcher.py # Dev mode QuantumTerminal.exe # Packaged mode This module does NOT introduce any calculation triggers. ================================================================================ """ import sys import os import threading import time import webbrowser import shutil import logging from pathlib import Path log = logging.getLogger("mk.launcher") # — Determine base paths (works for both dev and PyInstaller) — if getattr(sys, 'frozen', False): # Running as packaged .exe — everything is flat in _MEIPASS BASE_DIR = Path(sys._MEIPASS) EXE_DIR = Path(sys.executable).parent BACKEND_DIR = BASE_DIR # PyInstaller bundles flat, no backend/ subfolder FRONTEND_DIR = BASE_DIR / "frontend" else: # Running as script (dev mode) BASE_DIR = Path(__file__).resolve().parent EXE_DIR = BASE_DIR BACKEND_DIR = BASE_DIR FRONTEND_DIR = BASE_DIR.parent / "terminal_app" / "build" PORT = int(os.environ.get("MK_PORT", "8502")) # v2: Electron passes MK_PORT # v2: AppData dir name (env-var driven; default "QuantumTerminal" preserves v1 path). APP_DIR = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal") # — Default config content (f-string so APP_DIR is substituted at module load) — DEFAULT_CONFIG = f"""\ [server] # No external server needed for Quantum Terminal (open source) base_url = [data] cache_dir = %APPDATA%\\{APP_DIR}\\cache stale_warning_hours = 48 stale_error_hours = 72 """ def ensure_config(): """Create consumer_config.ini on first run. User never touches this.""" # Check next to .exe first (packaged mode) config_path = EXE_DIR / "consumer_config.ini" if config_path.exists(): return config_path # Also check AppData (fallback location) appdata_config = _get_appdata_base() / "consumer_config.ini" if appdata_config.exists(): return appdata_config # Try to write next to .exe try: template = BASE_DIR / "consumer_config.ini.template" if template.exists(): shutil.copy2(template, config_path) else: config_path.write_text(DEFAULT_CONFIG, encoding="utf-8") print(f" Config created: {config_path}") return config_path except PermissionError: # Program Files is write-protected — use AppData instead appdata_dir = _get_appdata_base() appdata_dir.mkdir(parents=True, exist_ok=True) appdata_config = appdata_dir / "consumer_config.ini" try: template = BASE_DIR / "consumer_config.ini.template" if template.exists(): shutil.copy2(template, appdata_config) else: appdata_config.write_text(DEFAULT_CONFIG, encoding="utf-8") print(f" Config created (AppData): {appdata_config}") return appdata_config except Exception as e: print(f" [WARN] Could not create config: {e}") return appdata_config def _get_appdata_base() -> Path: """Get AppData\\Roaming\\ path. APP_DIR comes from MK_APP_DIR_NAME env var.""" import platform if platform.system() == "Windows": appdata = os.environ.get("APPDATA", "") if appdata: return Path(appdata) / APP_DIR return Path.home() / f".{APP_DIR.lower()}" def ensure_appdata(): """Create AppData directories for auth cache and data cache.""" appdata = os.environ.get("APPDATA", "") if appdata: base = Path(appdata) / APP_DIR else: base = Path.home() / "AppData" / "Roaming" / APP_DIR for subdir in ["", "cache"]: d = base / subdir if subdir else base d.mkdir(parents=True, exist_ok=True) return base def open_browser(): """Wait for server to start, then open browser.""" for _ in range(30): # Wait up to 30 seconds time.sleep(1) try: import urllib.request urllib.request.urlopen(f"http://127.0.0.1:{PORT}/api/health", timeout=2) break except Exception: continue webbrowser.open(f"http://127.0.0.1:{PORT}") def main(): # v2 Phase D: persistent rotating log file under %APPDATA%\\logs\. # Console behavior unchanged; file logging is additive. try: from logging_setup import setup_logging _log_file = setup_logging() except Exception as _e: _log_file = None print(f"[launcher] logging setup skipped: {_e}") print() print("=" * 50) print(" Quantum Terminal Consumer Terminal") print("=" * 50) if _log_file: print(f" Logs : {_log_file}") # Step 1: Ensure config exists config_path = ensure_config() print(f" Config : {config_path}") # Step 2: Ensure AppData dirs exist appdata = ensure_appdata() print(f" AppData: {appdata}") # Step 3: Setup Python path sys.path.insert(0, str(BACKEND_DIR)) os.chdir(str(BACKEND_DIR)) # Also add EXE_DIR so consumer_config.ini is found if str(EXE_DIR) not in sys.path: sys.path.insert(0, str(EXE_DIR)) # Add config directory to path so modules find it config_dir = str(config_path.parent) if config_dir not in sys.path: sys.path.insert(0, config_dir) # Copy config next to backend modules if packaged if getattr(sys, 'frozen', False): backend_config = BACKEND_DIR / "consumer_config.ini" if not backend_config.exists() and config_path.exists(): try: shutil.copy2(config_path, backend_config) except PermissionError: pass # Read-only _internal — config found via sys.path instead print(f" Server : http://127.0.0.1:{PORT}") print(f" Press Ctrl+C to stop") print("=" * 50) print() # Step 4: Open browser in background — skipped when running under Electron. if not os.environ.get("MK_NO_BROWSER"): threading.Thread(target=open_browser, daemon=True).start() # Step 5: Import and start server from data_server import app # Serve React build as static files (production mode) # IMPORTANT: Use StaticFiles mount (not catch-all routes) so API routes # always take priority. The catch-all @app.get("/{path}") pattern causes # 405 errors on PATCH/POST to /api/* endpoints. if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists(): from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response as StarletteResponse # Helper: serve index.html with no-cache headers so browser # always fetches the latest after an update. JS/CSS files have # content hashes in their filenames so they cache safely. def _serve_index(): return FileResponse( str(FRONTEND_DIR / "index.html"), headers={ "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0", }, ) # Serve /static/* assets (JS, CSS, images) app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR / "static")), name="static") # SPA middleware: for non-API, non-static GET requests that don't match # a file, serve index.html. This replaces the old @app.get("/{full_path:path}") # catch-all which caused 405 errors on API PATCH/POST requests. class SPAMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): response = await call_next(request) # If the API returned 404 and this is a GET request # that doesn't target /api/ or /ws/, serve index.html # (React client-side routing) if ( response.status_code == 404 and request.method == "GET" and not request.url.path.startswith("/api/") and not request.url.path.startswith("/ws/") and not request.url.path.startswith("/static/") ): # Check if it's a real file in the build directory file_path = FRONTEND_DIR / request.url.path.lstrip("/") if file_path.exists() and file_path.is_file(): return FileResponse(str(file_path)) # Otherwise serve index.html for React Router return _serve_index() return response app.add_middleware(SPAMiddleware) # Root route — serve index.html @app.get("/", include_in_schema=False) async def serve_root(): return _serve_index() # Favicon favicon_path = FRONTEND_DIR / "favicon.ico" if favicon_path.exists(): @app.get("/favicon.ico", include_in_schema=False) async def serve_favicon(): return FileResponse(str(favicon_path)) print(" Frontend: serving React build") else: print(" Frontend: not found — use npm start for dev mode") # v2 Phase E: graceful shutdown. # Electron sends SIGTERM (Windows: terminates the process group) when # the user closes the window. uvicorn handles SIGTERM by setting its # `should_exit` flag and draining in-flight requests. We additionally # register a Python-level signal handler that flushes logs and lets # any pending sync_timestamps write finish before exit. SIGINT (Ctrl+C # in dev) goes through the same path. import uvicorn import signal config = uvicorn.Config( app=app, host="127.0.0.1", port=PORT, log_level="info", # Give in-flight HTTP requests up to 5s to finish before forcing exit. timeout_graceful_shutdown=5, ) server = uvicorn.Server(config) def _on_signal(signum, frame): log.info(f"Received signal {signum} — initiating graceful shutdown") server.should_exit = True for sig in (signal.SIGINT, signal.SIGTERM): try: signal.signal(sig, _on_signal) except Exception: # Some platforms don't allow custom handlers in non-main threads. pass try: server.run() finally: # Final flush — make sure log records on disk are complete and # any in-memory state (sync timestamps) has been persisted. try: from data_sync_client import get_sync_client as _gsc _sc = _gsc() try: # Best-effort: persist sync timestamps if anything was changed # mid-request when the signal arrived. from data_sync_client import _save_sync_timestamps _save_sync_timestamps(_sc._sync_timestamps) except Exception: pass except Exception: pass try: logging.shutdown() except Exception: pass if __name__ == "__main__": main()