ab5560a623
- BUG 1: Replace isinstance(active_remote_id, int) guard with _lookup_remote_by_device_id() so bell-clear fires correctly when active_remote_id is a UUID string (new format). Old code silently skipped the POST for all non-integer active_remote_id values. Regression test: test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uuid - GAP 4: Add _resolveActiveView(activeView, views) helper in app.js. Falls back to 'all' when active_view references a deleted/unknown view name. Ready for Phase 2 view-switching code to call at read time. - ISSUE 6: _createDeviceSelect now uses remotes[i].device_id || String(i) for option values instead of always String(i), so session creation routes receive correct device_id. - ISSUE 9: identity.json written with indent=2 + trailing newline, consistent with settings.json and state.json formatting conventions. - ISSUE 11: Fix state.py module docstring — 'tmux-web muxplex' → 'muxplex'. - ISSUE 12: Settings panel hidden_sessions checkboxes now use s.sessionKey || s.name as the checkbox value (and checked state) so remote sessions are stored in device_id:name format, consistent with getVisibleSessions() lookups.
193 lines
5.7 KiB
Python
193 lines
5.7 KiB
Python
"""
|
|
State schema and factory functions for muxplex.
|
|
|
|
State schema (all values are plain JSON-serialisable dicts):
|
|
|
|
{
|
|
"active_session": str | None,
|
|
"active_remote_id": str | None,
|
|
"active_view": str, # 'all' | 'hidden' | view name
|
|
"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" / "muxplex"
|
|
STATE_DIR: Path = Path(
|
|
os.environ.get(
|
|
"MUXPLEX_STATE_DIR", 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,
|
|
"active_view": "all",
|
|
"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)
|