Python web server that wraps Clide for browser access. Replaces the previous ttyd (C binary) + zellij (Rust binary) stack with a single FastAPI application using tmux for session persistence. Key features: - WebSocket ↔ PTY bridge via tmux attach - Project switching via /projects/<name> URL routing - Vendored xterm.js for offline LAN operation - Auto-respawn on Clide exit (tmux pane-died hook) - Setup wizard for first-run configuration - No scrollbar (TUI manages its own scrolling) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
9.1 KiB
Python
310 lines
9.1 KiB
Python
"""FastAPI application: HTTP routes + WebSocket terminal bridge."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import uvicorn
|
|
from clide.models.db import ConnectionLog
|
|
from clide.services.database import get_db, init_db
|
|
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlmodel import Session as DBSession
|
|
|
|
from clide_web.config import ClideWebSettings, load_settings
|
|
from clide_web.pty_bridge import PtyBridge
|
|
from clide_web.sessions import TmuxSessionManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Module-level settings and session manager (initialized in lifespan)
|
|
_settings: ClideWebSettings | None = None
|
|
_session_mgr: TmuxSessionManager | None = None
|
|
_cleanup_task: asyncio.Task | None = None # type: ignore[type-arg]
|
|
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifespan
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI): # type: ignore[no-untyped-def]
|
|
global _settings, _session_mgr, _cleanup_task
|
|
|
|
_settings = load_settings()
|
|
_session_mgr = TmuxSessionManager(_settings)
|
|
|
|
# Initialize database
|
|
init_db(_settings.db_path)
|
|
logger.info("Database initialized at %s", _settings.db_path)
|
|
|
|
# Start periodic session cleanup
|
|
_cleanup_task = asyncio.create_task(_periodic_cleanup())
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
if _cleanup_task:
|
|
_cleanup_task.cancel()
|
|
try:
|
|
await _cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
async def _periodic_cleanup() -> None:
|
|
"""Periodically sync DB session records with live tmux state."""
|
|
assert _settings is not None
|
|
assert _session_mgr is not None
|
|
while True:
|
|
await asyncio.sleep(_settings.session_cleanup_interval_seconds)
|
|
try:
|
|
db_gen = get_db()
|
|
db = next(db_gen)
|
|
try:
|
|
await _session_mgr.cleanup_dead_sessions(db)
|
|
finally:
|
|
try:
|
|
next(db_gen)
|
|
except StopIteration:
|
|
pass
|
|
except Exception:
|
|
logger.exception("Session cleanup failed")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# App
|
|
# ------------------------------------------------------------------
|
|
|
|
app = FastAPI(title="clide-web", version="1.0.0", lifespan=lifespan)
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Routes
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index():
|
|
"""Redirect root to project list or serve HTML."""
|
|
return _serve_index()
|
|
|
|
|
|
@app.get("/projects/{project_name}", response_class=HTMLResponse)
|
|
@app.head("/projects/{project_name}")
|
|
async def project_page(project_name: str): # noqa: ARG001
|
|
"""Serve the terminal page for a specific project."""
|
|
return _serve_index()
|
|
|
|
|
|
def _serve_index() -> FileResponse | HTMLResponse:
|
|
index_path = STATIC_DIR / "index.html"
|
|
if index_path.exists():
|
|
return FileResponse(index_path, media_type="text/html")
|
|
return HTMLResponse("<h1>clide-web</h1><p>index.html not found</p>", status_code=500)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
"""Health check for reverse proxy."""
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/projects")
|
|
async def list_projects():
|
|
"""List available git projects."""
|
|
assert _session_mgr is not None
|
|
return {"projects": _session_mgr.list_projects()}
|
|
|
|
|
|
@app.get("/api/sessions")
|
|
async def list_sessions():
|
|
"""List active tmux sessions."""
|
|
assert _session_mgr is not None
|
|
sessions = await _session_mgr.list_sessions()
|
|
return {"sessions": sessions}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# WebSocket terminal bridge
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@app.websocket("/projects/{project}/ws")
|
|
async def websocket_terminal(
|
|
ws: WebSocket,
|
|
project: str,
|
|
db: DBSession = Depends(get_db), # noqa: B008
|
|
):
|
|
"""Bridge WebSocket ↔ PTY (via tmux attach).
|
|
|
|
Protocol:
|
|
Prefix "0" + data → terminal I/O
|
|
Prefix "1" + json → control messages
|
|
Prefix "2" + C,R → resize (cols,rows)
|
|
"""
|
|
assert _settings is not None
|
|
assert _session_mgr is not None
|
|
|
|
await ws.accept()
|
|
|
|
if not project:
|
|
await ws.send_text('1{"type":"error","message":"No project specified"}')
|
|
await ws.close(code=1008)
|
|
return
|
|
|
|
# Validate and create/attach tmux session
|
|
try:
|
|
tmux_name = await _session_mgr.create_session(project, db)
|
|
except (ValueError, RuntimeError) as e:
|
|
await ws.send_text(f'1{json.dumps({"type": "error", "message": str(e)})}')
|
|
await ws.close(code=1008)
|
|
return
|
|
|
|
# Log connection
|
|
client_ip = ws.client.host if ws.client else "unknown"
|
|
log_entry = ConnectionLog(project_name=project, client_ip=client_ip)
|
|
db.add(log_entry)
|
|
db.commit()
|
|
db.refresh(log_entry)
|
|
|
|
# Send session info
|
|
await ws.send_text(
|
|
f'1{json.dumps({"type": "session_info", "project": project, "tmux_session": tmux_name})}'
|
|
)
|
|
|
|
# Set up PTY bridge to tmux session
|
|
send_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
|
|
def on_output(data: bytes) -> None:
|
|
send_queue.put_nowait(data)
|
|
|
|
def on_exit() -> None:
|
|
send_queue.put_nowait(b"") # Sentinel for EOF
|
|
|
|
bridge = PtyBridge(
|
|
tmux_session=tmux_name,
|
|
on_output=on_output,
|
|
on_exit=on_exit,
|
|
rows=_settings.default_rows,
|
|
cols=_settings.default_cols,
|
|
)
|
|
bridge.start()
|
|
|
|
# Task to forward PTY output → WebSocket
|
|
async def _forward_output() -> None:
|
|
while True:
|
|
data = await send_queue.get()
|
|
if not data:
|
|
break
|
|
try:
|
|
await ws.send_bytes(b"0" + data)
|
|
except Exception:
|
|
break
|
|
|
|
output_task = asyncio.create_task(_forward_output())
|
|
|
|
try:
|
|
while True:
|
|
message = await ws.receive()
|
|
|
|
if message["type"] == "websocket.disconnect":
|
|
break
|
|
|
|
if "text" in message:
|
|
text = message["text"]
|
|
if not text:
|
|
continue
|
|
prefix = text[0]
|
|
payload = text[1:]
|
|
|
|
if prefix == "0":
|
|
# Terminal input
|
|
bridge.write(payload.encode("utf-8", errors="surrogateescape"))
|
|
elif prefix == "1":
|
|
# Control message
|
|
await _handle_control(ws, payload, project)
|
|
elif prefix == "2":
|
|
# Resize: "2cols,rows"
|
|
try:
|
|
cols_str, rows_str = payload.split(",", 1)
|
|
bridge.resize(int(rows_str), int(cols_str))
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
elif "bytes" in message:
|
|
raw = message["bytes"]
|
|
if raw and len(raw) > 1:
|
|
prefix = raw[0:1]
|
|
payload_bytes = raw[1:]
|
|
if prefix == b"0":
|
|
bridge.write(payload_bytes)
|
|
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception:
|
|
logger.exception("WebSocket error for project %s", project)
|
|
finally:
|
|
bridge.stop()
|
|
output_task.cancel()
|
|
try:
|
|
await output_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Update connection log
|
|
log_entry.disconnected_at = datetime.utcnow()
|
|
db.add(log_entry)
|
|
db.commit()
|
|
|
|
|
|
async def _handle_control(ws: WebSocket, payload: str, project: str) -> None: # noqa: ARG001
|
|
"""Handle a JSON control message from the client."""
|
|
try:
|
|
msg = json.loads(payload)
|
|
except json.JSONDecodeError:
|
|
return
|
|
|
|
msg_type = msg.get("type", "")
|
|
|
|
if msg_type == "list_projects":
|
|
assert _session_mgr is not None
|
|
projects = _session_mgr.list_projects()
|
|
await ws.send_text(f'1{json.dumps({"type": "projects", "projects": projects})}')
|
|
|
|
elif msg_type == "ping":
|
|
await ws.send_text(f'1{json.dumps({"type": "pong"})}')
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Entry point
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the clide-web server."""
|
|
settings = load_settings()
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
)
|
|
logger.info("Starting clide-web on %s:%d", settings.host, settings.port)
|
|
uvicorn.run(
|
|
app,
|
|
host=settings.host,
|
|
port=settings.port,
|
|
log_level="info",
|
|
ws_ping_interval=20,
|
|
ws_ping_timeout=20,
|
|
)
|