Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
================================================================================
|
||||
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()
|
||||
Reference in New Issue
Block a user