merge
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
Key = TypeVar("Key")
|
||||
Value = TypeVar("Value")
|
||||
|
||||
|
||||
class TwoWayDict(Generic[Key, Value]):
|
||||
"""
|
||||
A two-way mapping offering O(1) access in both directions.
|
||||
|
||||
Wraps two dictionaries and uses them to provide efficient access to
|
||||
both values (given keys) and keys (given values).
|
||||
"""
|
||||
|
||||
def __init__(self, initial: dict[Key, Value] | None = None) -> None:
|
||||
initial_data = {} if initial is None else initial
|
||||
self._forward: dict[Key, Value] = initial_data
|
||||
self._reverse: dict[Value, Key] = {value: key for key, value in initial_data.items()}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __setitem__(self, key: Key, value: Value) -> None:
|
||||
with self._lock:
|
||||
# If reassigning the same key, remove old reverse mapping first
|
||||
old_value = self._forward.get(key)
|
||||
if old_value is not None and old_value != value:
|
||||
del self._reverse[old_value]
|
||||
# Enforce 1:1 mapping: value must not already map to a different key
|
||||
existing_key = self._reverse.get(value)
|
||||
if existing_key is not None and existing_key != key:
|
||||
raise ValueError(f"Value {value!r} already mapped to key {existing_key!r}")
|
||||
self._forward[key] = value
|
||||
self._reverse[value] = key
|
||||
|
||||
def __delitem__(self, key: Key) -> None:
|
||||
with self._lock:
|
||||
value = self._forward[key]
|
||||
self._forward.__delitem__(key)
|
||||
self._reverse.__delitem__(value)
|
||||
|
||||
def __iter__(self):
|
||||
with self._lock:
|
||||
return iter(dict(self._forward))
|
||||
|
||||
def get(self, key: Key) -> Value | None:
|
||||
"""Given a key, efficiently lookup and return the associated value.
|
||||
|
||||
Args:
|
||||
key: The key
|
||||
|
||||
Returns:
|
||||
The value
|
||||
"""
|
||||
with self._lock:
|
||||
return self._forward.get(key)
|
||||
|
||||
def get_key(self, value: Value) -> Key | None:
|
||||
"""Given a value, efficiently lookup and return the associated key.
|
||||
|
||||
Args:
|
||||
value: The value
|
||||
|
||||
Returns:
|
||||
The key
|
||||
"""
|
||||
with self._lock:
|
||||
return self._reverse.get(value)
|
||||
|
||||
def contains_value(self, value: Value) -> bool:
|
||||
"""Check if `value` is a value within this TwoWayDict.
|
||||
|
||||
Args:
|
||||
value: The value to check.
|
||||
|
||||
Returns:
|
||||
True if the value is within the values of this dict.
|
||||
"""
|
||||
with self._lock:
|
||||
return value in self._reverse
|
||||
|
||||
def __len__(self):
|
||||
with self._lock:
|
||||
return len(self._forward)
|
||||
|
||||
def __contains__(self, item: Key) -> bool:
|
||||
with self._lock:
|
||||
return item in self._forward
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from asyncio import IncompleteReadError, StreamReader, StreamWriter
|
||||
from datetime import timedelta
|
||||
from enum import Enum, auto
|
||||
from time import monotonic
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import rich.repr
|
||||
from importlib_metadata import version
|
||||
|
||||
from . import constants
|
||||
from .session import Session, SessionConnector
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
from pathlib import Path
|
||||
|
||||
from .types import Meta, SessionID
|
||||
|
||||
log = logging.getLogger("textual-web")
|
||||
|
||||
# Maximum payload size to prevent memory exhaustion (16MB)
|
||||
MAX_PAYLOAD_SIZE = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class ProcessState(Enum):
|
||||
"""The state of a process."""
|
||||
|
||||
PENDING = auto()
|
||||
RUNNING = auto()
|
||||
CLOSING = auto()
|
||||
CLOSED = auto()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
class AppSession(Session):
|
||||
"""Runs a single app process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
working_directory: Path,
|
||||
command: str,
|
||||
session_id: SessionID,
|
||||
devtools: bool = False,
|
||||
) -> None:
|
||||
self.working_directory = working_directory
|
||||
self.command = command
|
||||
self.session_id = session_id
|
||||
self.devtools = devtools
|
||||
self.start_time: float | None = None
|
||||
self.end_time: float | None = None
|
||||
self._process: Process | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
super().__init__()
|
||||
self._state = ProcessState.PENDING
|
||||
|
||||
@property
|
||||
def process(self) -> Process:
|
||||
"""The asyncio (sub)process"""
|
||||
assert self._process is not None
|
||||
return self._process
|
||||
|
||||
@property
|
||||
def stdin(self) -> StreamWriter:
|
||||
"""The processes stdin."""
|
||||
assert self._process is not None
|
||||
assert self._process.stdin is not None
|
||||
return self._process.stdin
|
||||
|
||||
@property
|
||||
def stdout(self) -> StreamReader:
|
||||
"""The process' stdout."""
|
||||
assert self._process is not None
|
||||
assert self._process.stdout is not None
|
||||
return self._process.stdout
|
||||
|
||||
@property
|
||||
def stderr(self) -> StreamReader:
|
||||
"""The process' stderr."""
|
||||
assert self._process is not None
|
||||
assert self._process.stderr is not None
|
||||
return self._process.stderr
|
||||
|
||||
@property
|
||||
def task(self) -> asyncio.Task:
|
||||
"""Session task."""
|
||||
assert self._task is not None
|
||||
return self._task
|
||||
|
||||
@property
|
||||
def state(self) -> ProcessState:
|
||||
"""Current running state."""
|
||||
return self._state
|
||||
|
||||
@state.setter
|
||||
def state(self, state: ProcessState) -> None:
|
||||
self._state = state
|
||||
run_time = self.run_time
|
||||
log.debug(
|
||||
"%r state=%r run_time=%s",
|
||||
self,
|
||||
self.state,
|
||||
"0" if run_time is None else timedelta(seconds=int(run_time)),
|
||||
)
|
||||
|
||||
@property
|
||||
def run_time(self) -> float | None:
|
||||
"""Time process was running, or `None` if it hasn't started."""
|
||||
if self.end_time is not None:
|
||||
assert self.start_time is not None
|
||||
return self.end_time - self.start_time
|
||||
elif self.start_time is not None:
|
||||
return monotonic() - self.start_time
|
||||
else:
|
||||
return None
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the app session is still running."""
|
||||
return self._state == ProcessState.RUNNING
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.command
|
||||
yield "id", self.session_id
|
||||
if self._process is not None:
|
||||
yield "returncode", self._process.returncode, None
|
||||
|
||||
async def open(self, width: int = 80, height: int = 24) -> None:
|
||||
"""Open the process."""
|
||||
environment = dict(os.environ.copy())
|
||||
environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver"
|
||||
environment["TEXTUAL_FPS"] = "60"
|
||||
environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor"
|
||||
environment["TERM_PROGRAM"] = "textual-web"
|
||||
environment["TERM_PROGRAM_VERSION"] = version("textual-web")
|
||||
environment["COLUMNS"] = str(width)
|
||||
environment["ROWS"] = str(height)
|
||||
if self.devtools:
|
||||
environment["TEXTUAL"] = "debug,devtools"
|
||||
environment["TEXTUAL_LOG"] = "textual.log"
|
||||
|
||||
# Use cwd parameter instead of os.chdir() for thread safety
|
||||
self._process = await asyncio.create_subprocess_shell(
|
||||
self.command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=environment,
|
||||
cwd=str(self.working_directory),
|
||||
)
|
||||
await self.set_terminal_size(width, height)
|
||||
log.debug("opened %r; %r", self.command, self._process)
|
||||
self.start_time = monotonic()
|
||||
|
||||
async def start(self, connector: SessionConnector) -> asyncio.Task:
|
||||
"""Start a task to run the process."""
|
||||
if self._task is not None:
|
||||
raise RuntimeError("AppSession.start() called while already running")
|
||||
self._connector = connector
|
||||
self._task = asyncio.create_task(self.run())
|
||||
return self._task
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the process."""
|
||||
self.state = ProcessState.CLOSING
|
||||
await self.send_meta({"type": "quit"})
|
||||
|
||||
async def wait(self) -> None:
|
||||
"""Wait for the process to finish (call close first)."""
|
||||
if self._task:
|
||||
await self._task
|
||||
self._task = None
|
||||
|
||||
async def set_terminal_size(self, width: int, height: int) -> None:
|
||||
"""Set the terminal size for the process.
|
||||
|
||||
Args:
|
||||
width: Width in cells.
|
||||
height: Height in cells.
|
||||
"""
|
||||
await self.send_meta(
|
||||
{
|
||||
"type": "resize",
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""This loop reads stdout from the process and relays it through the websocket."""
|
||||
|
||||
self.state = ProcessState.RUNNING
|
||||
|
||||
META = b"M"
|
||||
DATA = b"D"
|
||||
BINARY_ENCODED = b"P"
|
||||
|
||||
stderr_data = io.BytesIO()
|
||||
|
||||
async def read_stderr() -> None:
|
||||
"""Task to read stderr."""
|
||||
try:
|
||||
while True:
|
||||
data = await self.stderr.read(1024 * 4)
|
||||
if not data:
|
||||
break
|
||||
stderr_data.write(data)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
stderr_task = asyncio.create_task(read_stderr())
|
||||
readexactly = self.stdout.readexactly
|
||||
from_bytes = int.from_bytes
|
||||
|
||||
on_data = self._connector.on_data
|
||||
on_meta = self._connector.on_meta
|
||||
on_binary_encoded_message = self._connector.on_binary_encoded_message
|
||||
try:
|
||||
ready = False
|
||||
for _ in range(10):
|
||||
line = await self.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
if line == b"__GANGLION__\n":
|
||||
ready = True
|
||||
break
|
||||
if ready:
|
||||
while True:
|
||||
type_bytes = await readexactly(1)
|
||||
size_bytes = await readexactly(4)
|
||||
size = from_bytes(size_bytes, "big")
|
||||
if size > MAX_PAYLOAD_SIZE:
|
||||
log.error("Payload size %d exceeds limit %d", size, MAX_PAYLOAD_SIZE)
|
||||
break
|
||||
payload = await readexactly(size)
|
||||
if type_bytes == DATA:
|
||||
await on_data(payload)
|
||||
elif type_bytes == META:
|
||||
meta_data = json.loads(payload)
|
||||
meta_type = meta_data.get("type")
|
||||
if meta_type in {"exit", "blur", "focus"}:
|
||||
await self.send_meta({"type": meta_type})
|
||||
else:
|
||||
await on_meta(meta_data)
|
||||
elif type_bytes == BINARY_ENCODED:
|
||||
await on_binary_encoded_message(payload)
|
||||
|
||||
except IncompleteReadError:
|
||||
# Incomplete read means that the stream was closed
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
stderr_task.cancel()
|
||||
await stderr_task
|
||||
|
||||
self.end_time = monotonic()
|
||||
self.state = ProcessState.CLOSED
|
||||
|
||||
stderr_message = stderr_data.getvalue().decode("utf-8", errors="replace")
|
||||
if (
|
||||
self._process is not None
|
||||
and self._process.returncode != 0
|
||||
and constants.DEBUG
|
||||
and stderr_message
|
||||
):
|
||||
log.warning(stderr_message)
|
||||
|
||||
await self._connector.on_close()
|
||||
|
||||
@classmethod
|
||||
def encode_packet(cls, packet_type: bytes, payload: bytes) -> bytes:
|
||||
"""Encode a packet.
|
||||
|
||||
Args:
|
||||
packet_type: The packet type (b"D" for data or b"M" for meta)
|
||||
payload: The payload.
|
||||
|
||||
Returns:
|
||||
Data as bytes.
|
||||
"""
|
||||
return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload)
|
||||
|
||||
async def send_bytes(self, data: bytes) -> bool:
|
||||
"""Send bytes to process.
|
||||
|
||||
Args:
|
||||
data: Data to send.
|
||||
|
||||
Returns:
|
||||
True if the data was sent, otherwise False.
|
||||
"""
|
||||
if self._process is None or self._process.stdin is None:
|
||||
return False
|
||||
stdin = self._process.stdin
|
||||
try:
|
||||
stdin.write(self.encode_packet(b"D", data))
|
||||
await stdin.drain()
|
||||
except (RuntimeError, ConnectionResetError, BrokenPipeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def send_meta(self, data: Meta) -> bool:
|
||||
"""Send meta information to process.
|
||||
|
||||
Args:
|
||||
data: Meta dict to send.
|
||||
|
||||
Returns:
|
||||
True if the data was sent, otherwise False.
|
||||
"""
|
||||
if self._process is None or self._process.stdin is None:
|
||||
return False
|
||||
stdin = self._process.stdin
|
||||
data_bytes = json.dumps(data).encode("utf-8")
|
||||
try:
|
||||
stdin.write(self.encode_packet(b"M", data_bytes))
|
||||
await stdin.drain()
|
||||
except (RuntimeError, ConnectionResetError, BrokenPipeError):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from importlib_metadata import version
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from . import constants
|
||||
from .local_server import LocalServer
|
||||
|
||||
FORMAT = "%(message)s"
|
||||
logging.basicConfig(
|
||||
level="DEBUG" if constants.DEBUG else "INFO",
|
||||
format=FORMAT,
|
||||
datefmt="[%X]",
|
||||
handlers=[RichHandler(show_path=False)],
|
||||
)
|
||||
|
||||
log = logging.getLogger("textual-webterm")
|
||||
|
||||
|
||||
def _is_file_path(path: str) -> bool:
|
||||
"""Check if path looks like a file path (vs module path)."""
|
||||
return path.endswith(".py") or "/" in path or "\\" in path
|
||||
|
||||
|
||||
def parse_app_path(app_path: str) -> tuple[str, str]:
|
||||
"""Parse an app path like 'module.path:ClassName' or 'path/to/file.py:ClassName'.
|
||||
|
||||
Returns:
|
||||
Tuple of (module_or_file, class_name)
|
||||
"""
|
||||
if ":" not in app_path:
|
||||
raise click.BadParameter(
|
||||
f"Invalid app path '{app_path}'. Expected format: 'module.path:ClassName' or 'path/to/file.py:ClassName'"
|
||||
)
|
||||
|
||||
module_part, class_name = app_path.rsplit(":", 1)
|
||||
return module_part, class_name
|
||||
|
||||
|
||||
def load_app_class(app_path: str):
|
||||
"""Load a Textual App class from a module path.
|
||||
|
||||
Args:
|
||||
app_path: Path like 'module.path:ClassName' or 'path/to/file.py:ClassName'
|
||||
|
||||
Returns:
|
||||
The App class
|
||||
"""
|
||||
module_part, class_name = parse_app_path(app_path)
|
||||
|
||||
# Check if it's a file path or module path
|
||||
if _is_file_path(module_part):
|
||||
# File path - load from file
|
||||
file_path = Path(module_part).resolve()
|
||||
if not file_path.exists():
|
||||
raise click.BadParameter(f"File not found: {file_path}")
|
||||
|
||||
# Add parent directory to sys.path for imports
|
||||
parent_dir = str(file_path.parent)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
# Import the module
|
||||
module_name = file_path.stem
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise click.BadParameter(f"Could not load module from {file_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
else:
|
||||
# Module path - import normally
|
||||
try:
|
||||
module = importlib.import_module(module_part)
|
||||
except ImportError as e:
|
||||
raise click.BadParameter(f"Could not import module '{module_part}': {e}") from e
|
||||
|
||||
# Get the class
|
||||
if not hasattr(module, class_name):
|
||||
raise click.BadParameter(f"Module '{module_part}' has no attribute '{class_name}'")
|
||||
|
||||
app_class = getattr(module, class_name)
|
||||
return app_class
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.version_option(version("textual-webterm"))
|
||||
@click.argument("command", required=False)
|
||||
@click.option("--port", "-p", type=int, help="Port for server.", default=8080)
|
||||
@click.option("--host", "-H", help="Host for server.", default="0.0.0.0")
|
||||
@click.option(
|
||||
"--app",
|
||||
"-a",
|
||||
"app_path",
|
||||
help="Load a Textual app from module:ClassName (e.g., 'myapp:MyApp' or 'path/to/app.py:MyApp')",
|
||||
)
|
||||
@click.option(
|
||||
"--landing-manifest",
|
||||
"-L",
|
||||
"landing_manifest",
|
||||
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
|
||||
help="YAML manifest describing landing page tiles (slug/name/command).",
|
||||
)
|
||||
@click.option(
|
||||
"--compose-manifest",
|
||||
"-C",
|
||||
"compose_manifest",
|
||||
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
|
||||
help='Docker compose YAML; services with label "webterm-command" become landing tiles.',
|
||||
)
|
||||
def app(
|
||||
command: str | None,
|
||||
port: int,
|
||||
host: str,
|
||||
app_path: str | None,
|
||||
landing_manifest: Path | None,
|
||||
compose_manifest: Path | None,
|
||||
) -> None:
|
||||
"""Serve a terminal or Textual app over HTTP/WebSocket.
|
||||
|
||||
COMMAND: Shell command to run in terminal (default: $SHELL)
|
||||
|
||||
Examples:
|
||||
|
||||
\b
|
||||
textual-webterm # Serve default shell
|
||||
textual-webterm htop # Serve htop in terminal
|
||||
textual-webterm --app mymodule:MyApp # Serve a Textual app from module
|
||||
textual-webterm -a ./calculator.py:CalculatorApp # Serve from file
|
||||
"""
|
||||
VERSION = version("textual-webterm")
|
||||
log.info(f"textual-webterm v{VERSION}")
|
||||
|
||||
if constants.DEBUG:
|
||||
log.warning("DEBUG env var is set; logs may be verbose!")
|
||||
|
||||
from .config import default_config, load_compose_manifest, load_landing_yaml
|
||||
|
||||
_config = default_config()
|
||||
|
||||
landing_apps: list = []
|
||||
if landing_manifest:
|
||||
landing_apps = load_landing_yaml(landing_manifest)
|
||||
elif compose_manifest:
|
||||
landing_apps = load_compose_manifest(compose_manifest)
|
||||
|
||||
server = LocalServer(
|
||||
"./",
|
||||
_config,
|
||||
host=host,
|
||||
port=port,
|
||||
landing_apps=landing_apps,
|
||||
)
|
||||
for app_entry in landing_apps:
|
||||
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
|
||||
|
||||
if app_path:
|
||||
# Load and run as Textual app from module:class
|
||||
try:
|
||||
app_class = load_app_class(app_path)
|
||||
except click.BadParameter as e:
|
||||
log.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
# Create a command that runs the app using python -m runpy for safety
|
||||
module_part, class_name = parse_app_path(app_path)
|
||||
if _is_file_path(module_part):
|
||||
# File path - use absolute path and proper escaping
|
||||
file_path = Path(module_part).resolve()
|
||||
# Use runpy to safely run the file
|
||||
escaped_path = str(file_path).replace("'", "'\"'\"'")
|
||||
escaped_class = class_name.replace("'", "'\"'\"'")
|
||||
run_command = f'python3 -c \'import sys; sys.path.insert(0, "{file_path.parent}"); exec(open("{escaped_path}").read()); {escaped_class}().run()\''
|
||||
else:
|
||||
# Module path - validate module and class names
|
||||
if not module_part.replace(".", "").replace("_", "").isalnum():
|
||||
log.error(f"Invalid module path: {module_part}")
|
||||
sys.exit(1)
|
||||
if not class_name.isidentifier():
|
||||
log.error(f"Invalid class name: {class_name}")
|
||||
sys.exit(1)
|
||||
run_command = (
|
||||
f'python3 -c "from {module_part} import {class_name}; {class_name}().run()"'
|
||||
)
|
||||
|
||||
app_name = getattr(app_class, "TITLE", None) or class_name
|
||||
server.add_app(app_name, run_command, "")
|
||||
log.info(f"Serving Textual app: {app_path}")
|
||||
elif command:
|
||||
# Run command as terminal
|
||||
server.add_terminal("Terminal", command, "")
|
||||
log.info(f"Serving terminal: {command}")
|
||||
elif not landing_apps:
|
||||
# Run default shell
|
||||
terminal_command = os.environ.get("SHELL", "/bin/sh")
|
||||
server.add_terminal("Terminal", terminal_command, "")
|
||||
log.info(f"Serving terminal: {terminal_command}")
|
||||
|
||||
def _run_async():
|
||||
if constants.WINDOWS:
|
||||
asyncio.run(server.run())
|
||||
else:
|
||||
try:
|
||||
import uvloop
|
||||
except ImportError:
|
||||
asyncio.run(server.run())
|
||||
else:
|
||||
if sys.version_info >= (3, 11):
|
||||
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
|
||||
runner.run(server.run())
|
||||
else:
|
||||
uvloop.install()
|
||||
asyncio.run(server.run())
|
||||
|
||||
_run_async()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,150 @@
|
||||
from os.path import expandvars
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
try:
|
||||
import tomllib as tomli # py311+
|
||||
except ImportError: # pragma: no cover
|
||||
import tomli
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.functional_validators import AfterValidator
|
||||
|
||||
from .identity import generate
|
||||
from .slugify import slugify
|
||||
|
||||
ExpandVarsStr = Annotated[str, AfterValidator(expandvars)]
|
||||
|
||||
|
||||
class App(BaseModel):
|
||||
"""Defines an application."""
|
||||
|
||||
name: str
|
||||
slug: str = ""
|
||||
path: ExpandVarsStr = "./"
|
||||
color: str = ""
|
||||
command: ExpandVarsStr = ""
|
||||
terminal: bool = False
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Root configuration model."""
|
||||
|
||||
apps: list[App] = Field(default_factory=list)
|
||||
landing: list[App] = Field(default_factory=list)
|
||||
|
||||
|
||||
def default_config() -> Config:
|
||||
"""Get a default empty configuration.
|
||||
|
||||
Returns:
|
||||
Configuration object.
|
||||
"""
|
||||
return Config()
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> Config:
|
||||
"""Load config from a path.
|
||||
|
||||
Args:
|
||||
config_path: Path to TOML configuration.
|
||||
|
||||
Returns:
|
||||
Config object.
|
||||
"""
|
||||
with Path(config_path).open("rb") as config_file:
|
||||
config_data = tomli.load(config_file)
|
||||
|
||||
def make_app(name, data: dict[str, object], terminal: bool = False) -> App:
|
||||
data["name"] = name
|
||||
data["terminal"] = terminal
|
||||
if terminal:
|
||||
data["slug"] = generate().lower()
|
||||
elif not data.get("slug", ""):
|
||||
data["slug"] = slugify(name)
|
||||
|
||||
return App(**data)
|
||||
|
||||
apps = [make_app(name, app) for name, app in config_data.get("app", {}).items()]
|
||||
|
||||
apps += [
|
||||
make_app(name, app, terminal=True) for name, app in config_data.get("terminal", {}).items()
|
||||
]
|
||||
|
||||
config = Config(apps=apps)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def load_landing_yaml(manifest_path: Path) -> list[App]:
|
||||
"""Load landing apps from YAML manifest.
|
||||
|
||||
Expected schema: list of {name, slug, command, color?, path?, terminal?}
|
||||
"""
|
||||
with manifest_path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or []
|
||||
apps: list[App] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name")
|
||||
command = entry.get("command")
|
||||
if not name or not command:
|
||||
continue
|
||||
slug = entry.get("slug") or slugify(name)
|
||||
apps.append(
|
||||
App(
|
||||
name=name,
|
||||
slug=slug,
|
||||
command=command,
|
||||
path=entry.get("path", "./"),
|
||||
color=entry.get("color", ""),
|
||||
terminal=bool(entry.get("terminal", True)),
|
||||
)
|
||||
)
|
||||
return apps
|
||||
|
||||
|
||||
def _extract_label(labels: object, key: str) -> str | None:
|
||||
"""Extract a label value from either dict or list[str] forms."""
|
||||
if isinstance(labels, dict):
|
||||
value = labels.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
if isinstance(labels, list):
|
||||
for item in labels:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
if "=" in item:
|
||||
k, v = item.split("=", 1)
|
||||
if k == key:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def load_compose_manifest(manifest_path: Path) -> list[App]:
|
||||
"""Load landing apps from a docker-compose YAML file using label `webterm-command`."""
|
||||
with manifest_path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
services = data.get("services", {}) if isinstance(data, dict) else {}
|
||||
apps: list[App] = []
|
||||
for name, service in services.items():
|
||||
if not isinstance(service, dict):
|
||||
continue
|
||||
labels = service.get("labels", {})
|
||||
command = _extract_label(labels, "webterm-command")
|
||||
if not command:
|
||||
continue
|
||||
slug = slugify(name)
|
||||
apps.append(
|
||||
App(
|
||||
name=name,
|
||||
slug=slug,
|
||||
command=command,
|
||||
path=service.get("working_dir", "./"),
|
||||
color="",
|
||||
terminal=True,
|
||||
)
|
||||
)
|
||||
return apps
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Constants that we might want to expose via the public API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
from typing import Final
|
||||
|
||||
get_environ = os.environ.get
|
||||
|
||||
WINDOWS: Final = platform.system() == "Windows"
|
||||
"""True if running on Windows."""
|
||||
|
||||
|
||||
def get_environ_bool(name: str) -> bool:
|
||||
"""Check an environment variable switch.
|
||||
|
||||
Args:
|
||||
name: Name of environment variable.
|
||||
|
||||
Returns:
|
||||
`True` if the env var is "1", otherwise `False`.
|
||||
"""
|
||||
has_environ = get_environ(name) == "1"
|
||||
return has_environ
|
||||
|
||||
|
||||
def get_environ_int(name: str, default: int) -> int:
|
||||
"""Retrieves an integer environment variable.
|
||||
|
||||
Args:
|
||||
name: Name of environment variable.
|
||||
default: The value to use if the value is not set, or set to something other
|
||||
than a valid integer.
|
||||
|
||||
Returns:
|
||||
The integer associated with the environment variable if it's set to a valid int
|
||||
or the default value otherwise.
|
||||
"""
|
||||
try:
|
||||
return int(os.environ[name])
|
||||
except KeyError:
|
||||
return default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
DEBUG: Final = get_environ_bool("DEBUG")
|
||||
"""Enable debug mode."""
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from time import monotonic
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
EXIT_POLL_RATE = 5
|
||||
|
||||
log = logging.getLogger("textual-web")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .local_server import LocalServer
|
||||
|
||||
|
||||
class ExitPoller:
|
||||
"""Monitors the client for an idle state, and exits."""
|
||||
|
||||
def __init__(self, client: LocalServer, idle_wait: int) -> None:
|
||||
self.client = client
|
||||
self.idle_wait = idle_wait
|
||||
self._task: asyncio.Task | None = None
|
||||
self._idle_start_time: float | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start polling."""
|
||||
self._task = asyncio.create_task(self.run())
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop polling"""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the poller."""
|
||||
if not self.idle_wait:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(EXIT_POLL_RATE)
|
||||
is_idle = not self.client.session_manager.sessions
|
||||
if is_idle:
|
||||
if self._idle_start_time is not None:
|
||||
if monotonic() - self._idle_start_time > self.idle_wait:
|
||||
log.info("Exiting due to --exit-on-idle")
|
||||
self.client.force_exit()
|
||||
else:
|
||||
self._idle_start_time = monotonic()
|
||||
else:
|
||||
self._idle_start_time = None
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
SEPARATOR = "-"
|
||||
IDENTITY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTUVWYZ"
|
||||
IDENTITY_SIZE = 12
|
||||
|
||||
|
||||
def generate(size: int = IDENTITY_SIZE) -> str:
|
||||
"""Generate a random identifier."""
|
||||
alphabet = IDENTITY_ALPHABET
|
||||
return "".join(alphabet[byte % 31] for byte in os.urandom(size))
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Local server implementation for serving terminals over HTTP/WebSocket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import WSMsgType, web
|
||||
from rich.ansi import AnsiDecoder
|
||||
from rich.console import Console
|
||||
|
||||
from . import constants
|
||||
from .exit_poller import ExitPoller
|
||||
from .identity import generate
|
||||
from .poller import Poller
|
||||
from .session import SessionConnector
|
||||
from .session_manager import SessionManager
|
||||
from .types import Meta, RouteKey, SessionID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger("textual-web")
|
||||
|
||||
DISCONNECT_RESIZE = (132, 45)
|
||||
|
||||
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def _get_static_path() -> Path | None:
|
||||
"""Get the path to static assets from textual-serve."""
|
||||
try:
|
||||
import textual_serve
|
||||
|
||||
static_path = Path(textual_serve.__file__).parent / "static"
|
||||
if static_path.exists():
|
||||
return static_path
|
||||
except ImportError:
|
||||
log.warning("textual-serve not installed - static assets unavailable")
|
||||
return None
|
||||
|
||||
|
||||
STATIC_PATH = _get_static_path()
|
||||
|
||||
|
||||
class LocalClientConnector(SessionConnector):
|
||||
"""Local connector that handles communication between sessions and local server."""
|
||||
|
||||
def __init__(self, server: LocalServer, session_id: SessionID, route_key: RouteKey) -> None:
|
||||
self.server = server
|
||||
self.session_id = session_id
|
||||
self.route_key = route_key
|
||||
|
||||
async def on_data(self, data: bytes) -> None:
|
||||
await self.server.handle_session_data(self.route_key, data)
|
||||
|
||||
async def on_meta(self, meta: Meta) -> None:
|
||||
meta_type = meta.get("type")
|
||||
if meta_type == "open_url":
|
||||
log.info("App requested to open URL: %s", meta.get("url"))
|
||||
elif meta_type == "deliver_file_start":
|
||||
log.info("App requested file delivery: %s", meta.get("path"))
|
||||
else:
|
||||
log.debug("Unknown meta type: %r. Full meta: %r", meta_type, meta)
|
||||
|
||||
async def on_binary_encoded_message(self, payload: bytes) -> None:
|
||||
await self.server.handle_binary_message(self.route_key, payload)
|
||||
|
||||
async def on_close(self) -> None:
|
||||
await self.server.handle_session_close(self.session_id, self.route_key)
|
||||
|
||||
|
||||
class LocalServer:
|
||||
"""Manages local Textual apps and terminals without Ganglion server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config: Config,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
exit_on_idle: int = 0,
|
||||
landing_apps: list | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
abs_path = Path(config_path).absolute()
|
||||
path = abs_path if abs_path.is_dir() else abs_path.parent
|
||||
self.config = config
|
||||
self._websocket_server: aiohttp.web.WebSocketResponse | None = None
|
||||
self._poller = Poller()
|
||||
self.session_manager = SessionManager(self._poller, path, config.apps)
|
||||
self.exit_event = asyncio.Event()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._shutdown_task: asyncio.Task | None = None
|
||||
self._shutdown_started = False
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._exit_poller = ExitPoller(self, idle_wait=exit_on_idle)
|
||||
|
||||
self._websocket_connections: dict[RouteKey, web.WebSocketResponse] = {}
|
||||
self._landing_apps = landing_apps or []
|
||||
|
||||
@property
|
||||
def app_count(self) -> int:
|
||||
return len(self.session_manager.apps)
|
||||
|
||||
def add_app(self, name: str, command: str, slug: str = "") -> None:
|
||||
slug = slug or generate().lower()
|
||||
self.session_manager.add_app(name, command, slug=slug)
|
||||
|
||||
def add_terminal(self, name: str, command: str, slug: str = "") -> None:
|
||||
if constants.WINDOWS:
|
||||
log.warning("Sorry, textual-web does not currently support terminals on Windows")
|
||||
return
|
||||
slug = slug or generate().lower()
|
||||
self.session_manager.add_app(name, command, slug=slug, terminal=True)
|
||||
|
||||
async def run(self) -> None:
|
||||
try:
|
||||
await self._run()
|
||||
finally:
|
||||
self._exit_poller.stop()
|
||||
if not constants.WINDOWS:
|
||||
with contextlib.suppress(Exception):
|
||||
self._poller.exit()
|
||||
|
||||
def on_keyboard_interrupt(self) -> None:
|
||||
print("\r\033[F")
|
||||
log.info("Exit requested")
|
||||
|
||||
if self._shutdown_started:
|
||||
self.exit_event.set()
|
||||
return
|
||||
self._shutdown_started = True
|
||||
|
||||
# Ensure we shut down sessions and websockets before stopping the server.
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop is not None:
|
||||
if self._shutdown_task is None or self._shutdown_task.done():
|
||||
self._shutdown_task = asyncio.create_task(self._shutdown())
|
||||
return
|
||||
|
||||
if self._loop is not None and self._loop.is_running():
|
||||
if self._shutdown_task is None or self._shutdown_task.done():
|
||||
|
||||
def _schedule() -> None:
|
||||
self._shutdown_task = asyncio.create_task(self._shutdown())
|
||||
|
||||
self._loop.call_soon_threadsafe(_schedule)
|
||||
return
|
||||
|
||||
self.exit_event.set()
|
||||
|
||||
async def _run(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._loop = loop
|
||||
|
||||
if constants.WINDOWS:
|
||||
|
||||
def exit_handler(_sig, _frame) -> None:
|
||||
self.on_keyboard_interrupt()
|
||||
|
||||
signal.signal(signal.SIGINT, exit_handler)
|
||||
else:
|
||||
loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interrupt)
|
||||
self._poller.set_loop(loop)
|
||||
self._poller.start()
|
||||
|
||||
self._task = asyncio.create_task(self._run_local_server())
|
||||
self._exit_poller.start()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
|
||||
def _build_routes(self) -> list[web.AbstractRouteDef]:
|
||||
routes: list[web.AbstractRouteDef] = [
|
||||
web.get("/ws/{route_key}", self._handle_websocket),
|
||||
web.get("/screenshot.svg", self._handle_screenshot),
|
||||
web.get("/health", self._handle_health_check),
|
||||
web.get("/", self._handle_root),
|
||||
]
|
||||
|
||||
if STATIC_PATH is not None and STATIC_PATH.exists():
|
||||
routes.append(web.static("/static", STATIC_PATH))
|
||||
log.info("Static assets served from: %s", STATIC_PATH)
|
||||
else:
|
||||
log.error("Static assets not found at %s - terminal UI will not work", STATIC_PATH)
|
||||
|
||||
if WEBTERM_STATIC_PATH.exists():
|
||||
routes.append(web.static("/static-webterm", WEBTERM_STATIC_PATH))
|
||||
|
||||
return routes
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
try:
|
||||
for ws in list(self._websocket_connections.values()):
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.close()
|
||||
await self.session_manager.close_all()
|
||||
finally:
|
||||
self.exit_event.set()
|
||||
|
||||
async def _run_local_server(self) -> None:
|
||||
app = web.Application()
|
||||
app.add_routes(self._build_routes())
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
try:
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, self.host, self.port)
|
||||
await site.start()
|
||||
|
||||
log.info("Local server started on %s:%s", self.host, self.port)
|
||||
log.info("Available apps: %s", ", ".join(app.name for app in self.session_manager.apps))
|
||||
|
||||
await self.exit_event.wait()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
async def _dispatch_ws_message(
|
||||
self,
|
||||
envelope: list,
|
||||
route_key: str,
|
||||
ws: web.WebSocketResponse,
|
||||
session_created: bool,
|
||||
) -> bool:
|
||||
msg_type = envelope[0]
|
||||
|
||||
if msg_type == "stdin":
|
||||
data = envelope[1] if len(envelope) > 1 else ""
|
||||
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
||||
if session_process:
|
||||
await session_process.send_bytes(data.encode("utf-8"))
|
||||
|
||||
elif msg_type == "resize":
|
||||
size_data = envelope[1] if len(envelope) > 1 else {}
|
||||
width = max(1, min(500, int(size_data.get("width", 80))))
|
||||
height = max(1, min(500, int(size_data.get("height", 24))))
|
||||
|
||||
if not session_created:
|
||||
await self._create_terminal_session(route_key, width, height)
|
||||
session_created = True
|
||||
else:
|
||||
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
||||
if session_process:
|
||||
await session_process.set_terminal_size(width, height)
|
||||
|
||||
elif msg_type == "ping":
|
||||
data = envelope[1] if len(envelope) > 1 else ""
|
||||
await ws.send_json(["pong", data])
|
||||
|
||||
return session_created
|
||||
|
||||
async def _resize_on_disconnect(self, route_key: str) -> None:
|
||||
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
||||
if session_process is None or not hasattr(session_process, "set_terminal_size"):
|
||||
return
|
||||
width, height = DISCONNECT_RESIZE
|
||||
with contextlib.suppress(OSError):
|
||||
await session_process.set_terminal_size(width, height)
|
||||
|
||||
async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
|
||||
route_key = request.match_info["route_key"]
|
||||
ws = web.WebSocketResponse(heartbeat=30.0, max_msg_size=64 * 1024)
|
||||
await ws.prepare(request)
|
||||
|
||||
log.info("WebSocket connection established for route %s", route_key)
|
||||
self._websocket_connections[route_key] = ws
|
||||
|
||||
session_id = self.session_manager.routes.get(RouteKey(route_key))
|
||||
if session_id is not None:
|
||||
session = self.session_manager.get_session(session_id)
|
||||
if session is None or not session.is_running():
|
||||
self.session_manager.on_session_end(session_id)
|
||||
session_id = None
|
||||
|
||||
session_created = session_id is not None
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
try:
|
||||
envelope = json.loads(msg.data)
|
||||
if not isinstance(envelope, list) or len(envelope) < 1:
|
||||
continue
|
||||
session_created = await self._dispatch_ws_message(
|
||||
envelope, route_key, ws, session_created
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Error processing WebSocket message: %s", e)
|
||||
elif msg.type == WSMsgType.ERROR:
|
||||
log.error("WebSocket connection error for route %s", route_key)
|
||||
break
|
||||
finally:
|
||||
log.info("WebSocket connection closed for route %s", route_key)
|
||||
self._websocket_connections.pop(route_key, None)
|
||||
await self._resize_on_disconnect(route_key)
|
||||
|
||||
return ws
|
||||
|
||||
def _select_app_for_route(self, route_key: str):
|
||||
"""Pick the app matching the route key, or fall back to default."""
|
||||
app = self.session_manager.apps_by_slug.get(route_key)
|
||||
return app or self.session_manager.get_default_app()
|
||||
|
||||
async def _create_terminal_session(self, route_key: str, width: int, height: int) -> None:
|
||||
available_app = self._select_app_for_route(route_key)
|
||||
if available_app is None:
|
||||
log.error("No app available for route %s", route_key)
|
||||
ws = self._websocket_connections.get(route_key)
|
||||
if ws:
|
||||
await ws.send_json(["error", "No app configured"])
|
||||
return
|
||||
|
||||
session_id = SessionID(generate())
|
||||
log.info(
|
||||
"Creating %s session %s for route %s (%sx%s)",
|
||||
"terminal" if available_app.terminal else "app",
|
||||
session_id,
|
||||
route_key,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
session_process = await self.session_manager.new_session(
|
||||
available_app.slug,
|
||||
session_id,
|
||||
RouteKey(route_key),
|
||||
size=(width, height),
|
||||
)
|
||||
|
||||
if session_process is None:
|
||||
log.error("Failed to create session for route %s", route_key)
|
||||
ws = self._websocket_connections.get(route_key)
|
||||
if ws:
|
||||
await ws.send_json(["error", "Failed to create session"])
|
||||
return
|
||||
|
||||
connector = LocalClientConnector(self, session_id, RouteKey(route_key))
|
||||
await session_process.start(connector)
|
||||
|
||||
async def _handle_screenshot(self, request: web.Request) -> web.Response:
|
||||
route_key = request.query.get("route_key")
|
||||
if route_key is None:
|
||||
running = self.session_manager.get_first_running_session()
|
||||
if running:
|
||||
route_key = str(running[0])
|
||||
|
||||
if route_key is None:
|
||||
raise web.HTTPNotFound(text="No running session")
|
||||
|
||||
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
||||
if session_process is None and route_key in self.session_manager.apps_by_slug:
|
||||
await self._create_terminal_session(
|
||||
route_key,
|
||||
width=DISCONNECT_RESIZE[0],
|
||||
height=DISCONNECT_RESIZE[1],
|
||||
)
|
||||
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
||||
|
||||
if session_process is None or not hasattr(session_process, "get_replay_buffer"):
|
||||
raise web.HTTPNotFound(text="Session not found")
|
||||
|
||||
replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value]
|
||||
ansi_text = replay_data.decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
width = int(request.query.get("width", "120"))
|
||||
except ValueError:
|
||||
width = 120
|
||||
width = max(10, min(400, width))
|
||||
|
||||
try:
|
||||
height = int(request.query.get("height", str(DISCONNECT_RESIZE[1])))
|
||||
except ValueError:
|
||||
height = DISCONNECT_RESIZE[1]
|
||||
height = max(5, min(200, height))
|
||||
|
||||
lines = ansi_text.splitlines()
|
||||
if len(lines) > height:
|
||||
ansi_text = "\n".join(lines[-height:]) + "\n"
|
||||
|
||||
console = Console(record=True, width=width, height=height, file=io.StringIO())
|
||||
decoder = AnsiDecoder()
|
||||
for renderable in decoder.decode(ansi_text):
|
||||
console.print(renderable)
|
||||
|
||||
svg = console.export_svg(
|
||||
title="textual-webterm",
|
||||
code_format=(
|
||||
'<svg class="rich-terminal" viewBox="0 0 {terminal_width} {terminal_height}" '
|
||||
'xmlns="http://www.w3.org/2000/svg">'
|
||||
'<style>{styles}</style>'
|
||||
'<defs>'
|
||||
'<clipPath id="{unique_id}-clip-terminal">'
|
||||
'<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />'
|
||||
'</clipPath>'
|
||||
'{lines}'
|
||||
'</defs>'
|
||||
'<g clip-path="url(#{unique_id}-clip-terminal)">'
|
||||
'<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" fill="#000" />'
|
||||
'{backgrounds}'
|
||||
'<g class="{unique_id}-matrix">{matrix}</g>'
|
||||
'</g>'
|
||||
'</svg>'
|
||||
),
|
||||
)
|
||||
return web.Response(text=svg, content_type="image/svg+xml")
|
||||
|
||||
async def _handle_health_check(self, _request: web.Request) -> web.Response:
|
||||
return web.Response(text="Local server is running")
|
||||
|
||||
def _get_ws_url_from_request(self, request: web.Request, route_key: str) -> str:
|
||||
"""Build WebSocket URL honoring reverse proxies and port mapping."""
|
||||
|
||||
forwarded_proto = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower()
|
||||
forwarded_host = request.headers.get("X-Forwarded-Host", "").split(",")[0].strip()
|
||||
forwarded_port = request.headers.get("X-Forwarded-Port", "").split(",")[0].strip()
|
||||
|
||||
def _pick_proto() -> str:
|
||||
if forwarded_proto in ("https", "wss"):
|
||||
return "wss"
|
||||
if forwarded_proto in ("http", "ws"):
|
||||
return "ws"
|
||||
return "wss" if request.secure else "ws"
|
||||
|
||||
def _split_host_port(host: str) -> tuple[str, str]:
|
||||
if not host:
|
||||
return "", ""
|
||||
if ":" in host:
|
||||
return host.rsplit(":", 1)
|
||||
return host, ""
|
||||
|
||||
ws_proto = _pick_proto()
|
||||
ws_host, ws_port = _split_host_port(forwarded_host)
|
||||
|
||||
if not ws_host:
|
||||
host_header = request.headers.get("Host", "")
|
||||
ws_host, ws_port = _split_host_port(host_header)
|
||||
|
||||
if not ws_host:
|
||||
ws_host = "localhost" if self.host == "0.0.0.0" else self.host
|
||||
ws_port = str(self.port)
|
||||
|
||||
if not ws_port and forwarded_port:
|
||||
ws_port = forwarded_port
|
||||
|
||||
if ws_port and ws_port not in ("80", "443"):
|
||||
return f"{ws_proto}://{ws_host}:{ws_port}/ws/{route_key}"
|
||||
if not ws_port and self.port not in (80, 443):
|
||||
return f"{ws_proto}://{ws_host}:{self.port}/ws/{route_key}"
|
||||
return f"{ws_proto}://{ws_host}/ws/{route_key}"
|
||||
|
||||
async def _handle_root(self, request: web.Request) -> web.Response:
|
||||
route_key_param = request.query.get("route_key")
|
||||
|
||||
if self._landing_apps and not route_key_param:
|
||||
tiles = [
|
||||
{"slug": app.slug, "name": app.name, "command": app.command}
|
||||
for app in self._landing_apps
|
||||
]
|
||||
tiles_json = json.dumps(tiles)
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Textual WebTerm Dashboard</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 16px; background: #0f172a; color: #e2e8f0; }}
|
||||
h1 {{ margin-bottom: 8px; }}
|
||||
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }}
|
||||
.tile {{ background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.4); }}
|
||||
.tile-header {{ padding: 10px 12px; font-weight: bold; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 8px; }}
|
||||
.tile-body {{ padding: 0; }}
|
||||
.thumb {{ width: 100%; height: 180px; object-fit: contain; background: #0b1220; display: block; }}
|
||||
.meta {{ padding: 8px 12px; color: #94a3b8; font-size: 12px; }}
|
||||
a {{ color: inherit; text-decoration: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sessions</h1>
|
||||
<div class=\"grid\" id=\"grid\"></div>
|
||||
<script>
|
||||
const tiles = {tiles_json};
|
||||
function makeTile(tile) {{
|
||||
const card = document.createElement('div');
|
||||
card.className = 'tile';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tile-header';
|
||||
header.innerHTML = `<span>${{tile.name}}</span>`;
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-body';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.alt = tile.name;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
meta.innerText = tile.command;
|
||||
body.appendChild(img);
|
||||
card.appendChild(header);
|
||||
card.appendChild(body);
|
||||
card.appendChild(meta);
|
||||
card.onclick = () => {{
|
||||
window.open(`/?route_key=${{encodeURIComponent(tile.slug)}}`, '_blank');
|
||||
}};
|
||||
card.img = img;
|
||||
return card;
|
||||
}}
|
||||
const grid = document.getElementById('grid');
|
||||
const cards = tiles.map(makeTile);
|
||||
cards.forEach(c => grid.appendChild(c));
|
||||
async function refresh() {{
|
||||
for (const card of cards) {{
|
||||
const tile = tiles[cards.indexOf(card)];
|
||||
const url = `/screenshot.svg?route_key=${{encodeURIComponent(tile.slug)}}&t=${{Date.now()}}`;
|
||||
card.img.src = url;
|
||||
}}
|
||||
}}
|
||||
|
||||
let refreshTimer = null;
|
||||
function startRefresh() {{
|
||||
if (refreshTimer !== null) return;
|
||||
refresh();
|
||||
refreshTimer = setInterval(refresh, 15000);
|
||||
}}
|
||||
function stopRefresh() {{
|
||||
if (refreshTimer === null) return;
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {{
|
||||
if (document.hidden) stopRefresh();
|
||||
else startRefresh();
|
||||
}});
|
||||
|
||||
if (!document.hidden) startRefresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
available_app = None
|
||||
if route_key_param:
|
||||
available_app = self.session_manager.apps_by_slug.get(route_key_param)
|
||||
if available_app is None:
|
||||
available_app = self.session_manager.get_default_app()
|
||||
if available_app is None:
|
||||
html_content = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Textual Web Terminal Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>No Apps Available</h2>
|
||||
<p>No terminal or Textual applications are configured.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
route_key: RouteKey | None = None
|
||||
if route_key_param:
|
||||
route_key = RouteKey(route_key_param)
|
||||
else:
|
||||
running = self.session_manager.get_first_running_session()
|
||||
if running:
|
||||
route_key = running[0]
|
||||
|
||||
if route_key is None:
|
||||
route_key = RouteKey(generate().lower())
|
||||
|
||||
ws_url = self._get_ws_url_from_request(request, route_key)
|
||||
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Textual Web Terminal</title>
|
||||
<link rel=\"stylesheet\" href=\"/static/css/xterm.css\">
|
||||
<link rel=\"stylesheet\" href=\"/static-webterm/monospace.css\">
|
||||
<script src=\"/static/js/textual.js\"></script>
|
||||
<style>
|
||||
body {{ background: #000; margin: 0; padding: 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=\"textual-terminal\" data-session-websocket-url={ws_url!r} data-font-size=\"16\"></div>
|
||||
</body>
|
||||
</html>"""
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
async def handle_session_data(self, route_key: RouteKey, data: bytes) -> None:
|
||||
ws = self._websocket_connections.get(route_key)
|
||||
if ws is None:
|
||||
return
|
||||
await ws.send_bytes(data)
|
||||
|
||||
async def handle_binary_message(self, route_key: RouteKey, payload: bytes) -> None:
|
||||
ws = self._websocket_connections.get(route_key)
|
||||
if ws is None:
|
||||
return
|
||||
await ws.send_bytes(payload)
|
||||
|
||||
async def handle_session_close(self, session_id: SessionID, route_key: RouteKey) -> None:
|
||||
self.session_manager.on_session_end(session_id)
|
||||
ws = self._websocket_connections.get(route_key)
|
||||
if ws is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.close()
|
||||
|
||||
def force_exit(self) -> None:
|
||||
self.exit_event.set()
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import selectors
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Event, Thread
|
||||
|
||||
|
||||
@dataclass
|
||||
class Write:
|
||||
"""Data in a write queue."""
|
||||
|
||||
data: bytes
|
||||
position: int = 0
|
||||
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
|
||||
|
||||
class Poller(Thread):
|
||||
"""A thread which reads from file descriptors and posts read data to a queue."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._selector = selectors.DefaultSelector()
|
||||
self._read_queues: dict[int, asyncio.Queue[bytes | None]] = {}
|
||||
self._write_queues: dict[int, deque[Write]] = {}
|
||||
self._exit_event = Event()
|
||||
|
||||
def add_file(self, file_descriptor: int) -> asyncio.Queue:
|
||||
"""Add a file descriptor to the poller.
|
||||
|
||||
Args:
|
||||
file_descriptor: File descriptor.
|
||||
|
||||
Returns:
|
||||
Async queue.
|
||||
"""
|
||||
self._selector.register(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE)
|
||||
queue = self._read_queues[file_descriptor] = asyncio.Queue()
|
||||
return queue
|
||||
|
||||
def remove_file(self, file_descriptor: int) -> None:
|
||||
"""Remove a file descriptor from the poller.
|
||||
|
||||
Args:
|
||||
file_descriptor: File descriptor.
|
||||
"""
|
||||
self._selector.unregister(file_descriptor)
|
||||
self._read_queues.pop(file_descriptor, None)
|
||||
self._write_queues.pop(file_descriptor, None)
|
||||
|
||||
async def write(self, file_descriptor: int, data: bytes) -> None:
|
||||
"""Write data to a file descriptor.
|
||||
|
||||
Args:
|
||||
file_descriptor: File descriptor.
|
||||
data: Data to write.
|
||||
"""
|
||||
if file_descriptor not in self._write_queues:
|
||||
self._write_queues[file_descriptor] = deque()
|
||||
new_write = Write(data)
|
||||
self._write_queues[file_descriptor].append(new_write)
|
||||
self._selector.modify(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE)
|
||||
await new_write.done_event.wait()
|
||||
|
||||
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Set the asyncio loop.
|
||||
|
||||
Args:
|
||||
loop: Async loop.
|
||||
"""
|
||||
self._loop = loop
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the Poller thread."""
|
||||
|
||||
readable_events = selectors.EVENT_READ
|
||||
writeable_events = selectors.EVENT_WRITE
|
||||
|
||||
loop = self._loop
|
||||
selector = self._selector
|
||||
assert loop is not None
|
||||
while not self._exit_event.is_set():
|
||||
events = selector.select(1)
|
||||
|
||||
for selector_key, event_mask in events:
|
||||
file_descriptor = selector_key.fileobj
|
||||
assert isinstance(file_descriptor, int)
|
||||
|
||||
queue = self._read_queues.get(file_descriptor, None)
|
||||
if queue is not None:
|
||||
if event_mask & readable_events:
|
||||
try:
|
||||
data = os.read(file_descriptor, 1024 * 32) or None
|
||||
except OSError:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||
else:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, data)
|
||||
|
||||
if event_mask & writeable_events:
|
||||
write_queue = self._write_queues.get(file_descriptor, None)
|
||||
if write_queue:
|
||||
write = write_queue[0]
|
||||
remaining_data = write.data[write.position :]
|
||||
try:
|
||||
bytes_written = os.write(file_descriptor, remaining_data)
|
||||
except OSError:
|
||||
# Write failed; signal completion anyway to unblock waiters
|
||||
write_queue.popleft()
|
||||
loop.call_soon_threadsafe(write.done_event.set)
|
||||
continue
|
||||
write.position += bytes_written
|
||||
# Check if all data has been written
|
||||
if write.position >= len(write.data):
|
||||
write_queue.popleft()
|
||||
loop.call_soon_threadsafe(write.done_event.set)
|
||||
else:
|
||||
selector.modify(file_descriptor, readable_events)
|
||||
|
||||
def exit(self) -> None:
|
||||
"""Exit and block until finished."""
|
||||
for queue in self._read_queues.values():
|
||||
queue.put_nowait(None)
|
||||
self._exit_event.set()
|
||||
self.join()
|
||||
self._read_queues.clear()
|
||||
self._write_queues.clear()
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
|
||||
from .types import Meta
|
||||
|
||||
|
||||
class SessionConnector:
|
||||
"""Connect a session with a client."""
|
||||
|
||||
async def on_data(self, data: bytes) -> None:
|
||||
"""Handle data from session.
|
||||
|
||||
Args:
|
||||
data: Bytes to handle.
|
||||
"""
|
||||
|
||||
async def on_meta(self, meta: Meta) -> None:
|
||||
"""Handle meta from session.
|
||||
|
||||
Args:
|
||||
meta: Mapping of meta information.
|
||||
"""
|
||||
|
||||
async def on_binary_encoded_message(self, payload: bytes) -> None:
|
||||
"""Handle binary encoded data from the process.
|
||||
|
||||
Args:
|
||||
payload: Binary encoded data to handle.
|
||||
"""
|
||||
|
||||
async def on_close(self) -> None:
|
||||
"""Handle session close."""
|
||||
|
||||
|
||||
class Session:
|
||||
"""Virtual base class for a session."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connector = SessionConnector()
|
||||
|
||||
@abstractmethod
|
||||
async def open(self, width: int = 80, height: int = 24) -> None:
|
||||
"""Open the session."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def start(self, connector: SessionConnector) -> asyncio.Task:
|
||||
"""Start the session.
|
||||
|
||||
Returns:
|
||||
Running task.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Close the session."""
|
||||
|
||||
@abstractmethod
|
||||
async def wait(self) -> None:
|
||||
"""Wait for session to end."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_terminal_size(self, width: int, height: int) -> None:
|
||||
"""Set the terminal size.
|
||||
|
||||
Args:
|
||||
width: New width.
|
||||
height: New height.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def send_bytes(self, data: bytes) -> bool:
|
||||
"""Send bytes to the process.
|
||||
|
||||
Args:
|
||||
data: Bytes to send.
|
||||
|
||||
Returns:
|
||||
True on success, or False if the data was not sent.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def send_meta(self, data: Meta) -> bool:
|
||||
"""Send meta to the process.
|
||||
|
||||
Args:
|
||||
meta: Meta information.
|
||||
|
||||
Returns:
|
||||
True on success, or False if the data was not sent.
|
||||
"""
|
||||
...
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the session is still running.
|
||||
|
||||
Returns:
|
||||
True if session is active, False otherwise.
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import config, constants
|
||||
from ._two_way_dict import TwoWayDict
|
||||
from .app_session import AppSession
|
||||
from .identity import generate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from .poller import Poller
|
||||
from .session import Session
|
||||
from .types import RouteKey, SessionID
|
||||
|
||||
|
||||
log = logging.getLogger("textual-web")
|
||||
|
||||
|
||||
if not constants.WINDOWS:
|
||||
from .terminal_session import TerminalSession
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manage sessions (Textual apps or terminals)."""
|
||||
|
||||
def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None:
|
||||
self.poller = poller
|
||||
self.path = path
|
||||
self.apps = apps
|
||||
self.apps_by_slug = {app.slug: app for app in apps}
|
||||
self.sessions: dict[SessionID, Session] = {}
|
||||
self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict()
|
||||
|
||||
def add_app(self, name: str, command: str, slug: str, terminal: bool = False) -> None:
|
||||
"""Add a new app
|
||||
|
||||
Args:
|
||||
name: Name of the app.
|
||||
command: Command to run the app.
|
||||
slug: Slug used in URL, or blank to auto-generate on server.
|
||||
"""
|
||||
slug = slug or generate().lower()
|
||||
new_app = config.App(name=name, slug=slug, path="./", command=command, terminal=terminal)
|
||||
self.apps.append(new_app)
|
||||
self.apps_by_slug[slug] = new_app
|
||||
|
||||
def get_default_app(self) -> config.App | None:
|
||||
"""Get the default app (first configured app), or ``None``."""
|
||||
return self.apps[0] if self.apps else None
|
||||
|
||||
def on_session_end(self, session_id: SessionID) -> None:
|
||||
"""Called when a session ends."""
|
||||
self.sessions.pop(session_id, None)
|
||||
route_key = self.routes.get_key(session_id)
|
||||
if route_key is not None:
|
||||
del self.routes[route_key]
|
||||
log.debug(f"Session {session_id} ended")
|
||||
|
||||
async def close_all(self, timeout: float = 3.0) -> None:
|
||||
"""Close app sessions.
|
||||
|
||||
Args:
|
||||
timeout: Time (in seconds) to wait before giving up.
|
||||
|
||||
"""
|
||||
sessions = list(self.sessions.values())
|
||||
|
||||
if not sessions:
|
||||
return
|
||||
log.info("Closing %s session(s)", len(sessions))
|
||||
|
||||
async def do_close() -> int:
|
||||
"""Close all sessions, return number unclosed after timeout
|
||||
|
||||
Returns:
|
||||
Number of sessions not yet closed.
|
||||
"""
|
||||
|
||||
async def close_wait(session: Session) -> None:
|
||||
await asyncio.gather(session.close(), session.wait())
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
async with asyncio.TaskGroup() as tg: # type: ignore[attr-defined]
|
||||
for session in sessions:
|
||||
tg.create_task(close_wait(session))
|
||||
return 0
|
||||
_done, remaining = await asyncio.wait(
|
||||
[asyncio.create_task(close_wait(session)) for session in sessions],
|
||||
timeout=timeout,
|
||||
)
|
||||
return len(remaining)
|
||||
|
||||
remaining = await do_close()
|
||||
if remaining:
|
||||
log.warning("%s session(s) didn't close after %s seconds", remaining, timeout)
|
||||
|
||||
async def new_session(
|
||||
self,
|
||||
slug: str,
|
||||
session_id: SessionID,
|
||||
route_key: RouteKey,
|
||||
size: tuple[int, int] = (80, 24),
|
||||
) -> Session | None:
|
||||
"""Create a new session.
|
||||
|
||||
Args:
|
||||
slug: Slug for app.
|
||||
session_id: Session identity.
|
||||
route_key: Route key.
|
||||
size: Terminal size (width, height).
|
||||
|
||||
Returns:
|
||||
New session, or `None` if no app / terminal configured.
|
||||
"""
|
||||
app = self.apps_by_slug.get(slug)
|
||||
if app is None:
|
||||
return None
|
||||
|
||||
session_process: Session
|
||||
if app.terminal:
|
||||
if constants.WINDOWS:
|
||||
log.warning("Sorry, textual-web does not currently support terminals on Windows")
|
||||
return None
|
||||
else:
|
||||
session_process = TerminalSession(
|
||||
self.poller,
|
||||
session_id,
|
||||
app.command,
|
||||
)
|
||||
log.info(f"Created terminal session {session_id}")
|
||||
else:
|
||||
session_process = AppSession(
|
||||
self.path,
|
||||
app.command,
|
||||
session_id,
|
||||
)
|
||||
log.info(f"Created app session {session_id}")
|
||||
|
||||
self.sessions[session_id] = session_process
|
||||
self.routes[route_key] = session_id
|
||||
|
||||
await session_process.open(*size)
|
||||
log.debug(f"Session {session_id} opened and ready")
|
||||
|
||||
return session_process
|
||||
|
||||
async def close_session(self, session_id: SessionID) -> None:
|
||||
"""Close a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identity.
|
||||
"""
|
||||
session_process = self.sessions.get(session_id, None)
|
||||
if session_process is None:
|
||||
return
|
||||
await session_process.close()
|
||||
|
||||
def get_session(self, session_id: SessionID) -> Session | None:
|
||||
"""Get a session from a session ID.
|
||||
|
||||
Args:
|
||||
session_id: Session identity.
|
||||
|
||||
Returns:
|
||||
A session or `None` if it doesn't exist.
|
||||
"""
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def get_session_by_route_key(self, route_key: RouteKey) -> Session | None:
|
||||
"""Get a session from a route key.
|
||||
|
||||
Args:
|
||||
route_key: A route key.
|
||||
|
||||
Returns:
|
||||
A session or `None` if it doesn't exist.
|
||||
|
||||
"""
|
||||
session_id = self.routes.get(route_key)
|
||||
if session_id is not None:
|
||||
return self.sessions.get(session_id)
|
||||
return None
|
||||
|
||||
def get_first_running_session(self) -> tuple[RouteKey, Session] | None:
|
||||
"""Get the first running session.
|
||||
|
||||
Returns:
|
||||
Tuple of (route_key, session) or None if no running sessions.
|
||||
"""
|
||||
for route_key in self.routes:
|
||||
session_id = self.routes.get(route_key)
|
||||
if session_id:
|
||||
session = self.sessions.get(session_id)
|
||||
if session and session.is_running():
|
||||
return (route_key, session)
|
||||
return None
|
||||
@@ -0,0 +1,18 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def slugify(value: str, allow_unicode=False) -> str:
|
||||
"""
|
||||
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
|
||||
dashes to single dashes. Remove characters that aren't alphanumerics,
|
||||
underscores, or hyphens. Convert to lowercase. Also strip leading and
|
||||
trailing whitespace, dashes, and underscores.
|
||||
"""
|
||||
value = str(value)
|
||||
if allow_unicode:
|
||||
value = unicodedata.normalize("NFKC", value)
|
||||
else:
|
||||
value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||
value = re.sub(r"[^\w\s-]", "", value.lower())
|
||||
return re.sub(r"[-\s]+", "-", value).strip("-_")
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Generic monospace font stack for terminal rendering.
|
||||
|
||||
Prefers system monospace fonts, with optional Fira Code / Roboto Mono if available.
|
||||
We avoid external font fetching (e.g. Google Fonts) to keep local server self-contained.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--textual-webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font",
|
||||
"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--textual-webterm-mono);
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-family: var(--textual-webterm-mono);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import shlex
|
||||
import signal
|
||||
import termios
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import rich.repr
|
||||
from importlib_metadata import version
|
||||
|
||||
from .session import Session, SessionConnector
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .poller import Poller
|
||||
from .types import Meta, SessionID
|
||||
|
||||
log = logging.getLogger("textual-web")
|
||||
|
||||
# Maximum bytes to keep in replay buffer for reconnection
|
||||
REPLAY_BUFFER_SIZE = 64 * 1024 # 64KB
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class TerminalSession(Session):
|
||||
"""A session that manages a terminal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
poller: Poller,
|
||||
session_id: SessionID,
|
||||
command: str,
|
||||
) -> None:
|
||||
self.poller = poller
|
||||
self.session_id = session_id
|
||||
self.command = command or os.environ.get("SHELL", "sh")
|
||||
self.master_fd: int | None = None
|
||||
self.pid: int | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
self._replay_buffer: deque[bytes] = deque()
|
||||
self._replay_buffer_size = 0
|
||||
self._replay_lock = asyncio.Lock()
|
||||
super().__init__()
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "session_id", self.session_id
|
||||
yield "command", self.command
|
||||
|
||||
async def open(self, width: int = 80, height: int = 24) -> None:
|
||||
log.info(f"Opening terminal session {self.session_id} with command: {self.command}")
|
||||
pid, master_fd = pty.fork()
|
||||
self.pid = pid
|
||||
self.master_fd = master_fd
|
||||
if pid == pty.CHILD:
|
||||
os.environ["TERM_PROGRAM"] = "textual-webterm"
|
||||
os.environ["TERM_PROGRAM_VERSION"] = version("textual-webterm")
|
||||
try:
|
||||
argv = shlex.split(self.command)
|
||||
except ValueError:
|
||||
os._exit(1)
|
||||
if not argv:
|
||||
os._exit(1)
|
||||
try:
|
||||
os.execvp(argv[0], argv) ## Exits the app
|
||||
except OSError:
|
||||
os._exit(1)
|
||||
try:
|
||||
self._set_terminal_size(width, height)
|
||||
except OSError:
|
||||
# Clean up on failure
|
||||
os.close(master_fd)
|
||||
self.master_fd = None
|
||||
raise
|
||||
log.debug(f"Terminal session {self.session_id} opened successfully")
|
||||
|
||||
def _set_terminal_size(self, width: int, height: int) -> None:
|
||||
buf = array.array("h", [height, width, 0, 0])
|
||||
assert self.master_fd is not None
|
||||
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, buf)
|
||||
|
||||
async def set_terminal_size(self, width: int, height: int) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._set_terminal_size, width, height)
|
||||
|
||||
async def _add_to_replay_buffer(self, data: bytes) -> None:
|
||||
"""Add data to replay buffer, maintaining size limit."""
|
||||
async with self._replay_lock:
|
||||
self._replay_buffer.append(data)
|
||||
self._replay_buffer_size += len(data)
|
||||
while self._replay_buffer_size > REPLAY_BUFFER_SIZE and self._replay_buffer:
|
||||
old_data = self._replay_buffer.popleft()
|
||||
self._replay_buffer_size -= len(old_data)
|
||||
|
||||
async def get_replay_buffer(self) -> bytes:
|
||||
"""Get the contents of the replay buffer."""
|
||||
async with self._replay_lock:
|
||||
return b"".join(self._replay_buffer)
|
||||
|
||||
def update_connector(self, connector: SessionConnector) -> None:
|
||||
"""Update the connector for reconnection without restarting the session."""
|
||||
self._connector = connector
|
||||
log.debug(f"Updated connector for session {self.session_id}")
|
||||
|
||||
async def start(self, connector: SessionConnector) -> asyncio.Task:
|
||||
self._connector = connector
|
||||
assert self.master_fd is not None
|
||||
if self._task is not None:
|
||||
# Already running, just update connector (handled by update_connector)
|
||||
return self._task
|
||||
self._task = asyncio.create_task(self.run())
|
||||
return self._task
|
||||
|
||||
async def run(self) -> None:
|
||||
assert self.master_fd is not None
|
||||
queue = self.poller.add_file(self.master_fd)
|
||||
try:
|
||||
while True:
|
||||
data = await queue.get()
|
||||
if not data:
|
||||
break
|
||||
# Store in replay buffer for reconnection
|
||||
await self._add_to_replay_buffer(data)
|
||||
# Send to current connector
|
||||
if self._connector:
|
||||
await self._connector.on_data(data)
|
||||
except OSError:
|
||||
log.exception("error in terminal.run")
|
||||
finally:
|
||||
if self._connector:
|
||||
await self._connector.on_close()
|
||||
if self.master_fd is not None:
|
||||
fd = self.master_fd
|
||||
self.master_fd = None
|
||||
# Remove from poller first (while fd is still valid), then close
|
||||
self.poller.remove_file(fd)
|
||||
os.close(fd)
|
||||
|
||||
async def send_bytes(self, data: bytes) -> bool:
|
||||
if self.master_fd is None:
|
||||
return False
|
||||
await self.poller.write(self.master_fd, data)
|
||||
return True
|
||||
|
||||
async def send_meta(self, data: Meta) -> bool:
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.pid is not None:
|
||||
try:
|
||||
os.kill(self.pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass # Process already gone
|
||||
except Exception as e:
|
||||
log.warning(f"Error closing terminal session {self.session_id}: {e}")
|
||||
|
||||
async def wait(self) -> None:
|
||||
if self._task is not None:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the terminal session is still running."""
|
||||
if self.master_fd is None or self._task is None:
|
||||
return False
|
||||
# Check if process is actually alive
|
||||
if self.pid is not None:
|
||||
try:
|
||||
os.kill(self.pid, 0) # Signal 0 checks existence
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
# pid is None means process not started or already exited
|
||||
return False
|
||||
@@ -0,0 +1,6 @@
|
||||
from typing import NewType, Union
|
||||
|
||||
AppID = NewType("AppID", str)
|
||||
Meta = dict[str, Union[str, None, int, bool]]
|
||||
RouteKey = NewType("RouteKey", str)
|
||||
SessionID = NewType("SessionID", str)
|
||||
Reference in New Issue
Block a user