├── .gitignore ├── .python-version ├── LICENSE ├── README.md ├── examples ├── dictionary.py ├── dictionary.tcss ├── download_screenshot.py ├── open_link.py ├── serve.py ├── serve_any.py ├── serve_dictionary.py └── serve_open_link.py ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock └── src └── textual_serve ├── __init__.py ├── _binary_encode.py ├── app_service.py ├── download_manager.py ├── py.typed ├── server.py ├── static ├── css │ └── xterm.css ├── fonts │ ├── RobotoMono-Italic-VariableFont_wght.ttf │ └── RobotoMono-VariableFont_wght.ttf ├── images │ └── background.png └── js │ └── textual.js └── templates └── app_index.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | .venv/ 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | .DS_Store 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 111 | .pdm.toml 112 | .pdm-python 113 | .pdm-build/ 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.1 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Textualize 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # textual-serve 2 | 3 | Every [Textual](https://github.com/textualize/textual) application is now a web application. 4 | 5 | With 3 lines of code, any Textual app can run in the browser. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 18 | 19 | 20 | 23 | 26 | 27 |
13 |

This is Posting running in the terminal.

14 |
16 | 17 |
21 |

This is Posting running in the browser.

22 |
24 | 25 |
28 | 29 | --- 30 | 31 | ## Getting Started 32 | 33 | First, [install (or upgrade) Textual](https://textual.textualize.io/getting_started/#installation). 34 | 35 | Then install `textual-serve` from PyPI: 36 | 37 | 38 | ``` 39 | pip install textual-serve 40 | ``` 41 | 42 | ## Creating a server 43 | 44 | First import the Server class: 45 | 46 | ```python 47 | from textual_serve.server import Server 48 | ``` 49 | 50 | Then create a `Server` instance and pass the command that launches your Textual app: 51 | 52 | ```python 53 | server = Server("python -m textual") 54 | ``` 55 | 56 | The command can be anything you would enter in the shell, as long as it results in a Textual app running. 57 | 58 | Finally, call the `serve` method: 59 | 60 | ```python 61 | server.serve() 62 | ``` 63 | 64 | You will now be able to click on the link in the terminal to run your app in a browser. 65 | 66 | ### Summary 67 | 68 | Run this code, visit http://localhost:8000 69 | 70 | ```python 71 | from textual_serve.server import Server 72 | 73 | server = Server("python -m textual") 74 | server.serve() 75 | ``` 76 | 77 | ## Configuration 78 | 79 | The `Server` class has the following parameters: 80 | 81 | | parameter | description | 82 | | -------------- | ---------------------------------------------------------------------------------- | 83 | | command | A shell command to launch a Textual app. | 84 | | host | The host of the web application (defaults to "localhost"). | 85 | | port | The port for the web application (defaults to 8000). | 86 | | title | The title show in the web app on load, leave as `None` to use the command. | 87 | | public_url | The public URL, if the server is behind a proxy. `None` for the local URL. | 88 | | statics_path | Path to statics folder, relative to server.py. Default uses directory in module. | 89 | | templates_path | Path to templates folder, relative to server.py. Default uses directory in module. | 90 | 91 | The `Server.serve` method accepts a `debug` parameter. 92 | When set to `True`, this will enable [textual devtools](https://textual.textualize.io/guide/devtools/). 93 | 94 | ## How does it work? 95 | 96 | When you visit the app URL, the server launches an instance of your app in a subprocess, and communicates with it via a websocket. 97 | 98 | This means that you can serve multiple Textual apps across all the CPUs on your system. 99 | 100 | 101 | Note that Textual-serve uses a custom protocol to communicate with Textual apps. 102 | It *does not* simply expose a shell in your browser. 103 | There is no way for a malicious user to do anything the app-author didn't intend. 104 | 105 | ## See also 106 | 107 | See also [textual-web](https://github.com/Textualize/textual-web) which serves Textual apps on a public URL. 108 | 109 | You can consider this project to essentially be a self-hosted equivalent of Textual-web. 110 | -------------------------------------------------------------------------------- /examples/dictionary.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | try: 4 | import httpx 5 | except ImportError: 6 | raise ImportError("Please install httpx with 'pip install httpx' ") 7 | 8 | 9 | from textual import work 10 | from textual.app import App, ComposeResult 11 | from textual.containers import VerticalScroll 12 | from textual.widgets import Input, Markdown 13 | 14 | 15 | class DictionaryApp(App[None]): 16 | """Searches a dictionary API as-you-type.""" 17 | 18 | CSS_PATH = "dictionary.tcss" 19 | 20 | def compose(self) -> ComposeResult: 21 | yield Input(placeholder="Search for a word") 22 | with VerticalScroll(id="results-container"): 23 | yield Markdown(id="results") 24 | 25 | # def on_mount(self) -> None: 26 | # """Called when app starts.""" 27 | # # Give the input focus, so we can start typing straight away 28 | # self.query_one(Input).focus() 29 | 30 | async def on_input_changed(self, message: Input.Changed) -> None: 31 | """A coroutine to handle a text changed message.""" 32 | if message.value: 33 | self.lookup_word(message.value) 34 | else: 35 | # Clear the results 36 | await self.query_one("#results", Markdown).update("") 37 | 38 | @work(exclusive=True) 39 | async def lookup_word(self, word: str) -> None: 40 | """Looks up a word.""" 41 | url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" 42 | 43 | async with httpx.AsyncClient() as client: 44 | response = await client.get(url) 45 | try: 46 | results = response.json() 47 | except Exception: 48 | self.query_one("#results", Markdown).update(response.text) 49 | return 50 | 51 | if word == self.query_one(Input).value: 52 | markdown = self.make_word_markdown(results) 53 | self.query_one("#results", Markdown).update(markdown) 54 | 55 | def make_word_markdown(self, results: object) -> str: 56 | """Convert the results in to markdown.""" 57 | lines = [] 58 | if isinstance(results, dict): 59 | lines.append(f"# {results['title']}") 60 | lines.append(results["message"]) 61 | elif isinstance(results, list): 62 | for result in results: 63 | lines.append(f"# {result['word']}") 64 | lines.append("") 65 | for meaning in result.get("meanings", []): 66 | lines.append(f"_{meaning['partOfSpeech']}_") 67 | lines.append("") 68 | for definition in meaning.get("definitions", []): 69 | lines.append(f" - {definition['definition']}") 70 | lines.append("---") 71 | 72 | return "\n".join(lines) 73 | 74 | 75 | if __name__ == "__main__": 76 | app = DictionaryApp() 77 | app.run() 78 | -------------------------------------------------------------------------------- /examples/dictionary.tcss: -------------------------------------------------------------------------------- 1 | Screen { 2 | background: $panel; 3 | } 4 | 5 | Input { 6 | dock: top; 7 | margin: 1 0; 8 | } 9 | 10 | #results { 11 | width: 100%; 12 | height: auto; 13 | } 14 | 15 | #results-container { 16 | background: $background 50%; 17 | margin: 0 0 1 0; 18 | height: 100%; 19 | overflow: hidden auto; 20 | border: tall $background; 21 | } 22 | 23 | #results-container:focus { 24 | border: tall $accent; 25 | } 26 | -------------------------------------------------------------------------------- /examples/download_screenshot.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | from textual import on 4 | from textual.app import App, ComposeResult 5 | from textual.events import DeliveryComplete 6 | from textual.widgets import Button, Input, Label 7 | 8 | 9 | class ScreenshotApp(App[None]): 10 | def compose(self) -> ComposeResult: 11 | yield Button("screenshot: no filename or mime", id="button-1") 12 | yield Button("screenshot: screenshot.svg / open in browser", id="button-2") 13 | yield Button("screenshot: screenshot.svg / download", id="button-3") 14 | yield Button( 15 | "screenshot: screenshot.svg / open in browser / plaintext mime", 16 | id="button-4", 17 | ) 18 | yield Label("Deliver custom file:") 19 | yield Input(id="custom-path-input", placeholder="Path to file...") 20 | 21 | @on(Button.Pressed, selector="#button-1") 22 | def on_button_pressed(self) -> None: 23 | screenshot_string = self.export_screenshot() 24 | string_io = io.StringIO(screenshot_string) 25 | self.deliver_text(string_io) 26 | 27 | @on(Button.Pressed, selector="#button-2") 28 | def on_button_pressed_2(self) -> None: 29 | screenshot_string = self.export_screenshot() 30 | string_io = io.StringIO(screenshot_string) 31 | self.deliver_text( 32 | string_io, save_filename="screenshot.svg", open_method="browser" 33 | ) 34 | 35 | @on(Button.Pressed, selector="#button-3") 36 | def on_button_pressed_3(self) -> None: 37 | screenshot_string = self.export_screenshot() 38 | string_io = io.StringIO(screenshot_string) 39 | self.deliver_text( 40 | string_io, save_filename="screenshot.svg", open_method="download" 41 | ) 42 | 43 | @on(Button.Pressed, selector="#button-4") 44 | def on_button_pressed_4(self) -> None: 45 | screenshot_string = self.export_screenshot() 46 | string_io = io.StringIO(screenshot_string) 47 | self.deliver_text( 48 | string_io, 49 | save_filename="screenshot.svg", 50 | open_method="browser", 51 | mime_type="text/plain", 52 | ) 53 | 54 | @on(DeliveryComplete) 55 | def on_delivery_complete(self, event: DeliveryComplete) -> None: 56 | self.notify(title="Download complete", message=event.key) 57 | 58 | @on(Input.Submitted) 59 | def on_input_submitted(self, event: Input.Submitted) -> None: 60 | path = Path(event.value) 61 | if path.exists(): 62 | self.deliver_binary(path) 63 | else: 64 | self.notify( 65 | title="Invalid path", 66 | message="The path does not exist.", 67 | severity="error", 68 | ) 69 | 70 | 71 | app = ScreenshotApp() 72 | if __name__ == "__main__": 73 | app.run() 74 | -------------------------------------------------------------------------------- /examples/open_link.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import App, ComposeResult 3 | from textual.widgets import Button 4 | 5 | 6 | class OpenLink(App[None]): 7 | """Demonstrates opening a URL in the same tab or a new tab.""" 8 | 9 | def compose(self) -> ComposeResult: 10 | yield Button("Visit the Textual docs", id="open-link-same-tab") 11 | yield Button("Visit the Textual docs in a new tab", id="open-link-new-tab") 12 | 13 | @on(Button.Pressed) 14 | def open_link(self, event: Button.Pressed) -> None: 15 | """Open the URL in the same tab or a new tab depending on which button was pressed.""" 16 | self.open_url( 17 | "https://textual.textualize.io", 18 | new_tab=event.button.id == "open-link-new-tab", 19 | ) 20 | 21 | 22 | app = OpenLink() 23 | if __name__ == "__main__": 24 | app.run() 25 | -------------------------------------------------------------------------------- /examples/serve.py: -------------------------------------------------------------------------------- 1 | from textual_serve.server import Server 2 | 3 | server = Server("python -m textual") 4 | server.serve() 5 | -------------------------------------------------------------------------------- /examples/serve_any.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from textual_serve.server import Server 3 | 4 | if __name__ == "__main__": 5 | server = Server(sys.argv[1]) 6 | server.serve(debug=True) 7 | -------------------------------------------------------------------------------- /examples/serve_dictionary.py: -------------------------------------------------------------------------------- 1 | from textual_serve.server import Server 2 | 3 | server = Server("python dictionary.py") 4 | server.serve(debug=False) 5 | -------------------------------------------------------------------------------- /examples/serve_open_link.py: -------------------------------------------------------------------------------- 1 | from textual_serve.server import Server 2 | 3 | server = Server("python open_link.py") 4 | server.serve(debug=False) 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "textual-serve" 3 | version = "1.1.2" 4 | description = "Turn your Textual TUIs in to web applications" 5 | authors = [ 6 | { name = "Will McGugan", email = "will@textualize.io" } 7 | ] 8 | dependencies = [ 9 | "aiohttp>=3.9.5", 10 | "aiohttp-jinja2>=1.6", 11 | "jinja2>=3.1.4", 12 | "rich", 13 | "textual>=0.66.0", 14 | ] 15 | readme = "README.md" 16 | requires-python = ">= 3.9" 17 | license = "MIT" 18 | 19 | [project.urls] 20 | Homepage = "https://github.com/Textualize/textual-serve" 21 | 22 | 23 | [build-system] 24 | requires = ["hatchling>=1.26.1"] 25 | build-backend = "hatchling.build" 26 | 27 | [tool.hatch.metadata] 28 | allow-direct-references = true 29 | 30 | [tool.rye] 31 | managed = true 32 | dev-dependencies = [ 33 | "httpx", 34 | # required to run the dictionary example 35 | "textual-dev>=1.5.1", 36 | ] 37 | 38 | [tool.hatch.build.targets.wheel] 39 | packages = ["src/textual_serve"] 40 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | -e file:. 12 | aiohttp==3.9.5 13 | # via aiohttp-jinja2 14 | # via textual-dev 15 | # via textual-serve 16 | aiohttp-jinja2==1.6 17 | # via textual-serve 18 | aiosignal==1.3.1 19 | # via aiohttp 20 | anyio==4.4.0 21 | # via httpx 22 | attrs==23.2.0 23 | # via aiohttp 24 | certifi==2024.7.4 25 | # via httpcore 26 | # via httpx 27 | click==8.1.7 28 | # via textual-dev 29 | frozenlist==1.4.1 30 | # via aiohttp 31 | # via aiosignal 32 | h11==0.14.0 33 | # via httpcore 34 | httpcore==1.0.5 35 | # via httpx 36 | httpx==0.27.0 37 | idna==3.7 38 | # via anyio 39 | # via httpx 40 | # via yarl 41 | jinja2==3.1.4 42 | # via aiohttp-jinja2 43 | # via textual-serve 44 | linkify-it-py==2.0.3 45 | # via markdown-it-py 46 | markdown-it-py==3.0.0 47 | # via mdit-py-plugins 48 | # via rich 49 | # via textual 50 | markupsafe==2.1.5 51 | # via jinja2 52 | mdit-py-plugins==0.4.1 53 | # via markdown-it-py 54 | mdurl==0.1.2 55 | # via markdown-it-py 56 | msgpack==1.0.8 57 | # via textual-dev 58 | multidict==6.0.5 59 | # via aiohttp 60 | # via yarl 61 | pygments==2.18.0 62 | # via rich 63 | rich==13.7.1 64 | # via textual 65 | # via textual-serve 66 | sniffio==1.3.1 67 | # via anyio 68 | # via httpx 69 | textual==0.78.0 70 | # via textual-dev 71 | # via textual-serve 72 | textual-dev==1.5.1 73 | typing-extensions==4.12.2 74 | # via textual 75 | # via textual-dev 76 | uc-micro-py==1.0.3 77 | # via linkify-it-py 78 | yarl==1.9.4 79 | # via aiohttp 80 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | -e file:. 12 | aiohttp==3.9.5 13 | # via aiohttp-jinja2 14 | # via textual-serve 15 | aiohttp-jinja2==1.6 16 | # via textual-serve 17 | aiosignal==1.3.1 18 | # via aiohttp 19 | attrs==23.2.0 20 | # via aiohttp 21 | frozenlist==1.4.1 22 | # via aiohttp 23 | # via aiosignal 24 | idna==3.7 25 | # via yarl 26 | jinja2==3.1.4 27 | # via aiohttp-jinja2 28 | # via textual-serve 29 | linkify-it-py==2.0.3 30 | # via markdown-it-py 31 | markdown-it-py==3.0.0 32 | # via mdit-py-plugins 33 | # via rich 34 | # via textual 35 | markupsafe==2.1.5 36 | # via jinja2 37 | mdit-py-plugins==0.4.1 38 | # via markdown-it-py 39 | mdurl==0.1.2 40 | # via markdown-it-py 41 | multidict==6.0.5 42 | # via aiohttp 43 | # via yarl 44 | pygments==2.18.0 45 | # via rich 46 | rich==13.7.1 47 | # via textual 48 | # via textual-serve 49 | textual==0.78.0 50 | # via textual-serve 51 | typing-extensions==4.12.2 52 | # via textual 53 | uc-micro-py==1.0.3 54 | # via linkify-it-py 55 | yarl==1.9.4 56 | # via aiohttp 57 | -------------------------------------------------------------------------------- /src/textual_serve/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/__init__.py -------------------------------------------------------------------------------- /src/textual_serve/_binary_encode.py: -------------------------------------------------------------------------------- 1 | """ 2 | An encoding / decoding format suitable for serializing data structures to binary. 3 | 4 | This is based on https://en.wikipedia.org/wiki/Bencode with some extensions. 5 | 6 | The following data types may be encoded: 7 | 8 | - None 9 | - int 10 | - bool 11 | - bytes 12 | - str 13 | - list 14 | - tuple 15 | - dict 16 | 17 | """ 18 | 19 | from __future__ import annotations 20 | 21 | from typing import Any, Callable 22 | 23 | 24 | class DecodeError(Exception): 25 | """A problem decoding data.""" 26 | 27 | 28 | def dump(data: object) -> bytes: 29 | """Encodes a data structure in to bytes. 30 | 31 | Args: 32 | data: Data structure 33 | 34 | Returns: 35 | A byte string encoding the data. 36 | """ 37 | 38 | def encode_none(_datum: None) -> bytes: 39 | """ 40 | Encodes a None value. 41 | 42 | Args: 43 | datum: Always None. 44 | 45 | Returns: 46 | None encoded. 47 | """ 48 | return b"N" 49 | 50 | def encode_bool(datum: bool) -> bytes: 51 | """ 52 | Encode a boolean value. 53 | 54 | Args: 55 | datum: The boolean value to encode. 56 | 57 | Returns: 58 | The encoded bytes. 59 | """ 60 | return b"T" if datum else b"F" 61 | 62 | def encode_int(datum: int) -> bytes: 63 | """ 64 | Encode an integer value. 65 | 66 | Args: 67 | datum: The integer value to encode. 68 | 69 | Returns: 70 | The encoded bytes. 71 | """ 72 | return b"i%ie" % datum 73 | 74 | def encode_bytes(datum: bytes) -> bytes: 75 | """ 76 | Encode a bytes value. 77 | 78 | Args: 79 | datum: The bytes value to encode. 80 | 81 | Returns: 82 | The encoded bytes. 83 | """ 84 | return b"%i:%s" % (len(datum), datum) 85 | 86 | def encode_string(datum: str) -> bytes: 87 | """ 88 | Encode a string value. 89 | 90 | Args: 91 | datum: The string value to encode. 92 | 93 | Returns: 94 | The encoded bytes. 95 | """ 96 | encoded_data = datum.encode("utf-8") 97 | return b"s%i:%s" % (len(encoded_data), encoded_data) 98 | 99 | def encode_list(datum: list) -> bytes: 100 | """ 101 | Encode a list value. 102 | 103 | Args: 104 | datum: The list value to encode. 105 | 106 | Returns: 107 | The encoded bytes. 108 | """ 109 | return b"l%se" % b"".join(encode(element) for element in datum) 110 | 111 | def encode_tuple(datum: tuple) -> bytes: 112 | """ 113 | Encode a tuple value. 114 | 115 | Args: 116 | datum: The tuple value to encode. 117 | 118 | Returns: 119 | The encoded bytes. 120 | """ 121 | return b"t%se" % b"".join(encode(element) for element in datum) 122 | 123 | def encode_dict(datum: dict) -> bytes: 124 | """ 125 | Encode a dictionary value. 126 | 127 | Args: 128 | datum: The dictionary value to encode. 129 | 130 | Returns: 131 | The encoded bytes. 132 | """ 133 | return b"d%se" % b"".join( 134 | b"%s%s" % (encode(key), encode(value)) for key, value in datum.items() 135 | ) 136 | 137 | ENCODERS: dict[type, Callable[[Any], Any]] = { 138 | type(None): encode_none, 139 | bool: encode_bool, 140 | int: encode_int, 141 | bytes: encode_bytes, 142 | str: encode_string, 143 | list: encode_list, 144 | tuple: encode_tuple, 145 | dict: encode_dict, 146 | } 147 | 148 | def encode(datum: object) -> bytes: 149 | """Recursively encode data. 150 | 151 | Args: 152 | datum: Data suitable for encoding. 153 | 154 | Raises: 155 | TypeError: If `datum` is not one of the supported types. 156 | 157 | Returns: 158 | Encoded data bytes. 159 | """ 160 | try: 161 | decoder = ENCODERS[type(datum)] 162 | except KeyError: 163 | raise TypeError("Can't encode {datum!r}") from None 164 | return decoder(datum) 165 | 166 | return encode(data) 167 | 168 | 169 | def load(encoded: bytes) -> object: 170 | """Load an encoded data structure from bytes. 171 | 172 | Args: 173 | encoded: Encoded data in bytes. 174 | 175 | Raises: 176 | DecodeError: If an error was encountered decoding the string. 177 | 178 | Returns: 179 | Decoded data. 180 | """ 181 | if not isinstance(encoded, bytes): 182 | raise TypeError("must be bytes") 183 | max_position = len(encoded) 184 | position = 0 185 | 186 | def get_byte() -> bytes: 187 | """Get an encoded byte and advance position. 188 | 189 | Raises: 190 | DecodeError: If the end of the data was reached 191 | 192 | Returns: 193 | A bytes object with a single byte. 194 | """ 195 | nonlocal position 196 | if position >= max_position: 197 | raise DecodeError("More data expected") 198 | character = encoded[position : position + 1] 199 | position += 1 200 | return character 201 | 202 | def peek_byte() -> bytes: 203 | """Get the byte at the current position, but don't advance position. 204 | 205 | Returns: 206 | A bytes object with a single byte. 207 | """ 208 | return encoded[position : position + 1] 209 | 210 | def get_bytes(size: int) -> bytes: 211 | """Get a number of bytes of encode data. 212 | 213 | Args: 214 | size: Number of bytes to retrieve. 215 | 216 | Raises: 217 | DecodeError: If there aren't enough bytes. 218 | 219 | Returns: 220 | A bytes object. 221 | """ 222 | nonlocal position 223 | bytes_data = encoded[position : position + size] 224 | if len(bytes_data) != size: 225 | raise DecodeError(b"Missing bytes in {bytes_data!r}") 226 | position += size 227 | return bytes_data 228 | 229 | def decode_int() -> int: 230 | """Decode an int from the encoded data. 231 | 232 | Returns: 233 | An integer. 234 | """ 235 | int_bytes = b"" 236 | while (byte := get_byte()) != b"e": 237 | int_bytes += byte 238 | return int(int_bytes) 239 | 240 | def decode_bytes(size_bytes: bytes) -> bytes: 241 | """Decode a bytes string from the encoded data. 242 | 243 | Returns: 244 | A bytes object. 245 | """ 246 | while (byte := get_byte()) != b":": 247 | size_bytes += byte 248 | bytes_string = get_bytes(int(size_bytes)) 249 | return bytes_string 250 | 251 | def decode_string() -> str: 252 | """Decode a (utf-8 encoded) string from the encoded data. 253 | 254 | Returns: 255 | A string. 256 | """ 257 | size_bytes = b"" 258 | while (byte := get_byte()) != b":": 259 | size_bytes += byte 260 | bytes_string = get_bytes(int(size_bytes)) 261 | decoded_string = bytes_string.decode("utf-8", errors="replace") 262 | return decoded_string 263 | 264 | def decode_list() -> list[object]: 265 | """Decode a list. 266 | 267 | Returns: 268 | A list of data. 269 | """ 270 | elements: list[object] = [] 271 | add_element = elements.append 272 | while peek_byte() != b"e": 273 | add_element(decode()) 274 | get_byte() 275 | return elements 276 | 277 | def decode_tuple() -> tuple[object, ...]: 278 | """Decode a tuple. 279 | 280 | Returns: 281 | A tuple of decoded data. 282 | """ 283 | elements: list[object] = [] 284 | add_element = elements.append 285 | while peek_byte() != b"e": 286 | add_element(decode()) 287 | get_byte() 288 | return tuple(elements) 289 | 290 | def decode_dict() -> dict[object, object]: 291 | """Decode a dict. 292 | 293 | Returns: 294 | A dict of decoded data. 295 | """ 296 | elements: dict[object, object] = {} 297 | add_element = elements.__setitem__ 298 | while peek_byte() != b"e": 299 | add_element(decode(), decode()) 300 | get_byte() 301 | return elements 302 | 303 | DECODERS = { 304 | b"i": decode_int, 305 | b"s": decode_string, 306 | b"l": decode_list, 307 | b"t": decode_tuple, 308 | b"d": decode_dict, 309 | b"T": lambda: True, 310 | b"F": lambda: False, 311 | b"N": lambda: None, 312 | } 313 | 314 | def decode() -> object: 315 | """Recursively decode data. 316 | 317 | Returns: 318 | Decoded data. 319 | """ 320 | decoder = DECODERS.get(initial := get_byte(), None) 321 | if decoder is None: 322 | return decode_bytes(initial) 323 | return decoder() 324 | 325 | return decode() 326 | -------------------------------------------------------------------------------- /src/textual_serve/app_service.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from pathlib import Path 3 | 4 | import asyncio 5 | import io 6 | import json 7 | import os 8 | from typing import Awaitable, Callable, Literal 9 | from asyncio.subprocess import Process 10 | import logging 11 | 12 | from importlib.metadata import version 13 | import uuid 14 | 15 | from textual_serve.download_manager import DownloadManager 16 | from textual_serve._binary_encode import load as binary_load 17 | 18 | log = logging.getLogger("textual-serve") 19 | 20 | 21 | class AppService: 22 | """Creates and manages a single Textual app subprocess. 23 | 24 | When a user connects to the websocket in their browser, a new AppService 25 | instance is created to manage the corresponding Textual app process. 26 | """ 27 | 28 | def __init__( 29 | self, 30 | command: str, 31 | *, 32 | write_bytes: Callable[[bytes], Awaitable[None]], 33 | write_str: Callable[[str], Awaitable[None]], 34 | close: Callable[[], Awaitable[None]], 35 | download_manager: DownloadManager, 36 | debug: bool = False, 37 | ) -> None: 38 | self.app_service_id: str = uuid.uuid4().hex 39 | """The unique ID of this running app service.""" 40 | self.command = command 41 | """The command to launch the Textual app subprocess.""" 42 | self.remote_write_bytes = write_bytes 43 | """Write bytes to the client browser websocket.""" 44 | self.remote_write_str = write_str 45 | """Write string to the client browser websocket.""" 46 | self.remote_close = close 47 | """Close the client browser websocket.""" 48 | self.debug = debug 49 | """Enable/disable debug mode.""" 50 | 51 | self._process: Process | None = None 52 | self._task: asyncio.Task[None] | None = None 53 | self._stdin: asyncio.StreamWriter | None = None 54 | self._exit_event = asyncio.Event() 55 | self._download_manager = download_manager 56 | 57 | @property 58 | def stdin(self) -> asyncio.StreamWriter: 59 | """The processes standard input stream.""" 60 | assert self._stdin is not None 61 | return self._stdin 62 | 63 | def _build_environment(self, width: int = 80, height: int = 24) -> dict[str, str]: 64 | """Build an environment dict for the App subprocess. 65 | 66 | Args: 67 | width: Initial width. 68 | height: Initial height. 69 | 70 | Returns: 71 | A environment dict. 72 | """ 73 | environment = dict(os.environ.copy()) 74 | environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver" 75 | environment["TEXTUAL_FPS"] = "60" 76 | environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor" 77 | environment["TERM_PROGRAM"] = "textual" 78 | environment["TERM_PROGRAM_VERSION"] = version("textual-serve") 79 | environment["COLUMNS"] = str(width) 80 | environment["ROWS"] = str(height) 81 | if self.debug: 82 | environment["TEXTUAL"] = "debug,devtools" 83 | environment["TEXTUAL_LOG"] = "textual.log" 84 | return environment 85 | 86 | async def _open_app_process(self, width: int = 80, height: int = 24) -> Process: 87 | """Open a process to run the app. 88 | 89 | Args: 90 | width: Width of the terminal. 91 | height: height of the terminal. 92 | """ 93 | environment = self._build_environment(width=width, height=height) 94 | self._process = process = await asyncio.create_subprocess_shell( 95 | self.command, 96 | stdin=asyncio.subprocess.PIPE, 97 | stdout=asyncio.subprocess.PIPE, 98 | stderr=asyncio.subprocess.PIPE, 99 | env=environment, 100 | ) 101 | assert process.stdin is not None 102 | self._stdin = process.stdin 103 | 104 | return process 105 | 106 | @classmethod 107 | def encode_packet(cls, packet_type: Literal[b"D", b"M"], payload: bytes) -> bytes: 108 | """Encode a packet. 109 | 110 | Args: 111 | packet_type: The packet type (b"D" for data or b"M" for meta) 112 | payload: The payload. 113 | 114 | Returns: 115 | Data as bytes. 116 | """ 117 | return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload) 118 | 119 | async def send_bytes(self, data: bytes) -> bool: 120 | """Send bytes to process. 121 | 122 | Args: 123 | data: Data to send. 124 | 125 | Returns: 126 | True if the data was sent, otherwise False. 127 | """ 128 | stdin = self.stdin 129 | try: 130 | stdin.write(self.encode_packet(b"D", data)) 131 | except RuntimeError: 132 | return False 133 | try: 134 | await stdin.drain() 135 | except Exception: 136 | return False 137 | return True 138 | 139 | async def send_meta(self, data: dict[str, str | None | int | bool]) -> bool: 140 | """Send meta information to process. 141 | 142 | Args: 143 | data: Meta dict to send. 144 | 145 | Returns: 146 | True if the data was sent, otherwise False. 147 | """ 148 | stdin = self.stdin 149 | data_bytes = json.dumps(data).encode("utf-8") 150 | try: 151 | stdin.write(self.encode_packet(b"M", data_bytes)) 152 | except RuntimeError: 153 | return False 154 | try: 155 | await stdin.drain() 156 | except Exception: 157 | return False 158 | return True 159 | 160 | async def set_terminal_size(self, width: int, height: int) -> None: 161 | """Tell the process about the new terminal size. 162 | 163 | Args: 164 | width: Width of terminal in cells. 165 | height: Height of terminal in cells. 166 | """ 167 | await self.send_meta( 168 | { 169 | "type": "resize", 170 | "width": width, 171 | "height": height, 172 | } 173 | ) 174 | 175 | async def blur(self) -> None: 176 | """Send an (app) blur to the process.""" 177 | await self.send_meta({"type": "blur"}) 178 | 179 | async def focus(self) -> None: 180 | """Send an (app) focus to the process.""" 181 | await self.send_meta({"type": "focus"}) 182 | 183 | async def start(self, width: int, height: int) -> None: 184 | await self._open_app_process(width, height) 185 | self._task = asyncio.create_task(self.run()) 186 | 187 | async def stop(self) -> None: 188 | """Stop the process and wait for it to complete.""" 189 | if self._task is not None: 190 | await self._download_manager.cancel_app_downloads( 191 | app_service_id=self.app_service_id 192 | ) 193 | 194 | await self.send_meta({"type": "quit"}) 195 | await self._task 196 | self._task = None 197 | 198 | async def run(self) -> None: 199 | """Run the Textual app process. 200 | 201 | !!! note 202 | 203 | Do not call this manually, use `start`. 204 | 205 | """ 206 | META = b"M" 207 | DATA = b"D" 208 | PACKED = b"P" 209 | 210 | assert self._process is not None 211 | process = self._process 212 | 213 | stdout = process.stdout 214 | stderr = process.stderr 215 | assert stdout is not None 216 | assert stderr is not None 217 | 218 | stderr_data = io.BytesIO() 219 | 220 | async def read_stderr() -> None: 221 | """Task to read stderr.""" 222 | try: 223 | while True: 224 | data = await stderr.read(1024 * 4) 225 | if not data: 226 | break 227 | stderr_data.write(data) 228 | except asyncio.CancelledError: 229 | pass 230 | 231 | stderr_task = asyncio.create_task(read_stderr()) 232 | 233 | try: 234 | ready = False 235 | # Wait for prelude text, so we know it is a Textual app 236 | for _ in range(10): 237 | if not (line := await stdout.readline()): 238 | break 239 | if line == b"__GANGLION__\n": 240 | ready = True 241 | break 242 | 243 | if not ready: 244 | log.error("Application failed to start") 245 | if error_text := stderr_data.getvalue(): 246 | import sys 247 | 248 | sys.stdout.write(error_text.decode("utf-8", "replace")) 249 | 250 | readexactly = stdout.readexactly 251 | int_from_bytes = int.from_bytes 252 | while True: 253 | type_bytes = await readexactly(1) 254 | size_bytes = await readexactly(4) 255 | size = int_from_bytes(size_bytes, "big") 256 | payload = await readexactly(size) 257 | if type_bytes == DATA: 258 | await self.on_data(payload) 259 | elif type_bytes == META: 260 | await self.on_meta(payload) 261 | elif type_bytes == PACKED: 262 | await self.on_packed(payload) 263 | 264 | except asyncio.IncompleteReadError: 265 | pass 266 | except ConnectionResetError: 267 | pass 268 | except asyncio.CancelledError: 269 | pass 270 | 271 | finally: 272 | stderr_task.cancel() 273 | await stderr_task 274 | 275 | if error_text := stderr_data.getvalue(): 276 | import sys 277 | 278 | sys.stdout.write(error_text.decode("utf-8", "replace")) 279 | 280 | async def on_data(self, payload: bytes) -> None: 281 | """Called when there is data. 282 | 283 | Args: 284 | payload: Data received from process. 285 | """ 286 | await self.remote_write_bytes(payload) 287 | 288 | async def on_meta(self, data: bytes) -> None: 289 | """Called when there is a meta packet sent from the running app process. 290 | 291 | Args: 292 | data: Encoded meta data. 293 | """ 294 | meta_data: dict[str, object] = json.loads(data) 295 | meta_type = meta_data["type"] 296 | 297 | if meta_type == "exit": 298 | await self.remote_close() 299 | elif meta_type == "open_url": 300 | payload = json.dumps( 301 | [ 302 | "open_url", 303 | { 304 | "url": meta_data["url"], 305 | "new_tab": meta_data["new_tab"], 306 | }, 307 | ] 308 | ) 309 | await self.remote_write_str(payload) 310 | elif meta_type == "deliver_file_start": 311 | log.debug("deliver_file_start, %s", meta_data) 312 | try: 313 | # Record this delivery key as available for download. 314 | delivery_key = str(meta_data["key"]) 315 | await self._download_manager.create_download( 316 | app_service=self, 317 | delivery_key=delivery_key, 318 | file_name=Path(meta_data["path"]).name, 319 | open_method=meta_data["open_method"], 320 | mime_type=meta_data["mime_type"], 321 | encoding=meta_data["encoding"], 322 | name=meta_data.get("name", None), 323 | ) 324 | except KeyError: 325 | log.error("Missing key in `deliver_file_start` meta packet") 326 | return 327 | else: 328 | # Tell the browser front-end about the new delivery key, 329 | # so that it may hit the "/download/{key}" endpoint 330 | # to start the download. 331 | json_string = json.dumps(["deliver_file_start", delivery_key]) 332 | await self.remote_write_str(json_string) 333 | else: 334 | log.warning( 335 | f"Unknown meta type: {meta_type!r}. You may need to update `textual-serve`." 336 | ) 337 | 338 | async def on_packed(self, payload: bytes) -> None: 339 | """Called when there is a packed packet sent from the running app process. 340 | 341 | Args: 342 | payload: Encoded packed data. 343 | """ 344 | unpacked = binary_load(payload) 345 | if unpacked[0] == "deliver_chunk": 346 | # If we receive a chunk, hand it to the download manager to 347 | # handle distribution to the browser. 348 | _, delivery_key, chunk = unpacked 349 | await self._download_manager.chunk_received(delivery_key, chunk) 350 | -------------------------------------------------------------------------------- /src/textual_serve/download_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | import logging 6 | from typing import AsyncGenerator, TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from textual_serve.app_service import AppService 10 | 11 | log = logging.getLogger("textual-serve") 12 | 13 | DOWNLOAD_TIMEOUT = 4 14 | DOWNLOAD_CHUNK_SIZE = 1024 * 64 # 64 KB 15 | 16 | 17 | @dataclass 18 | class Download: 19 | app_service: "AppService" 20 | """The app service that the download is associated with.""" 21 | 22 | delivery_key: str 23 | """Key which identifies the download.""" 24 | 25 | file_name: str 26 | """The name of the file to download. This will be used to set 27 | the Content-Disposition filename.""" 28 | 29 | open_method: str 30 | """The method to open the file with. "browser" or "download".""" 31 | 32 | mime_type: str 33 | """The mime type of the content.""" 34 | 35 | encoding: str | None = None 36 | """The encoding of the content. 37 | Will be None if the content is binary. 38 | """ 39 | 40 | name: str | None = None 41 | """Optional name set bt the client.""" 42 | 43 | incoming_chunks: asyncio.Queue[bytes | None] = field(default_factory=asyncio.Queue) 44 | """A queue of incoming chunks for the download. 45 | Chunks are sent from the app service to the download handler 46 | via this queue.""" 47 | 48 | 49 | class DownloadManager: 50 | """Class which manages downloads for the server. 51 | 52 | Serves as the link between the web server and app processes during downloads. 53 | 54 | A single server has a single download manager, which manages all downloads for all 55 | running app processes. 56 | """ 57 | 58 | def __init__(self) -> None: 59 | self._active_downloads: dict[str, Download] = {} 60 | """A dictionary of active downloads. 61 | 62 | When a delivery key is received in a meta packet, it is added to this set. 63 | When the user hits the "/download/{key}" endpoint, we ensure the key is in 64 | this set and start the download by requesting chunks from the app process. 65 | 66 | When the download is complete, the app process sends a "deliver_file_end" 67 | meta packet, and we remove the key from this set. 68 | """ 69 | 70 | async def create_download( 71 | self, 72 | *, 73 | app_service: "AppService", 74 | delivery_key: str, 75 | file_name: str, 76 | open_method: str, 77 | mime_type: str, 78 | encoding: str | None = None, 79 | name: str | None = None, 80 | ) -> None: 81 | """Prepare for a new download. 82 | 83 | Args: 84 | app_service: The app service to start the download for. 85 | delivery_key: The delivery key to start the download for. 86 | file_name: The name of the file to download. 87 | open_method: The method to open the file with. 88 | mime_type: The mime type of the content. 89 | encoding: The encoding of the content or None if the content is binary. 90 | """ 91 | self._active_downloads[delivery_key] = Download( 92 | app_service, 93 | delivery_key, 94 | file_name, 95 | open_method, 96 | mime_type, 97 | encoding, 98 | name=name, 99 | ) 100 | 101 | async def download(self, delivery_key: str) -> AsyncGenerator[bytes, None]: 102 | """Download a file from the given app service. 103 | 104 | Args: 105 | delivery_key: The delivery key to download. 106 | """ 107 | 108 | app_service = await self._get_app_service(delivery_key) 109 | download = self._active_downloads[delivery_key] 110 | incoming_chunks = download.incoming_chunks 111 | 112 | while True: 113 | # Request a chunk from the app service. 114 | send_result = await app_service.send_meta( 115 | { 116 | "type": "deliver_chunk_request", 117 | "key": delivery_key, 118 | "size": DOWNLOAD_CHUNK_SIZE, 119 | "name": download.name, 120 | } 121 | ) 122 | 123 | if not send_result: 124 | log.warning( 125 | "Download {delivery_key!r} failed to request chunk from app service" 126 | ) 127 | del self._active_downloads[delivery_key] 128 | break 129 | 130 | try: 131 | chunk = await asyncio.wait_for(incoming_chunks.get(), DOWNLOAD_TIMEOUT) 132 | except asyncio.TimeoutError: 133 | log.warning( 134 | "Download %r failed to receive chunk from app service within %r seconds", 135 | delivery_key, 136 | DOWNLOAD_TIMEOUT, 137 | ) 138 | chunk = None 139 | 140 | if not chunk: 141 | # Empty chunk - the app process has finished sending the file 142 | # or the download has been cancelled. 143 | incoming_chunks.task_done() 144 | del self._active_downloads[delivery_key] 145 | break 146 | else: 147 | incoming_chunks.task_done() 148 | yield chunk 149 | 150 | async def chunk_received(self, delivery_key: str, chunk: bytes | str) -> None: 151 | """Handle a chunk received from the app service for a download. 152 | 153 | Args: 154 | delivery_key: The delivery key that the chunk was received for. 155 | chunk: The chunk that was received. 156 | """ 157 | 158 | download = self._active_downloads.get(delivery_key) 159 | if not download: 160 | # The download may have been cancelled - e.g. the websocket 161 | # was closed before the download could complete. 162 | log.debug("Chunk received for cancelled download %r", delivery_key) 163 | return 164 | 165 | if isinstance(chunk, str): 166 | chunk = chunk.encode(download.encoding or "utf-8") 167 | await download.incoming_chunks.put(chunk) 168 | 169 | async def _get_app_service(self, delivery_key: str) -> "AppService": 170 | """Get the app service that the given delivery key is linked to. 171 | 172 | Args: 173 | delivery_key: The delivery key to get the app service for. 174 | """ 175 | for key in self._active_downloads.keys(): 176 | if key == delivery_key: 177 | return self._active_downloads[key].app_service 178 | else: 179 | raise ValueError(f"No active download for delivery key {delivery_key!r}") 180 | 181 | async def get_download_metadata(self, delivery_key: str) -> Download: 182 | """Get the metadata for a download. 183 | 184 | Args: 185 | delivery_key: The delivery key to get the metadata for. 186 | """ 187 | return self._active_downloads[delivery_key] 188 | 189 | async def cancel_app_downloads(self, app_service_id: str) -> None: 190 | """Cancel all downloads for the given app service. 191 | 192 | Args: 193 | app_service_id: The app service ID to cancel downloads for. 194 | """ 195 | for download in self._active_downloads.values(): 196 | if download.app_service.app_service_id == app_service_id: 197 | await download.incoming_chunks.put(None) 198 | -------------------------------------------------------------------------------- /src/textual_serve/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/py.typed -------------------------------------------------------------------------------- /src/textual_serve/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | import logging 6 | import os 7 | from pathlib import Path 8 | import signal 9 | import sys 10 | 11 | from typing import Any 12 | 13 | import aiohttp_jinja2 14 | from aiohttp import web 15 | from aiohttp import WSMsgType 16 | from aiohttp.web_runner import GracefulExit 17 | import jinja2 18 | 19 | from importlib.metadata import version 20 | 21 | from rich.console import Console 22 | from rich.logging import RichHandler 23 | from rich.highlighter import RegexHighlighter 24 | 25 | from textual_serve.download_manager import DownloadManager 26 | 27 | from .app_service import AppService 28 | 29 | log = logging.getLogger("textual-serve") 30 | 31 | LOGO = r"""[bold magenta]___ ____ _ _ ___ _ _ ____ _ ____ ____ ____ _ _ ____ 32 | | |___ \/ | | | |__| | __ [__ |___ |__/ | | |___ 33 | | |___ _/\_ | |__| | | |___ ___] |___ | \ \/ |___ [not bold]VVVVV 34 | """.replace("VVVVV", f"v{version('textual-serve')}") 35 | 36 | 37 | WINDOWS = sys.platform == "WINDOWS" 38 | 39 | 40 | class LogHighlighter(RegexHighlighter): 41 | base_style = "repr." 42 | highlights = [ 43 | r"(?P(?\[.*?\])", 45 | r"(?b?'''.*?(? int: 50 | """Convert to an integer, or return a default if that's not possible. 51 | 52 | Args: 53 | number: A string possibly containing a decimal. 54 | default: Default value if value can't be decoded. 55 | 56 | Returns: 57 | Integer. 58 | """ 59 | try: 60 | return int(value) 61 | except ValueError: 62 | return default 63 | 64 | 65 | class Server: 66 | """Serve a Textual app.""" 67 | 68 | def __init__( 69 | self, 70 | command: str, 71 | host: str = "localhost", 72 | port: int = 8000, 73 | title: str | None = None, 74 | public_url: str | None = None, 75 | statics_path: str | os.PathLike = "./static", 76 | templates_path: str | os.PathLike = "./templates", 77 | ): 78 | """ 79 | 80 | Args: 81 | app_factory: A callable that returns a new App instance. 82 | host: Host of web application. 83 | port: Port for server. 84 | statics_path: Path to statics folder. May be absolute or relative to server.py. 85 | templates_path" Path to templates folder. May be absolute or relative to server.py. 86 | """ 87 | self.command = command 88 | self.host = host 89 | self.port = port 90 | self.title = title or command 91 | self.debug = False 92 | 93 | if public_url is None: 94 | if self.port == 80: 95 | self.public_url = f"http://{self.host}" 96 | elif self.port == 443: 97 | self.public_url = f"https://{self.host}" 98 | else: 99 | self.public_url = f"http://{self.host}:{self.port}" 100 | else: 101 | self.public_url = public_url 102 | 103 | base_path = (Path(__file__) / "../").resolve().absolute() 104 | self.statics_path = base_path / statics_path 105 | self.templates_path = base_path / templates_path 106 | self.console = Console() 107 | self.download_manager = DownloadManager() 108 | 109 | def initialize_logging(self) -> None: 110 | """Initialize logging. 111 | 112 | May be overridden in a subclass. 113 | """ 114 | FORMAT = "%(message)s" 115 | logging.basicConfig( 116 | level="DEBUG" if self.debug else "INFO", 117 | format=FORMAT, 118 | datefmt="[%X]", 119 | handlers=[ 120 | RichHandler( 121 | show_path=False, 122 | show_time=False, 123 | rich_tracebacks=True, 124 | tracebacks_show_locals=True, 125 | highlighter=LogHighlighter(), 126 | console=self.console, 127 | ) 128 | ], 129 | ) 130 | 131 | def request_exit(self) -> None: 132 | """Gracefully exit the app.""" 133 | raise GracefulExit() 134 | 135 | async def _make_app(self) -> web.Application: 136 | """Make the aiohttp web.Application. 137 | 138 | Returns: 139 | New aiohttp web application. 140 | """ 141 | app = web.Application() 142 | 143 | aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.templates_path)) 144 | 145 | ROUTES = [ 146 | web.get("/", self.handle_index, name="index"), 147 | web.get("/ws", self.handle_websocket, name="websocket"), 148 | web.get("/download/{key}", self.handle_download, name="download"), 149 | web.static("/static", self.statics_path, show_index=True, name="static"), 150 | ] 151 | app.add_routes(ROUTES) 152 | 153 | app.on_startup.append(self.on_startup) 154 | app.on_shutdown.append(self.on_shutdown) 155 | return app 156 | 157 | async def handle_download(self, request: web.Request) -> web.StreamResponse: 158 | """Handle a download request.""" 159 | key = request.match_info["key"] 160 | 161 | try: 162 | download_meta = await self.download_manager.get_download_metadata(key) 163 | except KeyError: 164 | raise web.HTTPNotFound(text=f"Download with key {key!r} not found") 165 | 166 | response = web.StreamResponse() 167 | mime_type = download_meta.mime_type 168 | 169 | content_type = mime_type 170 | if download_meta.encoding: 171 | content_type += f"; charset={download_meta.encoding}" 172 | 173 | response.headers["Content-Type"] = content_type 174 | disposition = ( 175 | "inline" if download_meta.open_method == "browser" else "attachment" 176 | ) 177 | response.headers["Content-Disposition"] = ( 178 | f"{disposition}; filename={download_meta.file_name}" 179 | ) 180 | 181 | await response.prepare(request) 182 | 183 | async for chunk in self.download_manager.download(key): 184 | await response.write(chunk) 185 | 186 | await response.write_eof() 187 | return response 188 | 189 | async def on_shutdown(self, app: web.Application) -> None: 190 | """Called on shutdown. 191 | 192 | Args: 193 | app: App instance. 194 | """ 195 | 196 | async def on_startup(self, app: web.Application) -> None: 197 | """Called on startup. 198 | 199 | Args: 200 | app: App instance. 201 | """ 202 | 203 | self.console.print(LOGO, highlight=False) 204 | self.console.print(f"Serving {self.command!r} on {self.public_url}") 205 | self.console.print("\n[cyan]Press Ctrl+C to quit") 206 | 207 | def serve(self, debug: bool = False) -> None: 208 | """Serve the Textual application. 209 | 210 | This will run a local webserver until it is closed with Ctrl+C 211 | 212 | """ 213 | self.debug = debug 214 | self.initialize_logging() 215 | 216 | loop = asyncio.get_event_loop() 217 | try: 218 | loop.add_signal_handler(signal.SIGINT, self.request_exit) 219 | loop.add_signal_handler(signal.SIGTERM, self.request_exit) 220 | except NotImplementedError: 221 | pass 222 | 223 | if self.debug: 224 | log.info("Running in debug mode. You may use textual dev tools.") 225 | 226 | web.run_app( 227 | self._make_app(), 228 | host=self.host, 229 | port=self.port, 230 | handle_signals=False, 231 | loop=loop, 232 | print=lambda *args: None, 233 | ) 234 | 235 | @aiohttp_jinja2.template("app_index.html") 236 | async def handle_index(self, request: web.Request) -> dict[str, Any]: 237 | """Serves the HTML for an app. 238 | 239 | Args: 240 | request: Request object. 241 | 242 | Returns: 243 | Template data. 244 | """ 245 | router = request.app.router 246 | font_size = to_int(request.query.get("fontsize", "16"), 16) 247 | 248 | def get_url(route: str, **args) -> str: 249 | """Get a URL from the aiohttp router.""" 250 | path = router[route].url_for(**args) 251 | return f"{self.public_url}{path}" 252 | 253 | def get_websocket_url(route: str, **args) -> str: 254 | """Get a URL with a websocket prefix.""" 255 | url = get_url(route, **args) 256 | 257 | if self.public_url.startswith("https"): 258 | return "wss:" + url.split(":", 1)[1] 259 | else: 260 | return "ws:" + url.split(":", 1)[1] 261 | 262 | context = { 263 | "font_size": font_size, 264 | "app_websocket_url": get_websocket_url("websocket"), 265 | } 266 | context["config"] = { 267 | "static": { 268 | "url": get_url("static", filename="/").rstrip("/") + "/", 269 | }, 270 | } 271 | context["application"] = { 272 | "name": self.title, 273 | } 274 | return context 275 | 276 | async def _process_messages( 277 | self, websocket: web.WebSocketResponse, app_service: AppService 278 | ) -> None: 279 | """Process messages from the client browser websocket. 280 | 281 | Args: 282 | websocket: Websocket instance. 283 | app_service: App service. 284 | """ 285 | TEXT = WSMsgType.TEXT 286 | 287 | async for message in websocket: 288 | if message.type != TEXT: 289 | continue 290 | envelope = message.json() 291 | assert isinstance(envelope, list) 292 | type_ = envelope[0] 293 | if type_ == "stdin": 294 | data = envelope[1] 295 | await app_service.send_bytes(data.encode("utf-8")) 296 | elif type_ == "resize": 297 | data = envelope[1] 298 | await app_service.set_terminal_size(data["width"], data["height"]) 299 | elif type_ == "ping": 300 | data = envelope[1] 301 | await websocket.send_json(["pong", data]) 302 | elif type_ == "blur": 303 | await app_service.blur() 304 | elif type_ == "focus": 305 | await app_service.focus() 306 | 307 | async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse: 308 | """Handle the websocket that drives the remote process. 309 | 310 | This is called when the browser connects to the websocket. 311 | 312 | Args: 313 | request: Request object. 314 | 315 | Returns: 316 | Websocket response. 317 | """ 318 | websocket = web.WebSocketResponse(heartbeat=15) 319 | 320 | width = to_int(request.query.get("width", "80"), 80) 321 | height = to_int(request.query.get("height", "24"), 24) 322 | 323 | app_service: AppService | None = None 324 | try: 325 | await websocket.prepare(request) 326 | app_service = AppService( 327 | self.command, 328 | write_bytes=websocket.send_bytes, 329 | write_str=websocket.send_str, 330 | close=websocket.close, 331 | download_manager=self.download_manager, 332 | debug=self.debug, 333 | ) 334 | await app_service.start(width, height) 335 | try: 336 | await self._process_messages(websocket, app_service) 337 | finally: 338 | await app_service.stop() 339 | 340 | except asyncio.CancelledError: 341 | await websocket.close() 342 | 343 | except Exception as error: 344 | log.exception(error) 345 | 346 | finally: 347 | if app_service is not None: 348 | await app_service.stop() 349 | 350 | return websocket 351 | -------------------------------------------------------------------------------- /src/textual_serve/static/css/xterm.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2014 The xterm.js authors. All rights reserved. 3 | * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) 4 | * https://github.com/chjj/term.js 5 | * @license MIT 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy 8 | * of this software and associated documentation files (the "Software"), to deal 9 | * in the Software without restriction, including without limitation the rights 10 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | * copies of the Software, and to permit persons to whom the Software is 12 | * furnished to do so, subject to the following conditions: 13 | * 14 | * The above copyright notice and this permission notice shall be included in 15 | * all copies or substantial portions of the Software. 16 | * 17 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | * THE SOFTWARE. 24 | * 25 | * Originally forked from (with the author's permission): 26 | * Fabrice Bellard's javascript vt100 for jslinux: 27 | * http://bellard.org/jslinux/ 28 | * Copyright (c) 2011 Fabrice Bellard 29 | * The original design remains. The terminal itself 30 | * has been extended to include xterm CSI codes, among 31 | * other features. 32 | */ 33 | 34 | /** 35 | * Default styles for xterm.js 36 | */ 37 | 38 | .xterm { 39 | cursor: text; 40 | position: relative; 41 | user-select: none; 42 | -ms-user-select: none; 43 | -webkit-user-select: none; 44 | } 45 | 46 | .xterm.focus, 47 | .xterm:focus { 48 | outline: none; 49 | } 50 | 51 | .xterm .xterm-helpers { 52 | position: absolute; 53 | top: 0; 54 | /** 55 | * The z-index of the helpers must be higher than the canvases in order for 56 | * IMEs to appear on top. 57 | */ 58 | z-index: 5; 59 | } 60 | 61 | .xterm .xterm-helper-textarea { 62 | padding: 0; 63 | border: 0; 64 | margin: 0; 65 | /* Move textarea out of the screen to the far left, so that the cursor is not visible */ 66 | position: absolute; 67 | opacity: 0; 68 | left: -9999em; 69 | top: 0; 70 | width: 0; 71 | height: 0; 72 | z-index: -5; 73 | /** Prevent wrapping so the IME appears against the textarea at the correct position */ 74 | white-space: nowrap; 75 | overflow: hidden; 76 | resize: none; 77 | } 78 | 79 | .xterm .composition-view { 80 | /* TODO: Composition position got messed up somewhere */ 81 | background: #000; 82 | color: #FFF; 83 | display: none; 84 | position: absolute; 85 | white-space: nowrap; 86 | z-index: 1; 87 | } 88 | 89 | .xterm .composition-view.active { 90 | display: block; 91 | } 92 | 93 | .xterm .xterm-viewport { 94 | /* On OS X this is required in order for the scroll bar to appear fully opaque */ 95 | background-color: #000; 96 | overflow-y: scroll; 97 | cursor: default; 98 | position: absolute; 99 | right: 0; 100 | left: 0; 101 | top: 0; 102 | bottom: 0; 103 | } 104 | 105 | .xterm .xterm-screen { 106 | position: relative; 107 | } 108 | 109 | .xterm .xterm-screen canvas { 110 | position: absolute; 111 | left: 0; 112 | top: 0; 113 | } 114 | 115 | .xterm .xterm-scroll-area { 116 | visibility: hidden; 117 | } 118 | 119 | .xterm-char-measure-element { 120 | display: inline-block; 121 | visibility: hidden; 122 | position: absolute; 123 | top: 0; 124 | left: -9999em; 125 | line-height: normal; 126 | } 127 | 128 | .xterm.enable-mouse-events { 129 | /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ 130 | cursor: default; 131 | } 132 | 133 | .xterm.xterm-cursor-pointer, 134 | .xterm .xterm-cursor-pointer { 135 | cursor: pointer; 136 | } 137 | 138 | .xterm.column-select.focus { 139 | /* Column selection mode */ 140 | cursor: crosshair; 141 | } 142 | 143 | .xterm .xterm-accessibility, 144 | .xterm .xterm-message { 145 | position: absolute; 146 | left: 0; 147 | top: 0; 148 | bottom: 0; 149 | z-index: 10; 150 | color: transparent; 151 | } 152 | 153 | .xterm .live-region { 154 | position: absolute; 155 | left: -9999px; 156 | width: 1px; 157 | height: 1px; 158 | overflow: hidden; 159 | } 160 | 161 | .xterm-dim { 162 | opacity: 0.5; 163 | } 164 | 165 | .xterm-underline-1 { text-decoration: underline; } 166 | .xterm-underline-2 { text-decoration: double underline; } 167 | .xterm-underline-3 { text-decoration: wavy underline; } 168 | .xterm-underline-4 { text-decoration: dotted underline; } 169 | .xterm-underline-5 { text-decoration: dashed underline; } 170 | 171 | .xterm-strikethrough { 172 | text-decoration: line-through; 173 | } 174 | 175 | .xterm-screen .xterm-decoration-container .xterm-decoration { 176 | z-index: 6; 177 | position: absolute; 178 | } 179 | 180 | .xterm-decoration-overview-ruler { 181 | z-index: 7; 182 | position: absolute; 183 | top: 0; 184 | right: 0; 185 | pointer-events: none; 186 | } 187 | 188 | .xterm-decoration-top { 189 | z-index: 2; 190 | position: relative; 191 | } 192 | -------------------------------------------------------------------------------- /src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/textual_serve/static/images/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/static/images/background.png -------------------------------------------------------------------------------- /src/textual_serve/templates/app_index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 103 | 121 | 122 | 123 |
124 |
125 |
126 | 132 | 138 | 139 | 140 |
{{ application.name or 'Textual Application' }}
141 | 142 |
143 |
144 | 145 |
146 |
147 |
148 |
Session ended.
149 | 150 |
151 |
152 | 153 |
159 | 160 | 161 | --------------------------------------------------------------------------------