可用性改动
This commit is contained in:
350
webshare/server.py
Normal file
350
webshare/server.py
Normal file
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp_jinja2
|
||||
from aiohttp import web
|
||||
from aiohttp import WSMsgType
|
||||
from aiohttp.web_runner import GracefulExit
|
||||
import jinja2
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.highlighter import RegexHighlighter
|
||||
|
||||
from webshare.download_manager import DownloadManager
|
||||
|
||||
from .app_service import AppService
|
||||
|
||||
log = logging.getLogger("textual-serve")
|
||||
|
||||
LOGO = r"""[bold magenta]___ ____ _ _ ___ _ _ ____ _ ____ ____ ____ _ _ ____
|
||||
| |___ \/ | | | |__| | __ [__ |___ |__/ | | |___
|
||||
| |___ _/\_ | |__| | | |___ ___] |___ | \ \/ |___ [not bold]VVVVV
|
||||
""".replace("VVVVV", f"v{version('textual-serve')}")
|
||||
|
||||
|
||||
WINDOWS = sys.platform == "WINDOWS"
|
||||
|
||||
|
||||
class LogHighlighter(RegexHighlighter):
|
||||
base_style = "repr."
|
||||
highlights = [
|
||||
r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[-+]?\d+?)?\b|0x[0-9a-fA-F]*)",
|
||||
r"(?P<path>\[.*?\])",
|
||||
r"(?<![\\\w])(?P<str>b?'''.*?(?<!\\)'''|b?'.*?(?<!\\)'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")",
|
||||
]
|
||||
|
||||
|
||||
def to_int(value: str, default: int) -> int:
|
||||
"""Convert to an integer, or return a default if that's not possible.
|
||||
|
||||
Args:
|
||||
number: A string possibly containing a decimal.
|
||||
default: Default value if value can't be decoded.
|
||||
|
||||
Returns:
|
||||
Integer.
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
class Server:
|
||||
"""Serve a Textual app."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: str,
|
||||
host: str = "localhost",
|
||||
port: int = 8000,
|
||||
title: str | None = None,
|
||||
public_url: str | None = None,
|
||||
statics_path: str | os.PathLike = "./static",
|
||||
templates_path: str | os.PathLike = "./templates",
|
||||
):
|
||||
"""
|
||||
|
||||
Args:
|
||||
app_factory: A callable that returns a new App instance.
|
||||
host: Host of web application.
|
||||
port: Port for server.
|
||||
statics_path: Path to statics folder. May be absolute or relative to server.py.
|
||||
templates_path" Path to templates folder. May be absolute or relative to server.py.
|
||||
"""
|
||||
self.command = command
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.title = title or command
|
||||
self.debug = False
|
||||
|
||||
if public_url is None:
|
||||
if self.port == 80:
|
||||
self.public_url = f"http://{self.host}"
|
||||
elif self.port == 443:
|
||||
self.public_url = f"https://{self.host}"
|
||||
else:
|
||||
self.public_url = f"http://{self.host}:{self.port}"
|
||||
else:
|
||||
self.public_url = public_url
|
||||
|
||||
base_path = (Path(__file__) / "../").resolve().absolute()
|
||||
self.statics_path = base_path / statics_path
|
||||
self.templates_path = base_path / templates_path
|
||||
self.console = Console()
|
||||
self.download_manager = DownloadManager()
|
||||
|
||||
def initialize_logging(self) -> None:
|
||||
"""Initialize logging.
|
||||
|
||||
May be overridden in a subclass.
|
||||
"""
|
||||
FORMAT = "%(message)s"
|
||||
logging.basicConfig(
|
||||
level="DEBUG" if self.debug else "INFO",
|
||||
format=FORMAT,
|
||||
datefmt="[%X]",
|
||||
handlers=[
|
||||
RichHandler(
|
||||
show_path=False,
|
||||
show_time=False,
|
||||
rich_tracebacks=True,
|
||||
tracebacks_show_locals=True,
|
||||
highlighter=LogHighlighter(),
|
||||
console=self.console,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def request_exit(self) -> None:
|
||||
"""Gracefully exit the app."""
|
||||
raise GracefulExit()
|
||||
|
||||
async def _make_app(self) -> web.Application:
|
||||
"""Make the aiohttp web.Application.
|
||||
|
||||
Returns:
|
||||
New aiohttp web application.
|
||||
"""
|
||||
app = web.Application()
|
||||
|
||||
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.templates_path))
|
||||
|
||||
ROUTES = [
|
||||
web.get("/", self.handle_index, name="index"),
|
||||
web.get("/ws", self.handle_websocket, name="websocket"),
|
||||
web.get("/download/{key}", self.handle_download, name="download"),
|
||||
web.static("/static", self.statics_path, show_index=True, name="static"),
|
||||
]
|
||||
app.add_routes(ROUTES)
|
||||
|
||||
app.on_startup.append(self.on_startup)
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
return app
|
||||
|
||||
async def handle_download(self, request: web.Request) -> web.StreamResponse:
|
||||
"""Handle a download request."""
|
||||
key = request.match_info["key"]
|
||||
|
||||
try:
|
||||
download_meta = await self.download_manager.get_download_metadata(key)
|
||||
except KeyError:
|
||||
raise web.HTTPNotFound(text=f"Download with key {key!r} not found")
|
||||
|
||||
response = web.StreamResponse()
|
||||
mime_type = download_meta.mime_type
|
||||
|
||||
content_type = mime_type
|
||||
if download_meta.encoding:
|
||||
content_type += f"; charset={download_meta.encoding}"
|
||||
|
||||
response.headers["Content-Type"] = content_type
|
||||
disposition = (
|
||||
"inline" if download_meta.open_method == "browser" else "attachment"
|
||||
)
|
||||
response.headers["Content-Disposition"] = (
|
||||
f"{disposition}; filename={download_meta.file_name}"
|
||||
)
|
||||
|
||||
await response.prepare(request)
|
||||
|
||||
async for chunk in self.download_manager.download(key):
|
||||
await response.write(chunk)
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
async def on_shutdown(self, app: web.Application) -> None:
|
||||
"""Called on shutdown.
|
||||
|
||||
Args:
|
||||
app: App instance.
|
||||
"""
|
||||
|
||||
async def on_startup(self, app: web.Application) -> None:
|
||||
"""Called on startup.
|
||||
|
||||
Args:
|
||||
app: App instance.
|
||||
"""
|
||||
|
||||
self.console.print(LOGO, highlight=False)
|
||||
self.console.print(f"Serving {self.command!r} on {self.public_url}")
|
||||
self.console.print("\n[cyan]Press Ctrl+C to quit")
|
||||
|
||||
def serve(self, debug: bool = False) -> None:
|
||||
"""Serve the Textual application.
|
||||
|
||||
This will run a local webserver until it is closed with Ctrl+C
|
||||
|
||||
"""
|
||||
self.debug = debug
|
||||
self.initialize_logging()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, self.request_exit)
|
||||
loop.add_signal_handler(signal.SIGTERM, self.request_exit)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
if self.debug:
|
||||
log.info("Running in debug mode. You may use textual dev tools.")
|
||||
|
||||
web.run_app(
|
||||
self._make_app(),
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
handle_signals=False,
|
||||
loop=loop,
|
||||
print=lambda *args: None,
|
||||
)
|
||||
|
||||
@aiohttp_jinja2.template("app_index.html")
|
||||
async def handle_index(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Serves the HTML for an app.
|
||||
|
||||
Args:
|
||||
request: Request object.
|
||||
|
||||
Returns:
|
||||
Template data.
|
||||
"""
|
||||
router = request.app.router
|
||||
font_size = to_int(request.query.get("fontsize", "16"), 16)
|
||||
|
||||
def get_url(route: str, **args) -> str:
|
||||
"""Get a URL from the aiohttp router."""
|
||||
path = router[route].url_for(**args)
|
||||
return f"{self.public_url}{path}"
|
||||
|
||||
def get_websocket_url(route: str, **args) -> str:
|
||||
"""Get a URL with a websocket prefix."""
|
||||
url = get_url(route, **args)
|
||||
|
||||
if self.public_url.startswith("https"):
|
||||
return "wss:" + url.split(":", 1)[1]
|
||||
else:
|
||||
return "ws:" + url.split(":", 1)[1]
|
||||
|
||||
context = {
|
||||
"font_size": font_size,
|
||||
"app_websocket_url": get_websocket_url("websocket"),
|
||||
}
|
||||
context["config"] = {
|
||||
"static": {
|
||||
"url": get_url("static", filename="/").rstrip("/") + "/",
|
||||
},
|
||||
}
|
||||
context["application"] = {
|
||||
"name": self.title,
|
||||
}
|
||||
return context
|
||||
|
||||
async def _process_messages(
|
||||
self, websocket: web.WebSocketResponse, app_service: AppService
|
||||
) -> None:
|
||||
"""Process messages from the client browser websocket.
|
||||
|
||||
Args:
|
||||
websocket: Websocket instance.
|
||||
app_service: App service.
|
||||
"""
|
||||
TEXT = WSMsgType.TEXT
|
||||
|
||||
async for message in websocket:
|
||||
if message.type != TEXT:
|
||||
continue
|
||||
envelope = message.json()
|
||||
assert isinstance(envelope, list)
|
||||
type_ = envelope[0]
|
||||
if type_ == "stdin":
|
||||
data = envelope[1]
|
||||
await app_service.send_bytes(data.encode("utf-8"))
|
||||
elif type_ == "resize":
|
||||
data = envelope[1]
|
||||
await app_service.set_terminal_size(data["width"], data["height"])
|
||||
elif type_ == "ping":
|
||||
data = envelope[1]
|
||||
await websocket.send_json(["pong", data])
|
||||
elif type_ == "blur":
|
||||
await app_service.blur()
|
||||
elif type_ == "focus":
|
||||
await app_service.focus()
|
||||
|
||||
async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""Handle the websocket that drives the remote process.
|
||||
|
||||
This is called when the browser connects to the websocket.
|
||||
|
||||
Args:
|
||||
request: Request object.
|
||||
|
||||
Returns:
|
||||
Websocket response.
|
||||
"""
|
||||
websocket = web.WebSocketResponse(heartbeat=15)
|
||||
|
||||
width = to_int(request.query.get("width", "80"), 80)
|
||||
height = to_int(request.query.get("height", "24"), 24)
|
||||
|
||||
app_service: AppService | None = None
|
||||
try:
|
||||
await websocket.prepare(request)
|
||||
app_service = AppService(
|
||||
self.command,
|
||||
write_bytes=websocket.send_bytes,
|
||||
write_str=websocket.send_str,
|
||||
close=websocket.close,
|
||||
download_manager=self.download_manager,
|
||||
debug=self.debug,
|
||||
)
|
||||
await app_service.start(width, height)
|
||||
try:
|
||||
await self._process_messages(websocket, app_service)
|
||||
finally:
|
||||
await app_service.stop()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await websocket.close()
|
||||
|
||||
except Exception as error:
|
||||
log.exception(error)
|
||||
|
||||
finally:
|
||||
if app_service is not None:
|
||||
await app_service.stop()
|
||||
|
||||
return websocket
|
Reference in New Issue
Block a user