This commit is contained in:
Rui Carmo
2026-01-21 23:53:57 +00:00
commit a0e31d43fd
52 changed files with 6312 additions and 0 deletions
View File
+89
View File
@@ -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
+330
View File
@@ -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
+229
View File
@@ -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()
+150
View File
@@ -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
+51
View File
@@ -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."""
+53
View File
@@ -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
+11
View File
@@ -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))
+621
View File
@@ -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()
+129
View File
@@ -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()
+109
View File
@@ -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
+201
View File
@@ -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
+18
View File
@@ -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("-_")
+19
View File
@@ -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);
}
+180
View File
@@ -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
+6
View File
@@ -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)