Files
muxplex/muxplex/state.py
T
Brian Krabach add9e03718 fix: persist active_remote_id in state for remote session restore
Remote sessions failed to reconnect after page refresh because
restoreState() and renderSheetList() didn't pass remoteId to
openSession(). Fix:

- state.py: add active_remote_id field to empty_state()
- main.py: extend StatePatch model to accept active_session and
  active_remote_id; update PATCH /api/state to selectively update
  only fields explicitly provided (using model_fields_set)
- app.js restoreState(): read state.active_remote_id and pass it
  as remoteId option so page-refresh reconnects remote sessions
- app.js openSession(): fire-and-forget PATCH /api/state to persist
  active_session + active_remote_id after successful connect
- app.js closeSession(): fire-and-forget PATCH /api/state to clear
  both fields so refresh doesn't try to reopen a closed session
- app.js renderSheetList(): add data-remote-id to sheet items and
  pass remoteId in click handler (previously called openSession(name)
  with no remoteId, routing remote sessions to local 404 endpoint)

Fixes: POST /api/sessions/paperclip-adapter/connect 404 when clicking
a remote session from the mobile bottom sheet or after page refresh.
2026-04-06 18:08:19 -07:00

186 lines
5.5 KiB
Python

"""
State schema and factory functions for the tmux-web muxplex.
State schema (all values are plain JSON-serialisable dicts):
{
"active_session": str | None,
"session_order": list[str],
"sessions": {
"<name>": {
"bell": {
"last_fired_at": float | None,
"seen_at": float | None,
"unseen_count": int,
}
}
},
"devices": {
"<device_id>": {
"label": str,
"viewing_session": str | None,
"view_mode": "fullscreen" | "grid",
"last_interaction_at": float,
"last_heartbeat_at": float,
}
},
}
"""
import asyncio
import json
import os
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_default_state_dir = Path.home() / ".local" / "share" / "tmux-web"
STATE_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir))
STATE_PATH: Path = STATE_DIR / "state.json"
# ---------------------------------------------------------------------------
# Global asyncio lock — must be acquired before reading or writing state.
# ---------------------------------------------------------------------------
state_lock: asyncio.Lock = asyncio.Lock()
# ---------------------------------------------------------------------------
# Factory functions
# ---------------------------------------------------------------------------
def empty_state() -> dict:
"""Return a fresh, empty top-level state dict.
Every call returns a fully independent object — no shared mutables.
"""
return {
"active_session": None,
"active_remote_id": None,
"session_order": [],
"sessions": {},
"devices": {},
}
def empty_bell() -> dict:
"""Return a fresh bell sub-dict with all fields reset."""
return {
"last_fired_at": None,
"seen_at": None,
"unseen_count": 0,
}
def empty_device(device_id: str, label: str) -> dict: # noqa: ARG001
"""Return a fresh device sub-dict.
Args:
device_id: Identifier for the device (unused in the dict itself,
kept as a parameter for call-site clarity).
label: Human-readable name for the device.
"""
now = time.time()
return {
"label": label,
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": now,
"last_heartbeat_at": now,
}
# ---------------------------------------------------------------------------
# Device helpers
# ---------------------------------------------------------------------------
def register_device(
state: dict,
device_id: str,
label: str,
viewing_session: str | None,
view_mode: str,
last_interaction_at: float,
) -> None:
"""Create or update a device entry in state['devices'].
For new devices, seeds the entry via empty_device().
Always refreshes last_heartbeat_at to time.time().
Updates label, viewing_session, view_mode, last_interaction_at.
"""
if device_id not in state["devices"]:
state["devices"][device_id] = empty_device(device_id, label)
device = state["devices"][device_id]
device["label"] = label
device["viewing_session"] = viewing_session
device["view_mode"] = view_mode
device["last_interaction_at"] = last_interaction_at
device["last_heartbeat_at"] = time.time()
def prune_devices(state: dict, ttl_seconds: float = 300.0) -> list[str]:
"""Remove devices whose last_heartbeat_at is older than ttl_seconds.
Returns the list of removed device IDs.
"""
cutoff = time.time() - ttl_seconds
stale = [
device_id
for device_id, device in state["devices"].items()
if device["last_heartbeat_at"] < cutoff
]
for device_id in stale:
del state["devices"][device_id]
return stale
# ---------------------------------------------------------------------------
# Sync I/O helpers (no lock — callers must hold state_lock when appropriate)
# ---------------------------------------------------------------------------
def load_state() -> dict:
"""Read and return state from STATE_PATH.
Returns empty_state() if the file does not exist or contains invalid JSON.
"""
try:
with open(STATE_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return empty_state()
def save_state(state: dict) -> None:
"""Atomically write *state* to STATE_PATH.
Uses the write-to-tmp-then-os.replace pattern so readers never see a
partial file. Creates STATE_DIR (and parents) if it does not exist.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = Path(str(STATE_PATH) + ".tmp")
tmp.write_text(json.dumps(state, indent=2))
os.replace(tmp, STATE_PATH)
# ---------------------------------------------------------------------------
# Async wrappers — acquire state_lock before touching the file
# ---------------------------------------------------------------------------
async def read_state() -> dict:
"""Async read: acquires state_lock, then delegates to load_state()."""
async with state_lock:
return load_state()
async def write_state(state: dict) -> None:
"""Async write: acquires state_lock, then delegates to save_state()."""
async with state_lock:
save_state(state)