Files

323 lines
11 KiB
Python

"""
================================================================================
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\\<APP_DIR> 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%\<APP_DIR>\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()