├── .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 |     
 13 |       This is Posting  running in the terminal.
 14 |      
 15 |           
 16 |        
 18 |    
 19 |   
 20 |     
 21 |       This is Posting  running in the browser .
 22 |      
 23 |         
 24 |            
 26 |      
 27 | 
 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 |     
124 |       
125 |       
126 |         
132 |            
139 | 
140 |         
{{ application.name or 'Textual Application' }}
141 |         
Start 
142 |       
143 |     
146 |       
147 |       
148 |         
Session ended.
149 |         
Restart 
150 |       
151 |     
159 |   
160 | 
161 | 
--------------------------------------------------------------------------------