refactor: restructure project into muxplex/ subdir with brand integration

- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt,
  docs/ into muxplex/ subdir in prep for packaging/sharing
- Add brand assets to frontend/: favicon.ico, pwa-192/512.png,
  apple-touch-icon.png, wordmark-on-dark.svg
- Update app: title → muxplex, header → wordmark SVG, brand color tokens
  in style.css, manifest.json updated with muxplex name and brand icons
- Add design system: assets/branding/tokens.css (101 CSS custom properties),
  tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec)
- Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG)
- Add scripts/render-brand-assets.py for reproducible PNG generation
- Add muxplex/README.md
This commit is contained in:
Brian Krabach
2026-03-27 15:06:00 -07:00
commit 8234e2ec05
76 changed files with 17006 additions and 0 deletions
+345
View File
@@ -0,0 +1,345 @@
"""
FastAPI coordinator application for tmux-web.
Entry point for the coordinator service. Exposes:
GET /health → {"status": "ok"}
Background poll loop reconciles tmux session state every POLL_INTERVAL seconds.
"""
import asyncio
import contextlib
import logging
import os
import pathlib
import time
from typing import Literal
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from coordinator.bells import apply_bell_clear_rule, process_bell_flags
from coordinator.sessions import (
enumerate_sessions,
get_session_list,
get_snapshots,
run_tmux,
snapshot_all,
update_session_cache,
)
from coordinator.state import (
empty_bell,
load_state,
prune_devices,
read_state,
register_device,
save_state,
state_lock,
)
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
_log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Module-level task reference
# ---------------------------------------------------------------------------
_poll_task: asyncio.Task | None = None
# ---------------------------------------------------------------------------
# Poll cycle
# ---------------------------------------------------------------------------
async def _run_poll_cycle() -> None:
"""Perform one full poll cycle, all operations executed under state_lock."""
async with state_lock:
# 1. Enumerate live tmux sessions
names = await enumerate_sessions()
name_set = set(names)
# 2. Capture pane snapshots and update in-memory snapshot cache
new_snapshots = await snapshot_all(names)
update_session_cache(names, new_snapshots)
# 3. Load current persisted state
state = load_state()
# 4. Reconcile session_order: preserve user ordering, add new, remove deleted
state["session_order"] = [s for s in state["session_order"] if s in name_set]
existing_order_set = set(state["session_order"])
for name in names:
if name not in existing_order_set:
state["session_order"].append(name)
# 5. Ensure bell entries exist for every current session
for name in names:
if name not in state["sessions"]:
state["sessions"][name] = {}
if "bell" not in state["sessions"][name]:
state["sessions"][name]["bell"] = empty_bell()
# 6. Remove state entries for sessions that no longer exist
deleted = [s for s in list(state["sessions"]) if s not in name_set]
for name in deleted:
del state["sessions"][name]
# 7. Clear active_session if the session is gone
if state["active_session"] not in name_set:
state["active_session"] = None
# 8. Process bell flags (detect 0→1 transitions, update unseen_count)
await process_bell_flags(names, state)
# 9. Apply bell clear rule (acknowledge bells when device is watching fullscreen)
apply_bell_clear_rule(state)
# 10. Prune devices that haven't sent a heartbeat recently
prune_devices(state)
# 11. Atomically persist the updated state
save_state(state)
# ---------------------------------------------------------------------------
# Poll loop
# ---------------------------------------------------------------------------
async def _poll_loop() -> None:
"""Run _run_poll_cycle() every POLL_INTERVAL seconds, catching all exceptions."""
while True:
try:
await _run_poll_cycle()
except Exception:
_log.exception("poll cycle error")
await asyncio.sleep(POLL_INTERVAL)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
global _poll_task
# Startup: kill any orphaned ttyd from a previous coordinator run, then
# start the background poll loop.
await kill_orphan_ttyd()
_poll_task = asyncio.create_task(_poll_loop())
# Register tmux alert-bell hook so bells are detected even when clients are attached.
# window_bell_flag is only set when no client watches the window; the hook fires always.
try:
await run_tmux(
"set-hook",
"-g",
"alert-bell",
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
)
except Exception:
pass # tmux not running at startup is OK; hook will be set on first poll
yield
# Shutdown: cancel the poll loop task and wait for it to finish.
if _poll_task is not None:
_poll_task.cancel()
try:
await _poll_task
except (asyncio.CancelledError, Exception):
pass
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(title="tmux-web coordinator", version="0.1.0", lifespan=lifespan)
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class StatePatch(BaseModel):
session_order: list[str]
class HeartbeatPayload(BaseModel):
device_id: str
label: str
viewing_session: str | None
view_mode: Literal["grid", "fullscreen"]
last_interaction_at: float
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/health")
async def health() -> dict[str, str]:
"""Simple liveness check."""
return {"status": "ok"}
@app.get("/api/state")
async def get_state() -> dict:
"""Return the full persistent state."""
return await read_state()
@app.patch("/api/state")
async def patch_state(patch: StatePatch) -> dict:
"""Update session_order in the persistent state and return the updated state."""
async with state_lock:
state = load_state()
state["session_order"] = patch.session_order
save_state(state)
return state
@app.get("/api/sessions")
async def get_sessions() -> list[dict]:
"""Return list of sessions with name, snapshot, and bell data."""
names = get_session_list()
snapshots = get_snapshots()
state = await read_state()
result = []
for name in names:
session_state = state.get("sessions", {}).get(name, {})
bell = session_state.get("bell", empty_bell())
result.append(
{
"name": name,
"snapshot": snapshots.get(name, ""),
"bell": bell,
}
)
return result
@app.post("/api/sessions/{name}/connect")
async def connect_session(name: str) -> dict:
"""Connect to a tmux session via ttyd.
Kills any existing ttyd process, spawns a new one attached to *name*,
and updates the active_session in persistent state.
Returns {active_session: name, ttyd_port: 7682}.
Raises HTTP 404 if *name* is not in the known session list (when non-empty).
"""
known = get_session_list()
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
await kill_ttyd()
await spawn_ttyd(name)
async with state_lock:
state = load_state()
state["active_session"] = name
save_state(state)
return {"active_session": name, "ttyd_port": TTYD_PORT}
@app.delete("/api/sessions/current")
async def delete_current_session() -> dict:
"""Disconnect the current ttyd session.
Kills the running ttyd process and clears active_session in persistent state.
Returns {active_session: None}.
"""
await kill_ttyd()
async with state_lock:
state = load_state()
state["active_session"] = None
save_state(state)
return {"active_session": None}
@app.post("/api/heartbeat")
async def heartbeat(payload: HeartbeatPayload) -> dict:
"""Register or update a device heartbeat.
Acquires state_lock, loads state, calls register_device() with payload
fields, saves state.
Returns {device_id: str, status: 'ok'}.
Missing device_id or invalid view_mode returns 422 (handled by Pydantic).
"""
async with state_lock:
state = load_state()
register_device(
state,
device_id=payload.device_id,
label=payload.label,
viewing_session=payload.viewing_session,
view_mode=payload.view_mode,
last_interaction_at=payload.last_interaction_at,
)
save_state(state)
return {"device_id": payload.device_id, "status": "ok"}
@app.post("/api/sessions/{name}/bell")
async def receive_bell(name: str) -> dict:
"""Called by tmux alert-bell hook when a bell fires in session *name*.
This is more reliable than polling window_bell_flag because tmux only
sets that flag when no client is attached -- with an SSH/WezTerm session
attached, the flag never gets set even though the bell fires.
"""
async with state_lock:
state = load_state()
if name not in state["sessions"]:
state["sessions"][name] = {}
if "bell" not in state["sessions"][name]:
state["sessions"][name]["bell"] = empty_bell()
bell = state["sessions"][name]["bell"]
bell["unseen_count"] = bell.get("unseen_count", 0) + 1
bell["last_fired_at"] = time.time()
save_state(state)
return {"ok": True, "session": name}
@app.post("/api/internal/setup-hooks")
async def setup_hooks() -> dict:
"""Re-register tmux hooks. Call after tmux server restarts."""
try:
await run_tmux(
"set-hook",
"-g",
"alert-bell",
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
# ---------------------------------------------------------------------------
# Static file serving — MUST come after all API routes (first-match-wins)
# ---------------------------------------------------------------------------
_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend"
app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend")