├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── calculator.css ├── calculator.py ├── download.py ├── env.py ├── ganglion.toml └── open_link.py ├── poetry.lock ├── pyproject.toml └── src └── textual_web ├── __init__.py ├── _two_way_dict.py ├── app_session.py ├── apps ├── merlin.py ├── signup.py ├── signup.tcss └── welcome.py ├── cli.py ├── config.py ├── constants.py ├── environment.py ├── exit_poller.py ├── ganglion_client.py ├── identity.py ├── packets.py ├── poller.py ├── retry.py ├── session.py ├── session_manager.py ├── slugify.py ├── terminal_session.py ├── types.py └── web.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 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 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 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 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | .vscode 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Version 1.1.0 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 7 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | 10 | ## [0.7.0] - 2024-02-20 11 | 12 | ### Changed 13 | 14 | - Now requires Python >= 3.8 15 | - Bumped dependencies 16 | 17 | ## [0.6.0] - 2023-11-28 18 | 19 | ### Added 20 | 21 | - Added app focus 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 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-web 2 | 3 | Textual Web publishes [Textual](https://github.com/Textualize/textual) apps and terminals on the web. 4 | 5 | Currently in a beta phase — help us test! 6 | 7 | [Hacker News discussion](https://news.ycombinator.com/item?id=37418424) 8 | 9 | ## Getting Started 10 | 11 | Textual Web is a Python application, but you don't need to be a Python developer to run it. 12 | 13 | The easiest way to install Textual Web is via [pipx](https://pypa.github.io/pipx/). 14 | Once you have pipx installed, run the following command: 15 | 16 | ```python 17 | pipx install textual-web 18 | ``` 19 | 20 | You will now have the `textual-web` command on your path. 21 | 22 | ## Run a test 23 | 24 | To see what Textual Web does, run the following at the command line: 25 | 26 | ```bash 27 | textual-web 28 | ``` 29 | 30 | You should see something like the following: 31 | 32 | Screenshot 2023-09-06 at 10 11 07 33 | 34 | 35 | Click the blue links to launch the example Textual apps (you may need to hold cmd or ctrl on some terminals). 36 | Or copy the link to your browser if your terminal doesn't support links. 37 | 38 | You should see something like this in your browser: 39 | 40 | Screenshot 2023-08-22 at 09 41 35 41 | 42 | Screenshot 2023-09-06 at 10 10 01 43 | 44 | These Textual apps are running on your machine, but have public URLs. 45 | You could send the URLs to anyone with internet access, and they would see the same thing. 46 | 47 | Hit ctrl+C in the terminal to stop serving the welcome application. 48 | 49 | ## Serving a terminal 50 | 51 | Textual Web can also serve your terminal. For quick access add the `-t` switch: 52 | 53 | ```bash 54 | textual-web -t 55 | ``` 56 | 57 | This will generate another URL, which will present you with your terminal in your browser: 58 | 59 | 60 | Screenshot 2023-08-22 at 09 42 23 61 | 62 | 63 | When you serve a terminal in this way it will generate a random public URL. 64 | 65 | > [!WARNING] 66 | > Don't share this with anyone you wouldn't trust to have access to your machine. 67 | 68 | 69 | ## Configuration 70 | 71 | Textual Web can serve multiple [Textual](https://github.com/Textualize/textual) apps and terminals (as many as you like). 72 | 73 | To demonstrate this, [install Textual](https://textual.textualize.io/getting_started/) and clone the repository. 74 | Navigate to the `textual/examples` directory and add the following TOML file: 75 | 76 | ```toml 77 | [app.Calculator] 78 | command = "python calculator.py" 79 | 80 | [app.Dictionary] 81 | command = "python dictionary.py" 82 | ``` 83 | 84 | The name is unimportant, but let's say you called it "serve.toml". 85 | Use the `--config` switch to load the new configuration: 86 | 87 | ```bash 88 | textual-web --config serve.toml 89 | ``` 90 | 91 | You should now get 3 links, one for each of the sections in the configuration: 92 | 93 | 94 | Screenshot 2023-08-22 at 10 37 59 95 | 96 | 97 | Click any of the links to serve the respective app: 98 | 99 | 100 | Screenshot 2023-08-22 at 10 42 25 101 | 102 | ### Slugs 103 | 104 | Textual Web will derive the slug (text in the URL) from the name of the app. 105 | You can also set it explicitly with the slug parameter. 106 | 107 | ```toml 108 | [app.Calculator] 109 | command = "python calculator.py" 110 | slug = "calc" 111 | ``` 112 | 113 | ### Terminal configuration 114 | 115 | > [!NOTE] 116 | > Terminals currently work on macOS and Linux only. Windows support is planned for a future update. 117 | 118 | You can also add terminals to the configuration file, in a similar way. 119 | 120 | ```toml 121 | [terminal.Terminal] 122 | ``` 123 | 124 | This will launch a terminal with your current shell. 125 | You can also add a `command` value to run a command other than your shell. 126 | For instance, let's say we want to serve the `htop` command. 127 | We could add the following to the configuration: 128 | 129 | ```toml 130 | [terminal.HTOP] 131 | command = "htop" 132 | ``` 133 | 134 | ## Accounts 135 | 136 | In previous examples, the URLs all contained a random string of digits which will change from run to run. 137 | If you want to create a permanent URL you will need to create an account. 138 | 139 | To create an account, run the following command: 140 | 141 | 142 | ```bash 143 | textual-web --signup 144 | ``` 145 | 146 | This will bring up a dialog in your terminal that looks something like this: 147 | 148 | 149 | Screenshot 2023-08-22 at 09 43 03 150 | 151 | 152 | If you fill in that dialog, it will create an account for you and generate a file called "ganglion.toml". 153 | At the top of that file you will see a section like the following: 154 | 155 | ```toml 156 | [account] 157 | api_key = "JSKK234LLNWEDSSD" 158 | ``` 159 | 160 | You can add that to your configuration file, or edit "ganglion.toml" with your apps / terminals. 161 | Run it as you did previously: 162 | 163 | ```bash 164 | textual-web --config ganglion.toml 165 | ``` 166 | 167 | Now the URLs generated by `textual-web` will contain your account slug in the first part of the path. 168 | The account slug won't change, so you will get the same URLs from one run to the next. 169 | 170 | ## Debugging 171 | 172 | For a little more visibility on what is going on "under the hood", set the `DEBUG` environment variable: 173 | 174 | ``` 175 | DEBUG=1 textual-web --config ganglion.toml 176 | ``` 177 | 178 | Note this may generate a lot of output, and it may even slow your apps down. 179 | 180 | ## Known problems 181 | 182 | You may encounter a glitch with apps that have a lot of colors. 183 | This is a bug in an upstream library, which we are expecting a fix for soon. 184 | 185 | The experience on mobile may vary. 186 | On iPhone Textual apps are quite usable, but other systems may have a few issues. 187 | We should be able to improve the mobile exprience in future updates. 188 | 189 | ## What's next? 190 | 191 | The goal of this project is to turn Textual apps into fully featured web applications. 192 | 193 | Currently serving Textual apps and terminals appears very similar. 194 | In fact, if you serve a terminal and then launch a Textual app, it will work just fine in the browser. 195 | Under the hood, however, Textual apps are served using a custom protocol. 196 | This protocol will be used to expose web application features to the Textual app. 197 | 198 | For example, a Textual app might generate a file (say a CSV with a server report). 199 | If you run that in the terminal, the file would be saved in your working directory. 200 | But in a Textual app it would be served and saved in your Downloads folder, like a regular web app. 201 | 202 | In the future, other web APIs can be exposed to Textual apps in a similar way. 203 | 204 | Also planned for the near future is *sessions*. 205 | Currently, if you close the browser tab it will also close the Textual app. 206 | In the future you will be able to close a tab and later resume where you left off. 207 | This will also allow us to upgrade servers without kicking anyone off. 208 | 209 | ## Help us test 210 | 211 | Currently testing is being coordinated via our [Discord server](https://discord.com/invite/Enf6Z3qhVr). 212 | Join us if you would like to participate. 213 | -------------------------------------------------------------------------------- /examples/calculator.css: -------------------------------------------------------------------------------- 1 | Screen { 2 | overflow: auto; 3 | } 4 | 5 | #calculator { 6 | layout: grid; 7 | grid-size: 4; 8 | grid-gutter: 1 2; 9 | grid-columns: 1fr; 10 | grid-rows: 2fr 1fr 1fr 1fr 1fr 1fr; 11 | margin: 1 2; 12 | min-height: 25; 13 | min-width: 26; 14 | height: 100%; 15 | } 16 | 17 | Button { 18 | width: 100%; 19 | height: 100%; 20 | } 21 | 22 | #numbers { 23 | column-span: 4; 24 | content-align: right middle; 25 | padding: 0 1; 26 | height: 100%; 27 | background: $primary-lighten-2; 28 | color: $text; 29 | } 30 | 31 | #number-0 { 32 | column-span: 2; 33 | } 34 | -------------------------------------------------------------------------------- /examples/calculator.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from textual import events 4 | from textual.app import App, ComposeResult 5 | from textual.containers import Container 6 | from textual.css.query import NoMatches 7 | from textual.reactive import var 8 | from textual.widgets import Button, Static 9 | 10 | 11 | class CalculatorApp(App): 12 | """A working 'desktop' calculator.""" 13 | 14 | CSS_PATH = "calculator.css" 15 | 16 | numbers = var("0") 17 | show_ac = var(True) 18 | left = var(Decimal("0")) 19 | right = var(Decimal("0")) 20 | value = var("") 21 | operator = var("plus") 22 | 23 | NAME_MAP = { 24 | "asterisk": "multiply", 25 | "slash": "divide", 26 | "underscore": "plus-minus", 27 | "full_stop": "point", 28 | "plus_minus_sign": "plus-minus", 29 | "percent_sign": "percent", 30 | "equals_sign": "equals", 31 | "minus": "minus", 32 | "plus": "plus", 33 | } 34 | 35 | def watch_numbers(self, value: str) -> None: 36 | """Called when numbers is updated.""" 37 | # Update the Numbers widget 38 | self.query_one("#numbers", Static).update(value) 39 | 40 | def compute_show_ac(self) -> bool: 41 | """Compute switch to show AC or C button""" 42 | return self.value in ("", "0") and self.numbers == "0" 43 | 44 | def watch_show_ac(self, show_ac: bool) -> None: 45 | """Called when show_ac changes.""" 46 | self.query_one("#c").display = not show_ac 47 | self.query_one("#ac").display = show_ac 48 | 49 | def compose(self) -> ComposeResult: 50 | """Add our buttons.""" 51 | with Container(id="calculator"): 52 | yield Static(id="numbers") 53 | yield Button("AC", id="ac", variant="primary") 54 | yield Button("C", id="c", variant="primary") 55 | yield Button("+/-", id="plus-minus", variant="primary") 56 | yield Button("%", id="percent", variant="primary") 57 | yield Button("÷", id="divide", variant="warning") 58 | yield Button("7", id="number-7") 59 | yield Button("8", id="number-8") 60 | yield Button("9", id="number-9") 61 | yield Button("×", id="multiply", variant="warning") 62 | yield Button("4", id="number-4") 63 | yield Button("5", id="number-5") 64 | yield Button("6", id="number-6") 65 | yield Button("-", id="minus", variant="warning") 66 | yield Button("1", id="number-1") 67 | yield Button("2", id="number-2") 68 | yield Button("3", id="number-3") 69 | yield Button("+", id="plus", variant="warning") 70 | yield Button("0", id="number-0") 71 | yield Button(".", id="point") 72 | yield Button("=", id="equals", variant="warning") 73 | 74 | def on_key(self, event: events.Key) -> None: 75 | """Called when the user presses a key.""" 76 | 77 | def press(button_id: str) -> None: 78 | try: 79 | self.query_one(f"#{button_id}", Button).press() 80 | except NoMatches: 81 | pass 82 | 83 | key = event.key 84 | if key.isdecimal(): 85 | press(f"number-{key}") 86 | elif key == "c": 87 | press("c") 88 | press("ac") 89 | else: 90 | button_id = self.NAME_MAP.get(key) 91 | if button_id is not None: 92 | press(self.NAME_MAP.get(key, key)) 93 | 94 | def on_button_pressed(self, event: Button.Pressed) -> None: 95 | """Called when a button is pressed.""" 96 | 97 | button_id = event.button.id 98 | assert button_id is not None 99 | 100 | def do_math() -> None: 101 | """Does the math: LEFT OPERATOR RIGHT""" 102 | try: 103 | if self.operator == "plus": 104 | self.left += self.right 105 | elif self.operator == "minus": 106 | self.left -= self.right 107 | elif self.operator == "divide": 108 | self.left /= self.right 109 | elif self.operator == "multiply": 110 | self.left *= self.right 111 | self.numbers = str(self.left) 112 | self.value = "" 113 | except Exception: 114 | self.numbers = "Error" 115 | 116 | if button_id.startswith("number-"): 117 | number = button_id.partition("-")[-1] 118 | self.numbers = self.value = self.value.lstrip("0") + number 119 | elif button_id == "plus-minus": 120 | self.numbers = self.value = str(Decimal(self.value or "0") * -1) 121 | elif button_id == "percent": 122 | self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) 123 | elif button_id == "point": 124 | if "." not in self.value: 125 | self.numbers = self.value = (self.value or "0") + "." 126 | elif button_id == "ac": 127 | self.value = "" 128 | self.left = self.right = Decimal(0) 129 | self.operator = "plus" 130 | self.numbers = "0" 131 | elif button_id == "c": 132 | self.value = "" 133 | self.numbers = "0" 134 | elif button_id in ("plus", "minus", "divide", "multiply"): 135 | self.right = Decimal(self.value or "0") 136 | do_math() 137 | self.operator = button_id 138 | elif button_id == "equals": 139 | if self.value: 140 | self.right = Decimal(self.value) 141 | do_math() 142 | 143 | 144 | if __name__ == "__main__": 145 | CalculatorApp().run() 146 | -------------------------------------------------------------------------------- /examples/download.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/env.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.widgets import Pretty 3 | 4 | import os 5 | 6 | 7 | class TerminalEnv(App): 8 | def compose(self) -> ComposeResult: 9 | yield Pretty(dict(os.environ)) 10 | 11 | 12 | if __name__ == "__main__": 13 | TerminalEnv().run() 14 | -------------------------------------------------------------------------------- /examples/ganglion.toml: -------------------------------------------------------------------------------- 1 | [account] 2 | 3 | [app.Calculator] 4 | path = "./" 5 | command = "python calculator.py" 6 | 7 | 8 | [app.Easing] 9 | slug = "easing" 10 | path = "./" 11 | command = "textual easing" 12 | 13 | 14 | [app.Keys] 15 | slug = "keys" 16 | path = "./" 17 | command = "textual keys" 18 | 19 | 20 | [app.Borders] 21 | slug = "borders" 22 | path = "./" 23 | command = "textual borders" 24 | 25 | 26 | [app.Demo] 27 | name = "Demo" 28 | slug = "demo" 29 | path = "./" 30 | command = "python -m textual" 31 | 32 | [terminal.Terminal] 33 | name = "Terminal" 34 | path = "./" 35 | terminal = true 36 | 37 | [app.OpenLink] 38 | name = "Open Link" 39 | slug = "open-link" 40 | path = "./" 41 | command = "python open_link.py" 42 | 43 | [app.Download] 44 | name = "Download" 45 | slug = "download" 46 | path = "./" 47 | command = "python download.py" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "textual_web" 3 | version = "0.8.0" 4 | description = "Serve Textual apps" 5 | authors = ["Will McGugan "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8.1" 12 | textual = "^0.43.0" 13 | # textual = { path = "../textual/", develop = true } 14 | aiohttp = "^3.9.3" 15 | uvloop = { version = "^0.19.0", markers = "sys_platform != 'win32'" } 16 | click = "^8.1.3" 17 | aiohttp-jinja2 = "^1.5.1" 18 | pydantic = "^2.1.1" 19 | xdg = "^6.0.0" 20 | msgpack = "^1.0.5" 21 | importlib-metadata = ">=4.11.3" 22 | httpx = ">=0.24.1" 23 | tomli = "^2.0.1" 24 | 25 | 26 | [build-system] 27 | requires = ["poetry-core"] 28 | build-backend = "poetry.core.masonry.api" 29 | 30 | [tool.black] 31 | includes = "src" 32 | 33 | [tool.poetry.scripts] 34 | textual-web = "textual_web.cli:app" 35 | -------------------------------------------------------------------------------- /src/textual_web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textual-web/7d6741c9f7869881722d7d8dcf70a286cc270db9/src/textual_web/__init__.py -------------------------------------------------------------------------------- /src/textual_web/_two_way_dict.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Generic, TypeVar 4 | 5 | Key = TypeVar("Key") 6 | Value = TypeVar("Value") 7 | 8 | 9 | class TwoWayDict(Generic[Key, Value]): 10 | """ 11 | A two-way mapping offering O(1) access in both directions. 12 | 13 | Wraps two dictionaries and uses them to provide efficient access to 14 | both values (given keys) and keys (given values). 15 | """ 16 | 17 | def __init__(self, initial: dict[Key, Value] | None = None) -> None: 18 | initial_data = {} if initial is None else initial 19 | self._forward: dict[Key, Value] = initial_data 20 | self._reverse: dict[Value, Key] = { 21 | value: key for key, value in initial_data.items() 22 | } 23 | 24 | def __setitem__(self, key: Key, value: Value) -> None: 25 | # TODO: Duplicate values need to be managed to ensure consistency, 26 | # decide on best approach. 27 | self._forward.__setitem__(key, value) 28 | self._reverse.__setitem__(value, key) 29 | 30 | def __delitem__(self, key: Key) -> None: 31 | value = self._forward[key] 32 | self._forward.__delitem__(key) 33 | self._reverse.__delitem__(value) 34 | 35 | def __iter__(self): 36 | return iter(self._forward) 37 | 38 | def get(self, key: Key) -> Value | None: 39 | """Given a key, efficiently lookup and return the associated value. 40 | 41 | Args: 42 | key: The key 43 | 44 | Returns: 45 | The value 46 | """ 47 | return self._forward.get(key) 48 | 49 | def get_key(self, value: Value) -> Key | None: 50 | """Given a value, efficiently lookup and return the associated key. 51 | 52 | Args: 53 | value: The value 54 | 55 | Returns: 56 | The key 57 | """ 58 | return self._reverse.get(value) 59 | 60 | def contains_value(self, value: Value) -> bool: 61 | """Check if `value` is a value within this TwoWayDict. 62 | 63 | Args: 64 | value: The value to check. 65 | 66 | Returns: 67 | True if the value is within the values of this dict. 68 | """ 69 | return value in self._reverse 70 | 71 | def __len__(self): 72 | return len(self._forward) 73 | 74 | def __contains__(self, item: Key) -> bool: 75 | return item in self._forward 76 | -------------------------------------------------------------------------------- /src/textual_web/app_session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from asyncio import StreamReader, StreamWriter, IncompleteReadError 5 | from asyncio.subprocess import Process 6 | from enum import Enum, auto 7 | import io 8 | import logging 9 | import json 10 | import logging 11 | import os 12 | from time import monotonic 13 | from datetime import timedelta 14 | from pathlib import Path 15 | 16 | from importlib_metadata import version 17 | 18 | import rich.repr 19 | 20 | from . import constants 21 | from .session import Session, SessionConnector 22 | from .types import Meta, SessionID 23 | 24 | 25 | log = logging.getLogger("textual-web") 26 | 27 | 28 | class ProcessState(Enum): 29 | """The state of a process.""" 30 | 31 | PENDING = auto() 32 | RUNNING = auto() 33 | CLOSING = auto() 34 | CLOSED = auto() 35 | 36 | def __repr__(self) -> str: 37 | return self.name 38 | 39 | 40 | @rich.repr.auto(angular=True) 41 | class AppSession(Session): 42 | """Runs a single app process.""" 43 | 44 | def __init__( 45 | self, 46 | working_directory: Path, 47 | command: str, 48 | session_id: SessionID, 49 | devtools: bool = False, 50 | ) -> None: 51 | self.working_directory = working_directory 52 | self.command = command 53 | self.session_id = session_id 54 | self.devtools = devtools 55 | self.start_time: float | None = None 56 | self.end_time: float | None = None 57 | self._process: Process | None = None 58 | self._task: asyncio.Task | None = None 59 | 60 | super().__init__() 61 | self._state = ProcessState.PENDING 62 | 63 | @property 64 | def process(self) -> Process: 65 | """The asyncio (sub)process""" 66 | assert self._process is not None 67 | return self._process 68 | 69 | @property 70 | def stdin(self) -> StreamWriter: 71 | """The processes stdin.""" 72 | assert self._process is not None 73 | assert self._process.stdin is not None 74 | return self._process.stdin 75 | 76 | @property 77 | def stdout(self) -> StreamReader: 78 | """The process' stdout.""" 79 | assert self._process is not None 80 | assert self._process.stdout is not None 81 | return self._process.stdout 82 | 83 | @property 84 | def stderr(self) -> StreamReader: 85 | """The process' stderr.""" 86 | assert self._process is not None 87 | assert self._process.stderr is not None 88 | return self._process.stderr 89 | 90 | @property 91 | def task(self) -> asyncio.Task: 92 | """Session task.""" 93 | assert self._task is not None 94 | return self._task 95 | 96 | @property 97 | def state(self) -> ProcessState: 98 | """Current running state.""" 99 | return self._state 100 | 101 | @state.setter 102 | def state(self, state: ProcessState) -> None: 103 | self._state = state 104 | run_time = self.run_time 105 | log.debug( 106 | "%r state=%r run_time=%s", 107 | self, 108 | self.state, 109 | "0" if run_time is None else timedelta(seconds=int(run_time)), 110 | ) 111 | 112 | @property 113 | def run_time(self) -> float | None: 114 | """Time process was running, or `None` if it hasn't started.""" 115 | if self.end_time is not None: 116 | assert self.start_time is not None 117 | return self.end_time - self.start_time 118 | elif self.start_time is not None: 119 | return monotonic() - self.start_time 120 | else: 121 | return None 122 | 123 | def __rich_repr__(self) -> rich.repr.Result: 124 | yield self.command 125 | yield "id", self.session_id 126 | if self._process is not None: 127 | yield "returncode", self._process.returncode, None 128 | 129 | async def open(self, width: int = 80, height: int = 24) -> None: 130 | """Open the process.""" 131 | environment = dict(os.environ.copy()) 132 | environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver" 133 | environment["TEXTUAL_FPS"] = "60" 134 | environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor" 135 | environment["TERM_PROGRAM"] = "textual-web" 136 | environment["TERM_PROGRAM_VERSION"] = version("textual-web") 137 | environment["COLUMNS"] = str(width) 138 | environment["ROWS"] = str(height) 139 | if self.devtools: 140 | environment["TEXTUAL"] = "debug,devtools" 141 | environment["TEXTUAL_LOG"] = "textual.log" 142 | 143 | cwd = os.getcwd() 144 | os.chdir(str(self.working_directory)) 145 | try: 146 | self._process = await asyncio.create_subprocess_shell( 147 | self.command, 148 | stdin=asyncio.subprocess.PIPE, 149 | stdout=asyncio.subprocess.PIPE, 150 | stderr=asyncio.subprocess.PIPE, 151 | env=environment, 152 | ) 153 | finally: 154 | os.chdir(cwd) 155 | await self.set_terminal_size(width, height) 156 | log.debug("opened %r; %r", self.command, self._process) 157 | self.start_time = monotonic() 158 | 159 | async def start(self, connector: SessionConnector) -> asyncio.Task: 160 | """Start a task to run the process.""" 161 | self._connector = connector 162 | assert self._task is None 163 | self._task = asyncio.create_task(self.run()) 164 | return self._task 165 | 166 | async def close(self) -> None: 167 | """Close the process.""" 168 | self.state = ProcessState.CLOSING 169 | await self.send_meta({"type": "quit"}) 170 | 171 | async def wait(self) -> None: 172 | """Wait for the process to finish (call close first).""" 173 | if self._task: 174 | await self._task 175 | self._task = None 176 | 177 | async def set_terminal_size(self, width: int, height: int) -> None: 178 | """Set the terminal size for the process. 179 | 180 | Args: 181 | width: Width in cells. 182 | height: Height in cells. 183 | """ 184 | await self.send_meta( 185 | { 186 | "type": "resize", 187 | "width": width, 188 | "height": height, 189 | } 190 | ) 191 | 192 | async def run(self) -> None: 193 | """This loop reads stdout from the process and relays it through the websocket.""" 194 | 195 | self.state = ProcessState.RUNNING 196 | 197 | META = b"M" 198 | DATA = b"D" 199 | BINARY_ENCODED = b"P" 200 | 201 | stderr_data = io.BytesIO() 202 | 203 | async def read_stderr() -> None: 204 | """Task to read stderr.""" 205 | try: 206 | while True: 207 | data = await self.stderr.read(1024 * 4) 208 | if not data: 209 | break 210 | stderr_data.write(data) 211 | except asyncio.CancelledError: 212 | pass 213 | 214 | stderr_task = asyncio.create_task(read_stderr()) 215 | readexactly = self.stdout.readexactly 216 | from_bytes = int.from_bytes 217 | 218 | on_data = self._connector.on_data 219 | on_meta = self._connector.on_meta 220 | on_binary_encoded_message = self._connector.on_binary_encoded_message 221 | try: 222 | ready = False 223 | for _ in range(10): 224 | line = await self.stdout.readline() 225 | if not line: 226 | break 227 | if line == b"__GANGLION__\n": 228 | ready = True 229 | break 230 | if ready: 231 | while True: 232 | type_bytes = await readexactly(1) 233 | size_bytes = await readexactly(4) 234 | size = from_bytes(size_bytes, "big") 235 | payload = await readexactly(size) 236 | if type_bytes == DATA: 237 | await on_data(payload) 238 | elif type_bytes == META: 239 | meta_data = json.loads(payload) 240 | meta_type = meta_data.get("type") 241 | if meta_type in {"exit", "blur", "focus"}: 242 | await self.send_meta({"type": meta_type}) 243 | else: 244 | await on_meta(json.loads(payload)) 245 | elif type_bytes == BINARY_ENCODED: 246 | await on_binary_encoded_message(payload) 247 | 248 | except IncompleteReadError: 249 | # Incomplete read means that the stream was closed 250 | pass 251 | except asyncio.CancelledError: 252 | pass 253 | finally: 254 | stderr_task.cancel() 255 | await stderr_task 256 | 257 | self.end_time = monotonic() 258 | self.state = ProcessState.CLOSED 259 | 260 | stderr_message = stderr_data.getvalue().decode("utf-8", errors="replace") 261 | if self._process is not None and self._process.returncode != 0: 262 | if constants.DEBUG and stderr_message: 263 | log.warning(stderr_message) 264 | 265 | await self._connector.on_close() 266 | 267 | @classmethod 268 | def encode_packet(cls, packet_type: bytes, payload: bytes) -> bytes: 269 | """Encode a packet. 270 | 271 | Args: 272 | packet_type: The packet type (b"D" for data or b"M" for meta) 273 | payload: The payload. 274 | 275 | Returns: 276 | Data as bytes. 277 | """ 278 | return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload) 279 | 280 | async def send_bytes(self, data: bytes) -> bool: 281 | """Send bytes to process. 282 | 283 | Args: 284 | data: Data to send. 285 | 286 | Returns: 287 | True if the data was sent, otherwise False. 288 | """ 289 | stdin = self.stdin 290 | try: 291 | stdin.write(self.encode_packet(b"D", data)) 292 | except RuntimeError: 293 | return False 294 | await stdin.drain() 295 | return True 296 | 297 | async def send_meta(self, data: Meta) -> bool: 298 | """Send meta information to process. 299 | 300 | Args: 301 | data: Meta dict to send. 302 | 303 | Returns: 304 | True if the data was sent, otherwise False. 305 | """ 306 | stdin = self.stdin 307 | data_bytes = json.dumps(data).encode("utf-8") 308 | try: 309 | stdin.write(self.encode_packet(b"M", data_bytes)) 310 | except RuntimeError: 311 | return False 312 | await stdin.drain() 313 | return True 314 | -------------------------------------------------------------------------------- /src/textual_web/apps/merlin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | from datetime import timedelta 5 | from time import monotonic 6 | 7 | from textual import events 8 | from textual.app import App, ComposeResult 9 | from textual.color import Color 10 | from textual.containers import Grid 11 | from textual.reactive import var 12 | from textual.renderables.gradient import LinearGradient 13 | from textual.widget import Widget 14 | from textual.widgets import Digits, Label, Switch 15 | 16 | COLORS = [ 17 | "#881177", 18 | "#aa3355", 19 | "#cc6666", 20 | "#ee9944", 21 | "#eedd00", 22 | "#99dd55", 23 | "#44dd88", 24 | "#22ccbb", 25 | "#00bbcc", 26 | "#0099cc", 27 | "#3366bb", 28 | "#663399", 29 | ] 30 | 31 | 32 | TOGGLES: dict[int, tuple[int, ...]] = { 33 | 1: (2, 4, 5), 34 | 2: (1, 3), 35 | 3: (2, 5, 6), 36 | 4: (1, 7), 37 | 5: (2, 4, 6, 8), 38 | 6: (3, 9), 39 | 7: (4, 5, 8), 40 | 8: (7, 9), 41 | 9: (5, 6, 8), 42 | } 43 | 44 | 45 | class LabelSwitch(Widget): 46 | """Switch with a numeric label.""" 47 | 48 | DEFAULT_CSS = """ 49 | LabelSwitch Label { 50 | text-align: center; 51 | width: 1fr; 52 | text-style: bold; 53 | } 54 | 55 | LabelSwitch Label#label-5 { 56 | color: $text-disabled; 57 | } 58 | """ 59 | 60 | def __init__(self, switch_no: int) -> None: 61 | self.switch_no = switch_no 62 | super().__init__() 63 | 64 | def compose(self) -> ComposeResult: 65 | yield Label(str(self.switch_no), id=f"label-{self.switch_no}") 66 | yield Switch(id=f"switch-{self.switch_no}", name=str(self.switch_no)) 67 | 68 | 69 | class Timer(Digits): 70 | DEFAULT_CSS = """ 71 | Timer { 72 | text-align: center; 73 | width: auto; 74 | margin: 2 8; 75 | color: $warning; 76 | } 77 | """ 78 | start_time = var(0) 79 | running = var(True) 80 | 81 | def on_mount(self) -> None: 82 | self.start_time = monotonic() 83 | self.set_interval(1, self.tick) 84 | self.tick() 85 | 86 | def tick(self) -> None: 87 | if self.start_time == 0 or not self.running: 88 | return 89 | time_elapsed = timedelta(seconds=int(monotonic() - self.start_time)) 90 | self.update(str(time_elapsed)) 91 | 92 | 93 | class MerlinApp(App): 94 | """A simple reproduction of one game on the Merlin hand held console.""" 95 | 96 | CSS = """ 97 | Screen { 98 | align: center middle; 99 | } 100 | 101 | Screen.-win { 102 | background: transparent; 103 | } 104 | 105 | Screen.-win Timer { 106 | color: $success; 107 | } 108 | 109 | Grid { 110 | width: auto; 111 | height: auto; 112 | border: thick $primary; 113 | padding: 1 2; 114 | grid-size: 3 3; 115 | grid-rows: auto; 116 | grid-columns: auto; 117 | grid-gutter: 1 1; 118 | background: $surface; 119 | } 120 | """ 121 | 122 | def render(self) -> LinearGradient: 123 | stops = [(i / (len(COLORS) - 1), Color.parse(c)) for i, c in enumerate(COLORS)] 124 | return LinearGradient(30.0, stops) 125 | 126 | def compose(self) -> ComposeResult: 127 | yield Timer() 128 | with Grid(): 129 | for switch in (7, 8, 9, 4, 5, 6, 1, 2, 3): 130 | yield LabelSwitch(switch) 131 | 132 | def on_mount(self) -> None: 133 | for switch_no in range(1, 10): 134 | if random.randint(0, 1): 135 | self.query_one(f"#switch-{switch_no}", Switch).toggle() 136 | 137 | def check_win(self) -> bool: 138 | on_switches = { 139 | int(switch.name or "0") for switch in self.query(Switch) if switch.value 140 | } 141 | return on_switches == {1, 2, 3, 4, 6, 7, 8, 9} 142 | 143 | def on_switch_changed(self, event: Switch.Changed) -> None: 144 | switch_no = int(event.switch.name or "0") 145 | with self.prevent(Switch.Changed): 146 | for toggle_no in TOGGLES[switch_no]: 147 | self.query_one(f"#switch-{toggle_no}", Switch).toggle() 148 | if self.check_win(): 149 | self.query_one("Screen").add_class("-win") 150 | self.query_one(Timer).running = False 151 | 152 | def on_key(self, event: events.Key) -> None: 153 | if event.character and event.character.isdigit(): 154 | self.query_one(f"#switch-{event.character}", Switch).toggle() 155 | 156 | 157 | if __name__ == "__main__": 158 | MerlinApp().run() 159 | -------------------------------------------------------------------------------- /src/textual_web/apps/signup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from pathlib import Path 5 | import re 6 | import unicodedata 7 | 8 | import httpx 9 | from rich.console import Console, RenderableType 10 | from rich.panel import Panel 11 | import xdg 12 | 13 | from textual import on 14 | from textual import work 15 | from textual.app import App, ComposeResult 16 | from textual import events 17 | from textual.containers import Vertical, Container 18 | from textual.renderables.bar import Bar 19 | from textual.reactive import reactive 20 | from textual.widget import Widget 21 | from textual.widgets import Label, Input, Button, LoadingIndicator 22 | from textual.screen import Screen 23 | 24 | 25 | from ..environment import Environment 26 | 27 | 28 | class Form(Container): 29 | DEFAULT_CSS = """ 30 | Form { 31 | color: $text; 32 | width: auto; 33 | height: auto; 34 | background: $boost; 35 | padding: 1 2; 36 | layout: grid; 37 | grid-size: 2; 38 | grid-columns: auto 50; 39 | grid-rows: auto; 40 | grid-gutter: 1; 41 | } 42 | 43 | Form .title { 44 | color: $text; 45 | text-align: center; 46 | text-style: bold; 47 | margin-bottom: 2; 48 | width: 100%; 49 | column-span: 2; 50 | } 51 | 52 | Form Button { 53 | width: 100%; 54 | margin: 1 1 0 0; 55 | column-span: 2; 56 | } 57 | LoadingIndicator { 58 | width: 100%; 59 | height: 3 !important; 60 | margin: 2 1 0 1; 61 | display: none; 62 | } 63 | Form:disabled Button { 64 | display: none; 65 | 66 | } 67 | Form:disabled LoadingIndicator { 68 | display: block; 69 | column-span: 2; 70 | padding-bottom: 1; 71 | } 72 | 73 | Form Label { 74 | width: 100%; 75 | text-align: right; 76 | padding: 1 0 0 1; 77 | } 78 | 79 | Form .group { 80 | height: auto; 81 | width: 100%; 82 | } 83 | 84 | Form .group > PasswordStrength { 85 | padding: 0 1; 86 | color: $text-muted; 87 | } 88 | 89 | Form Input { 90 | border: tall transparent; 91 | } 92 | 93 | Form Label.info { 94 | text-align: left; 95 | padding: 0 1 0 1; 96 | color: $warning 70%; 97 | } 98 | 99 | """ 100 | 101 | 102 | class PasswordStrength(Widget): 103 | DEFAULT_CSS = """ 104 | PasswordStrength { 105 | height: 1; 106 | padding-left: 0; 107 | padding-top: 0; 108 | } 109 | PasswordStrength > .password-strength--highlight { 110 | color: $error; 111 | 112 | } 113 | PasswordStrength > .password-strength--back { 114 | color: $foreground 10%; 115 | } 116 | 117 | PasswordStrength > .password-strength--success { 118 | color: $success; 119 | } 120 | """ 121 | COMPONENT_CLASSES = { 122 | "password-strength--highlight", 123 | "password-strength--back", 124 | "password-strength--success", 125 | } 126 | password = reactive("") 127 | 128 | def render(self) -> RenderableType: 129 | if self.password: 130 | steps = 8 131 | progress = len(self.password) / steps 132 | if progress >= 1: 133 | highlight_style = self.get_component_rich_style( 134 | "password-strength--success" 135 | ) 136 | else: 137 | highlight_style = self.get_component_rich_style( 138 | "password-strength--highlight" 139 | ) 140 | back_style = self.get_component_rich_style("password-strength--back") 141 | return Bar( 142 | highlight_range=(0, progress * self.size.width), 143 | width=None, 144 | background_style=back_style, 145 | highlight_style=highlight_style, 146 | ) 147 | else: 148 | return "Minimum 8 characters, no common words" 149 | 150 | 151 | def slugify(value: str, allow_unicode=False) -> str: 152 | """ 153 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated 154 | dashes to single dashes. Remove characters that aren't alphanumerics, 155 | underscores, or hyphens. Convert to lowercase. Also strip leading and 156 | trailing whitespace, dashes, and underscores. 157 | """ 158 | value = str(value) 159 | if allow_unicode: 160 | value = unicodedata.normalize("NFKC", value) 161 | else: 162 | value = ( 163 | unicodedata.normalize("NFKD", value) 164 | .encode("ascii", "ignore") 165 | .decode("ascii") 166 | ) 167 | value = re.sub(r"[^\w\s-]", "", value.lower()) 168 | return re.sub(r"[-\s]+", "-", value).strip("-_") 169 | 170 | 171 | class SignupInput(Vertical): 172 | DEFAULT_CSS = """ 173 | 174 | SignupInput { 175 | width: 100%; 176 | height: auto; 177 | } 178 | """ 179 | 180 | 181 | class ErrorLabel(Label): 182 | DEFAULT_CSS = """ 183 | SignupScreen ErrorLabel { 184 | color: $error; 185 | text-align: left !important; 186 | padding-left: 1; 187 | padding-top: 0; 188 | display: none; 189 | } 190 | SignupScreen ErrorLabel.-show-error { 191 | display: block; 192 | } 193 | """ 194 | 195 | 196 | class SignupScreen(Screen): 197 | @property 198 | def app(self) -> SignUpApp: 199 | app = super().app 200 | assert isinstance(app, SignUpApp) 201 | return app 202 | 203 | def compose(self) -> ComposeResult: 204 | with Form(): 205 | yield Label("Textual-web Signup", classes="title") 206 | 207 | yield Label("Your name*") 208 | with SignupInput(id="name"): 209 | yield Input() 210 | yield ErrorLabel() 211 | 212 | yield Label("Account slug*") 213 | with SignupInput(id="account_slug"): 214 | with Vertical(classes="group"): 215 | yield Input(placeholder="Identifier used in URLs") 216 | yield Label("First come, first serve (pick wisely)", classes="info") 217 | yield ErrorLabel() 218 | 219 | yield Label("Email*") 220 | with SignupInput(id="email"): 221 | yield Input() 222 | yield ErrorLabel() 223 | 224 | yield Label("Password*") 225 | with SignupInput(id="password"): 226 | with Vertical(classes="group"): 227 | yield Input(password=True) 228 | yield PasswordStrength() 229 | yield ErrorLabel() 230 | 231 | yield Label("Password (again)") 232 | with SignupInput(id="password_check"): 233 | yield Input(password=True) 234 | yield ErrorLabel() 235 | 236 | yield Button("Signup", variant="primary", id="signup") 237 | yield LoadingIndicator() 238 | 239 | @on(Button.Pressed, "#signup") 240 | def signup(self): 241 | """Initiate signup process.""" 242 | self.disabled = True 243 | data = { 244 | input.id: input.query_one(Input).value 245 | for input in self.query(SignupInput) 246 | if input.id is not None 247 | } 248 | self.send_signup(data) 249 | 250 | @work 251 | async def send_signup(self, data: dict[str, str]) -> None: 252 | """Send a post request to the Ganglion server. 253 | 254 | Args: 255 | data: Form data. 256 | """ 257 | try: 258 | async with httpx.AsyncClient() as client: 259 | response = await client.post( 260 | f"{self.app.environment.api_url}signup/", data=data 261 | ) 262 | result = response.json() 263 | 264 | except Exception as request_error: 265 | self.notify( 266 | "Unable to reach server. Please try again later.", severity="error" 267 | ) 268 | self.log(request_error) 269 | return 270 | finally: 271 | self.disabled = False 272 | 273 | try: 274 | result = response.json() 275 | except Exception: 276 | self.notify( 277 | "Server returned an invalid response. Please try again later.", 278 | severity="error", 279 | ) 280 | return 281 | 282 | for error_label in self.query(ErrorLabel): 283 | error_label.update("") 284 | error_label.remove_class("-show-error") 285 | 286 | result_type = result["type"] 287 | 288 | if result_type == "success": 289 | self.dismiss(result) 290 | elif result_type == "fail": 291 | for field, errors in result.get("errors", {}).items(): 292 | if field == "_": 293 | for error in errors: 294 | self.notify(error, severity="error") 295 | else: 296 | error_label = self.query_one(f"#{field} ErrorLabel", ErrorLabel) 297 | error_label.add_class("-show-error") 298 | error_label.update("\n".join(errors)) 299 | 300 | @on(Input.Changed, "#password Input") 301 | def input_changed(self, event: Input.Changed): 302 | self.query_one(PasswordStrength).password = event.input.value 303 | 304 | @on(events.DescendantBlur, "#password_check") 305 | def password_check(self, event: Input.Changed) -> None: 306 | password = self.query_one("#password", Input).value 307 | if password: 308 | password_check = self.query_one("#password-check", Input).value 309 | if password != password_check: 310 | self.notify("Passwords do not match", severity="error") 311 | 312 | @on(events.DescendantFocus, "#account_slug Input") 313 | def update_account_slug(self) -> None: 314 | org_name = self.query_one("#account_slug Input", Input).value 315 | if not org_name: 316 | name = self.query_one("#name Input", Input).value 317 | self.query_one("#account_slug Input", Input).insert_text_at_cursor( 318 | slugify(name) 319 | ) 320 | 321 | 322 | class SignUpApp(App): 323 | CSS_PATH = "signup.tcss" 324 | 325 | def __init__(self, environment: Environment) -> None: 326 | self.environment = environment 327 | super().__init__() 328 | 329 | def on_ready(self) -> None: 330 | self.push_screen(SignupScreen(), callback=self.exit) 331 | 332 | @classmethod 333 | def signup(cls, environment: Environment) -> None: 334 | console = Console() 335 | app = SignUpApp(environment) 336 | result = app.run() 337 | 338 | if result is None: 339 | return 340 | 341 | console.print( 342 | Panel.fit("[bold]You have signed up to textual-web!", border_style="green") 343 | ) 344 | 345 | home_path = xdg.xdg_config_home() 346 | config_path = home_path / "textual-web" 347 | config_path.mkdir(parents=True, exist_ok=True) 348 | auth_path = config_path / "auth.json" 349 | auth = { 350 | "email": result["user"]["email"], 351 | "auth": result["auth_token"]["key"], 352 | } 353 | auth_path.write_text(json.dumps(auth)) 354 | 355 | console.print(f" • Wrote auth to {str(auth_path)!r}") 356 | api_key = result["api_key"]["key"] 357 | console.print(f" • Your API key is {api_key!r}") 358 | ganglion_path = Path("./ganglion.toml") 359 | 360 | CONFIG = f"""\ 361 | [account] 362 | api_key = "{api_key}" 363 | """ 364 | if ganglion_path.exists(): 365 | console.print( 366 | f" • [red]Not writing to existing {str(ganglion_path)!r}, please update manually." 367 | ) 368 | else: 369 | ganglion_path.write_text(CONFIG) 370 | console.print(f" • [green]Wrote {str(ganglion_path)!r}") 371 | 372 | console.print() 373 | 374 | console.print("Run 'textual-web --config ganglion.toml' to get started.") 375 | -------------------------------------------------------------------------------- /src/textual_web/apps/signup.tcss: -------------------------------------------------------------------------------- 1 | SignupScreen { 2 | align: center middle; 3 | 4 | } 5 | -------------------------------------------------------------------------------- /src/textual_web/apps/welcome.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from textual.app import App, ComposeResult 4 | from textual.widgets import Digits, Label 5 | 6 | 7 | class WelcomeApp(App): 8 | CSS = """ 9 | Screen { 10 | align: center middle; 11 | } 12 | #clock { 13 | width: auto; 14 | } 15 | Digits { 16 | color: $success; 17 | } 18 | Label { 19 | text-align: center; 20 | } 21 | """ 22 | 23 | def compose(self) -> ComposeResult: 24 | yield Label("Welcome to textual-web!") 25 | yield Digits("", id="clock") 26 | 27 | def on_ready(self) -> None: 28 | self.update_clock() 29 | self.set_interval(1, self.update_clock) 30 | 31 | def update_clock(self) -> None: 32 | clock = datetime.now().time() 33 | self.query_one(Digits).update(f"{clock:%T}") 34 | 35 | 36 | if __name__ == "__main__": 37 | app = WelcomeApp() 38 | app.run() 39 | -------------------------------------------------------------------------------- /src/textual_web/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import click 5 | from pathlib import Path 6 | import logging 7 | import os 8 | import platform 9 | from rich.panel import Panel 10 | import sys 11 | 12 | from . import constants 13 | from . import identity 14 | from .environment import ENVIRONMENTS 15 | from .ganglion_client import GanglionClient 16 | 17 | from rich.console import Console 18 | from rich.logging import RichHandler 19 | from rich.text import Text 20 | 21 | from importlib_metadata import version 22 | 23 | WINDOWS = platform.system() == "Windows" 24 | 25 | if constants.DEBUG: 26 | FORMAT = "%(message)s" 27 | logging.basicConfig( 28 | level="DEBUG", 29 | format=FORMAT, 30 | datefmt="[%X]", 31 | handlers=[RichHandler(show_path=False)], 32 | ) 33 | else: 34 | FORMAT = "%(message)s" 35 | logging.basicConfig( 36 | level="INFO", 37 | format=FORMAT, 38 | datefmt="[%X]", 39 | handlers=[RichHandler(show_path=False)], 40 | ) 41 | 42 | log = logging.getLogger("textual-web") 43 | 44 | 45 | def print_disclaimer() -> None: 46 | """Print a disclaimer message.""" 47 | from rich import print 48 | from rich import box 49 | 50 | panel = Panel.fit( 51 | Text.from_markup( 52 | "[b]textual-web is currently under active development, and not suitable for production use.[/b]\n\n" 53 | "For support, please join the [blue][link=https://discord.gg/Enf6Z3qhVr]Discord server[/link]", 54 | ), 55 | border_style="red", 56 | box=box.HEAVY, 57 | title="[b]Disclaimer", 58 | padding=(1, 2), 59 | ) 60 | print(panel) 61 | 62 | 63 | @click.command() 64 | @click.version_option(version("textual-web")) 65 | @click.option("-c", "--config", help="Location of TOML config file.", metavar="PATH") 66 | @click.option( 67 | "-e", 68 | "--environment", 69 | help="Environment switch.", 70 | type=click.Choice(list(ENVIRONMENTS)), 71 | default=constants.ENVIRONMENT, 72 | ) 73 | @click.option("-a", "--api-key", help="API key", default=constants.API_KEY) 74 | @click.option( 75 | "-r", 76 | "--run", 77 | help="Command to run a Textual app.", 78 | multiple=True, 79 | metavar="COMMAND", 80 | ) 81 | @click.option( 82 | "--dev", is_flag=True, help="Enable devtools in Textual apps.", default=False 83 | ) 84 | @click.option( 85 | "-t", "--terminal", is_flag=True, help="Publish a remote terminal on a random URL." 86 | ) 87 | @click.option( 88 | "-x", 89 | "--exit-on-idle", 90 | type=int, 91 | metavar="WAIT", 92 | default=0, 93 | help="Exit textual-web when no apps have been launched in WAIT seconds", 94 | ) 95 | @click.option("-w", "--web-interface", is_flag=True, help="Enable web interface") 96 | @click.option("-s", "--signup", is_flag=True, help="Create a textual-web account.") 97 | @click.option("--welcome", is_flag=True, help="Launch an example app.") 98 | @click.option("--merlin", is_flag=True, help="Launch Merlin game.") 99 | def app( 100 | config: str | None, 101 | environment: str, 102 | run: list[str], 103 | dev: bool, 104 | terminal: bool, 105 | exit_on_idle: int, 106 | web_interface: bool, 107 | api_key: str, 108 | signup: bool, 109 | welcome: bool, 110 | merlin: bool, 111 | ) -> None: 112 | """Textual-web can server Textual apps and terminals.""" 113 | 114 | # Args: 115 | # config: Path to config. 116 | # environment: environment switch. 117 | # devtools: Enable devtools. 118 | # terminal: Enable a terminal. 119 | # api_key: API key. 120 | # signup: Signup dialog. 121 | # welcome: Welcome app. 122 | # merlin: Merlin app. 123 | 124 | error_console = Console(stderr=True) 125 | from .config import load_config, default_config 126 | from .environment import get_environment 127 | 128 | _environment = get_environment(environment) 129 | 130 | if signup: 131 | from .apps.signup import SignUpApp 132 | 133 | SignUpApp.signup(_environment) 134 | return 135 | 136 | if welcome: 137 | from .apps.welcome import WelcomeApp 138 | 139 | WelcomeApp().run() 140 | return 141 | 142 | if merlin: 143 | from .apps.merlin import MerlinApp 144 | 145 | MerlinApp().run() 146 | return 147 | 148 | VERSION = version("textual-web") 149 | 150 | print_disclaimer() 151 | log.info(f"version='{VERSION}'") 152 | if constants.DEBUG: 153 | log.info(f"environment={_environment!r}") 154 | else: 155 | log.info(f"environment={_environment.name!r}") 156 | 157 | if constants.DEBUG: 158 | log.warning("DEBUG env var is set; logs may be verbose!") 159 | 160 | if config is not None: 161 | path = Path(config).absolute() 162 | log.info(f"loading config from {str(path)!r}") 163 | try: 164 | _config = load_config(path) 165 | except FileNotFoundError: 166 | log.critical("Config not found") 167 | return 168 | except Exception as error: 169 | error_console.print(f"Failed to load config from {str(path)!r}; {error!r}") 170 | return 171 | else: 172 | log.info("No --config specified, using defaults.") 173 | _config = default_config() 174 | 175 | if constants.DEBUG: 176 | from rich import print 177 | 178 | print(_config) 179 | 180 | if dev: 181 | log.info("Devtools enabled in Textual apps (run textual console)") 182 | 183 | ganglion_client = GanglionClient( 184 | config or "./", 185 | _config, 186 | _environment, 187 | api_key=api_key or None, 188 | devtools=dev, 189 | exit_on_idle=exit_on_idle, 190 | web_interface=web_interface, 191 | ) 192 | 193 | for app_command in run: 194 | ganglion_client.add_app(app_command, app_command, "") 195 | 196 | if terminal: 197 | ganglion_client.add_terminal( 198 | "Terminal", 199 | os.environ.get("SHELL", "bin/sh"), 200 | "", 201 | ) 202 | 203 | if not ganglion_client.app_count: 204 | ganglion_client.add_app("Welcome", "textual-web --welcome", "welcome") 205 | ganglion_client.add_app("Merlin Tribute", "textual-web --merlin", "merlin") 206 | 207 | if WINDOWS: 208 | asyncio.run(ganglion_client.run()) 209 | else: 210 | try: 211 | import uvloop 212 | except ImportError: 213 | asyncio.run(ganglion_client.run()) 214 | else: 215 | if sys.version_info >= (3, 11): 216 | with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner: 217 | runner.run(ganglion_client.run()) 218 | else: 219 | uvloop.install() 220 | asyncio.run(ganglion_client.run()) 221 | 222 | 223 | if __name__ == "__main__": 224 | app() 225 | -------------------------------------------------------------------------------- /src/textual_web/config.py: -------------------------------------------------------------------------------- 1 | from os.path import expandvars 2 | from typing import Optional, Dict, List 3 | 4 | from typing_extensions import Annotated 5 | from pathlib import Path 6 | import tomli 7 | 8 | 9 | from pydantic import BaseModel, Field 10 | from pydantic.functional_validators import AfterValidator 11 | 12 | from .identity import generate 13 | from .slugify import slugify 14 | 15 | ExpandVarsStr = Annotated[str, AfterValidator(expandvars)] 16 | 17 | 18 | class Account(BaseModel): 19 | api_key: Optional[str] = None 20 | 21 | 22 | class App(BaseModel): 23 | """Defines an application.""" 24 | 25 | name: str 26 | slug: str = "" 27 | path: ExpandVarsStr = "./" 28 | color: str = "" 29 | command: ExpandVarsStr = "" 30 | terminal: bool = False 31 | 32 | 33 | class Config(BaseModel): 34 | """Root configuration model.""" 35 | 36 | account: Account 37 | apps: List[App] = Field(default_factory=list) 38 | 39 | 40 | def default_config() -> Config: 41 | """Get a default empty configuration. 42 | 43 | Returns: 44 | Configuration object. 45 | """ 46 | return Config(account=Account()) 47 | 48 | 49 | def load_config(config_path: Path) -> Config: 50 | """Load config from a path. 51 | 52 | Args: 53 | config_path: Path to TOML configuration. 54 | 55 | Returns: 56 | Config object. 57 | """ 58 | with Path(config_path).open("rb") as config_file: 59 | config_data = tomli.load(config_file) 60 | 61 | account = Account(**config_data.get("account", {})) 62 | 63 | def make_app(name, data: Dict[str, object], terminal: bool = False) -> App: 64 | data["name"] = name 65 | data["terminal"] = terminal 66 | if terminal: 67 | data["slug"] = generate().lower() 68 | elif not data.get("slug", ""): 69 | data["slug"] = slugify(name) 70 | 71 | return App(**data) 72 | 73 | apps = [make_app(name, app) for name, app in config_data.get("app", {}).items()] 74 | 75 | apps += [ 76 | make_app(name, app, terminal=True) 77 | for name, app in config_data.get("terminal", {}).items() 78 | ] 79 | 80 | config = Config(account=account, apps=apps) 81 | 82 | return config 83 | -------------------------------------------------------------------------------- /src/textual_web/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants that we might want to expose via the public API. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import os 8 | 9 | from typing_extensions import Final 10 | 11 | get_environ = os.environ.get 12 | 13 | 14 | def get_environ_bool(name: str) -> bool: 15 | """Check an environment variable switch. 16 | 17 | Args: 18 | name: Name of environment variable. 19 | 20 | Returns: 21 | `True` if the env var is "1", otherwise `False`. 22 | """ 23 | has_environ = get_environ(name) == "1" 24 | return has_environ 25 | 26 | 27 | def get_environ_int(name: str, default: int) -> int: 28 | """Retrieves an integer environment variable. 29 | 30 | Args: 31 | name: Name of environment variable. 32 | default: The value to use if the value is not set, or set to something other 33 | than a valid integer. 34 | 35 | Returns: 36 | The integer associated with the environment variable if it's set to a valid int 37 | or the default value otherwise. 38 | """ 39 | try: 40 | return int(os.environ[name]) 41 | except KeyError: 42 | return default 43 | except ValueError: 44 | return default 45 | 46 | 47 | DEBUG: Final = get_environ_bool("DEBUG") 48 | """Enable debug mode.""" 49 | 50 | ENVIRONMENT: Final[str] = get_environ("GANGLION_ENVIRONMENT", "prod") 51 | """Select alternative environment.""" 52 | 53 | API_KEY: Final[str] = get_environ("GANGLION_API_KEY", "") 54 | -------------------------------------------------------------------------------- /src/textual_web/environment.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class Environment: 8 | """Data structure to describe the environment (dev, prod, local).""" 9 | 10 | name: str 11 | """Name of the environment, used in switch.""" 12 | api_url: str 13 | """Endpoint for API.""" 14 | url: str 15 | """Websocket endpoint for client.""" 16 | 17 | 18 | ENVIRONMENTS = { 19 | "prod": Environment( 20 | name="prod", 21 | api_url="https://textual-web.io/api/", 22 | url="wss://textual-web.io/app-service/", 23 | ), 24 | "local": Environment( 25 | name="local", 26 | api_url="ws://127.0.0.1:8080/api/", 27 | url="ws://127.0.0.1:8080/app-service/", 28 | ), 29 | "dev": Environment( 30 | name="dev", 31 | api_url="https://textualize-dev.io/api/", 32 | url="wss://textualize-dev.io/app-service/", 33 | ), 34 | } 35 | 36 | 37 | def get_environment(environment: str) -> Environment: 38 | """Get an Environment instance for the given environment name. 39 | 40 | Returns: 41 | A Environment instance. 42 | 43 | """ 44 | try: 45 | run_environment = ENVIRONMENTS[environment] 46 | except KeyError: 47 | raise RuntimeError(f"Invalid environment {environment!r}") 48 | return run_environment 49 | -------------------------------------------------------------------------------- /src/textual_web/exit_poller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from time import monotonic 6 | from typing import TYPE_CHECKING 7 | 8 | EXIT_POLL_RATE = 5 9 | 10 | log = logging.getLogger("textual-web") 11 | 12 | if TYPE_CHECKING: 13 | from .ganglion_client import GanglionClient 14 | 15 | 16 | class ExitPoller: 17 | """Monitors the client for an idle state, and exits.""" 18 | 19 | def __init__(self, client: GanglionClient, idle_wait: int) -> None: 20 | self.client = client 21 | self.idle_wait = idle_wait 22 | self._task: asyncio.Task | None = None 23 | self._idle_start_time: float | None = None 24 | 25 | def start(self) -> None: 26 | """Start polling.""" 27 | self._task = asyncio.create_task(self.run()) 28 | 29 | def stop(self) -> None: 30 | """Stop polling""" 31 | if self._task is not None: 32 | self._task.cancel() 33 | 34 | async def run(self) -> None: 35 | """Run the poller.""" 36 | if not self.idle_wait: 37 | return 38 | try: 39 | while True: 40 | await asyncio.sleep(EXIT_POLL_RATE) 41 | is_idle = not self.client.session_manager.sessions 42 | if is_idle: 43 | if self._idle_start_time is not None: 44 | if monotonic() - self._idle_start_time > self.idle_wait: 45 | log.info("Exiting due to --exit-on-idle") 46 | self.client.force_exit() 47 | else: 48 | self._idle_start_time = monotonic() 49 | else: 50 | self._idle_start_time = None 51 | 52 | except asyncio.CancelledError: 53 | pass 54 | -------------------------------------------------------------------------------- /src/textual_web/ganglion_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import signal 6 | from functools import partial 7 | from pathlib import Path 8 | import platform 9 | from typing import TYPE_CHECKING, Union, cast 10 | 11 | import aiohttp 12 | import msgpack 13 | from aiohttp.client_exceptions import WSServerHandshakeError 14 | 15 | from . import constants, packets 16 | from .environment import Environment 17 | from .exit_poller import ExitPoller 18 | from .identity import generate 19 | from .packets import ( 20 | Blur, 21 | Focus, 22 | PACKET_MAP, 23 | Handlers, 24 | NotifyTerminalSize, 25 | OpenUrl, 26 | Packet, 27 | RoutePing, 28 | RoutePong, 29 | SessionClose, 30 | SessionData, 31 | ) 32 | from .poller import Poller 33 | from .retry import Retry 34 | from .session import SessionConnector 35 | from .session_manager import SessionManager 36 | from .types import Meta, RouteKey, SessionID 37 | from .web import run_web_interface 38 | 39 | 40 | if TYPE_CHECKING: 41 | from .config import Config 42 | 43 | WINDOWS = platform.system() == "Windows" 44 | 45 | log = logging.getLogger("textual-web") 46 | 47 | 48 | PacketDataType = Union[int, bytes, str, None] 49 | 50 | 51 | class PacketError(Exception): 52 | """A packet error.""" 53 | 54 | 55 | class _ClientConnector(SessionConnector): 56 | def __init__( 57 | self, client: GanglionClient, session_id: SessionID, route_key: RouteKey 58 | ) -> None: 59 | self.client = client 60 | self.session_id = session_id 61 | self.route_key = route_key 62 | 63 | async def on_data(self, data: bytes) -> None: 64 | """Data received from the process.""" 65 | await self.client.send(packets.SessionData(self.route_key, data)) 66 | 67 | async def on_meta(self, meta: Meta) -> None: 68 | """On receiving a meta dict from the running process, send it to the Ganglion server.""" 69 | meta_type = meta.get("type") 70 | if meta_type == "open_url": 71 | await self.client.send( 72 | packets.OpenUrl( 73 | route_key=self.route_key, 74 | url=meta["url"], 75 | new_tab=meta["new_tab"], 76 | ) 77 | ) 78 | elif meta_type == "deliver_file_start": 79 | await self.client.send( 80 | packets.DeliverFileStart( 81 | route_key=self.route_key, 82 | delivery_key=meta["key"], 83 | file_name=Path(meta["path"]).name, 84 | open_method=meta["open_method"], 85 | mime_type=meta["mime_type"], 86 | encoding=meta["encoding"], 87 | ) 88 | ) 89 | else: 90 | log.warning( 91 | f"Unknown meta type: {meta_type!r}. Full meta: {meta!r}.\n" 92 | "You may be running a version of Textual unsupported by this version of Textual Web." 93 | ) 94 | 95 | async def on_binary_encoded_message(self, payload: bytes) -> None: 96 | """Handle binary encoded data from the process. 97 | 98 | This data is forwarded directly to Ganglion. 99 | 100 | Args: 101 | payload: Binary encoded data to forward to Ganglion. 102 | """ 103 | await self.client.send( 104 | packets.BinaryEncodedMessage(route_key=self.route_key, data=payload) 105 | ) 106 | 107 | async def on_close(self) -> None: 108 | await self.client.send(packets.SessionClose(self.session_id, self.route_key)) 109 | self.client.session_manager.on_session_end(self.session_id) 110 | 111 | 112 | class GanglionClient(Handlers): 113 | """Manages a connection to a ganglion server.""" 114 | 115 | def __init__( 116 | self, 117 | config_path: str, 118 | config: Config, 119 | environment: Environment, 120 | api_key: str | None, 121 | devtools: bool = False, 122 | exit_on_idle: int = 0, 123 | web_interface: bool = False, 124 | ) -> None: 125 | self.environment = environment 126 | self.websocket_url = environment.url 127 | self.exit_on_idle = exit_on_idle 128 | self.web_interface = web_interface 129 | 130 | abs_path = Path(config_path).absolute() 131 | path = abs_path if abs_path.is_dir() else abs_path.parent 132 | self.config = config 133 | self.api_key = api_key 134 | self._devtools = devtools 135 | self._websocket: aiohttp.ClientWebSocketResponse | None = None 136 | self._poller = Poller() 137 | self.session_manager = SessionManager(self._poller, path, config.apps) 138 | self.exit_event = asyncio.Event() 139 | self._task: asyncio.Task | None = None 140 | self._exit_poller = ExitPoller(self, exit_on_idle) 141 | self._connected_event = asyncio.Event() 142 | 143 | @property 144 | def app_count(self) -> int: 145 | """The number of configured apps.""" 146 | return len(self.session_manager.apps) 147 | 148 | def add_app(self, name: str, command: str, slug: str = "") -> None: 149 | """Add a new app 150 | 151 | Args: 152 | name: Name of the app. 153 | command: Command to run the app. 154 | slug: Slug used in URL, or blank to auto-generate on server. 155 | """ 156 | slug = slug or generate().lower() 157 | self.session_manager.add_app(name, command, slug=slug) 158 | 159 | def add_terminal(self, name: str, command: str, slug: str = "") -> None: 160 | """Add a new terminal. 161 | 162 | Args: 163 | name: Name of the app. 164 | command: Command to run the app. 165 | slug: Slug used in URL, or blank to auto-generate on server. 166 | """ 167 | if WINDOWS: 168 | log.warning( 169 | "Sorry, textual-web does not currently support terminals on Windows" 170 | ) 171 | else: 172 | slug = slug or generate().lower() 173 | self.session_manager.add_app(name, command, slug=slug, terminal=True) 174 | 175 | @classmethod 176 | def decode_envelope( 177 | cls, packet_envelope: tuple[PacketDataType, ...] 178 | ) -> Packet | None: 179 | """Decode a packet envelope. 180 | 181 | Packet envelopes are a list where the first value is an integer denoting the type. 182 | The type is used to look up the appropriate Packet class which is instantiated with 183 | the rest of the data. 184 | 185 | If the envelope contains *more* data than required, then that data is silently dropped. 186 | This is to provide an extension mechanism. 187 | 188 | Raises: 189 | PacketError: If the packet_envelope is empty. 190 | PacketError: If the packet type is not an int. 191 | 192 | Returns: 193 | One of the Packet classes defined in packets.py or None if the packet was of an unknown type. 194 | """ 195 | if not packet_envelope: 196 | raise PacketError("Packet data is empty") 197 | 198 | packet_data: list[PacketDataType] 199 | packet_type, *packet_data = packet_envelope 200 | if not isinstance(packet_type, int): 201 | raise PacketError(f"Packet id expected int, found {packet_type!r}") 202 | packet_class = PACKET_MAP.get(packet_type, None) 203 | if packet_class is None: 204 | return None 205 | try: 206 | packet = packet_class.build(*packet_data[: len(packet_class._attributes)]) 207 | except TypeError as error: 208 | raise PacketError(f"Packet failed to validate; {error}") 209 | return packet 210 | 211 | async def run(self) -> None: 212 | """Run the connection loop.""" 213 | 214 | try: 215 | self._exit_poller.start() 216 | await self._run() 217 | finally: 218 | self._exit_poller.stop() 219 | # Shut down the poller thread 220 | if not WINDOWS: 221 | try: 222 | self._poller.exit() 223 | except Exception: 224 | pass 225 | 226 | def on_keyboard_interrupt(self) -> None: 227 | """Signal handler to respond to keyboard interrupt.""" 228 | print( 229 | "\r\033[F" 230 | ) # Move to start of line, to overwrite "^C" written by the shell (?) 231 | log.info("Exit requested") 232 | self.exit_event.set() 233 | if self._task is not None: 234 | self._task.cancel() 235 | 236 | async def _run(self) -> None: 237 | loop = asyncio.get_event_loop() 238 | if WINDOWS: 239 | 240 | def exit_handler(signal_handler, stack_frame) -> None: 241 | """Signal handler.""" 242 | self.on_keyboard_interrupt() 243 | 244 | signal.signal(signal.SIGINT, exit_handler) 245 | else: 246 | loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interrupt) 247 | self._poller.set_loop(loop) 248 | self._poller.start() 249 | 250 | if self.web_interface: 251 | app = await run_web_interface(self._connected_event) 252 | try: 253 | self._task = asyncio.create_task(self.connect()) 254 | finally: 255 | await app.shutdown() 256 | else: 257 | self._task = asyncio.create_task(self.connect()) 258 | 259 | await self._task 260 | 261 | def force_exit(self) -> None: 262 | """Force the app to exit.""" 263 | self.exit_event.set() 264 | if self._task is not None: 265 | self._task.cancel() 266 | 267 | async def connect(self) -> None: 268 | """Connect to the Ganglion server.""" 269 | try: 270 | await self._connect() 271 | except asyncio.CancelledError: 272 | pass 273 | 274 | async def _connect(self) -> None: 275 | """Internal connect.""" 276 | api_key = self.config.account.api_key or self.api_key or None 277 | if api_key: 278 | headers = {"GANGLIONAPIKEY": api_key} 279 | else: 280 | headers = {} 281 | 282 | retry = Retry() 283 | 284 | async for retry_count in retry: 285 | self._connected_event.clear() 286 | if self.exit_event.is_set(): 287 | break 288 | try: 289 | if retry_count == 1: 290 | log.info("connecting to Ganglion") 291 | async with aiohttp.ClientSession() as session: 292 | async with session.ws_connect( 293 | self.websocket_url, 294 | headers=headers, 295 | heartbeat=15, # Sends a regular ping 296 | compress=12, # Enables websocket compression 297 | ) as websocket: 298 | self._websocket = websocket 299 | retry.success() 300 | await self.post_connect() 301 | try: 302 | await self.run_websocket(websocket, retry) 303 | finally: 304 | self._websocket = None 305 | log.info("Disconnected from Ganglion") 306 | if self.exit_event.is_set(): 307 | break 308 | except asyncio.CancelledError: 309 | raise 310 | except WSServerHandshakeError: 311 | if retry_count == 1: 312 | log.warning("Received forbidden response, check your API Key") 313 | except Exception as error: 314 | if retry_count == 1: 315 | log.warning( 316 | "Unable to connect to Ganglion server. Will reattempt connection soon." 317 | ) 318 | if constants.DEBUG: 319 | log.error("Unable to connect; %s", error) 320 | 321 | async def run_websocket( 322 | self, websocket: aiohttp.ClientWebSocketResponse, retry: Retry 323 | ) -> None: 324 | """Run the websocket loop. 325 | 326 | Args: 327 | websocket: Websocket. 328 | """ 329 | unpackb = partial(msgpack.unpackb, use_list=True, raw=False) 330 | BINARY = aiohttp.WSMsgType.BINARY 331 | 332 | async def run_messages() -> None: 333 | """Read, decode, and dispatch websocket messages.""" 334 | async for message in websocket: 335 | if message.type == BINARY: 336 | try: 337 | envelope = unpackb(message.data) 338 | except Exception: 339 | log.error(f"Unable to decode {message.data!r}") 340 | else: 341 | packet = self.decode_envelope(envelope) 342 | log.debug(" %r", packet) 343 | if packet is not None: 344 | try: 345 | await self.dispatch_packet(packet) 346 | except Exception: 347 | log.exception("error processing %r", packet) 348 | 349 | elif message.type == aiohttp.WSMsgType.ERROR: 350 | break 351 | 352 | try: 353 | await run_messages() 354 | except asyncio.CancelledError: 355 | retry.done() 356 | await self.session_manager.close_all() 357 | await websocket.close(message=b"Close requested") 358 | try: 359 | await run_messages() 360 | except asyncio.CancelledError: 361 | pass 362 | except ConnectionResetError: 363 | log.info("connection reset") 364 | except Exception as error: 365 | log.exception(str(error)) 366 | 367 | async def post_connect(self) -> None: 368 | """Called immediately after connecting to the Ganglion server.""" 369 | # Inform the server about our apps 370 | try: 371 | apps = [ 372 | app.model_dump(include={"name", "slug", "color", "terminal"}) 373 | for app in self.config.apps 374 | ] 375 | if WINDOWS: 376 | filter_apps = [app for app in apps if not app["terminal"]] 377 | if filter_apps != apps: 378 | log.warn( 379 | "Sorry, textual-web does not currently support terminals on Windows" 380 | ) 381 | apps = filter_apps 382 | 383 | await self.send(packets.DeclareApps(apps)) 384 | finally: 385 | self._connected_event.set() 386 | 387 | async def send(self, packet: Packet) -> bool: 388 | """Send a packet to the Ganglion server through the websocket. 389 | 390 | Args: 391 | packet: Packet to send. 392 | 393 | Returns: 394 | bool: `True` if the packet was sent, otherwise `False`. 395 | """ 396 | if self._websocket is None: 397 | log.warning("Failed to send %r", packet) 398 | return False 399 | packet_bytes = msgpack.packb(packet, use_bin_type=True) 400 | try: 401 | await self._websocket.send_bytes(packet_bytes) 402 | except Exception as error: 403 | log.warning("Failed to send %r; %s", packet, error) 404 | return False 405 | else: 406 | log.debug(" %r", packet) 407 | return True 408 | 409 | async def on_ping(self, packet: packets.Ping) -> None: 410 | """Sent by the server.""" 411 | # Reply to a Ping with an immediate Pong. 412 | await self.send(packets.Pong(packet.data)) 413 | 414 | async def on_log(self, packet: packets.Log) -> None: 415 | """A log message sent by the server.""" 416 | log.debug(f" {packet.message}") 417 | 418 | async def on_info(self, packet: packets.Info) -> None: 419 | """An info message (higher priority log) sent by the server.""" 420 | log.info(f" {packet.message}") 421 | 422 | async def on_session_open(self, packet: packets.SessionOpen) -> None: 423 | route_key = packet.route_key 424 | session_process = await self.session_manager.new_session( 425 | packet.application_slug, 426 | SessionID(packet.session_id), 427 | RouteKey(packet.route_key), 428 | devtools=self._devtools, 429 | size=(packet.width, packet.height), 430 | ) 431 | if session_process is None: 432 | log.debug("Failed to create session") 433 | return 434 | 435 | connector = _ClientConnector( 436 | self, cast(SessionID, packet.session_id), cast(RouteKey, route_key) 437 | ) 438 | 439 | await session_process.start(connector) 440 | 441 | async def on_session_close(self, packet: SessionClose) -> None: 442 | session_id = SessionID(packet.session_id) 443 | session_process = self.session_manager.get_session(session_id) 444 | if session_process is not None: 445 | await self.session_manager.close_session(session_id) 446 | 447 | async def on_session_data(self, packet: SessionData) -> None: 448 | session_process = self.session_manager.get_session_by_route_key( 449 | RouteKey(packet.route_key) 450 | ) 451 | if session_process is not None: 452 | await session_process.send_bytes(packet.data) 453 | 454 | async def on_notify_terminal_size(self, packet: NotifyTerminalSize) -> None: 455 | session_process = self.session_manager.get_session(SessionID(packet.session_id)) 456 | if session_process is not None: 457 | await session_process.set_terminal_size(packet.width, packet.height) 458 | 459 | async def on_route_ping(self, packet: RoutePing) -> None: 460 | await self.send(RoutePong(packet.route_key, packet.data)) 461 | 462 | async def on_focus(self, packet: Focus) -> None: 463 | """The remote app was focused.""" 464 | session_process = self.session_manager.get_session_by_route_key( 465 | RouteKey(packet.route_key) 466 | ) 467 | if session_process is not None: 468 | await session_process.send_meta({"type": "focus"}) 469 | 470 | async def on_blur(self, packet: Blur) -> None: 471 | """The remote app lost focus.""" 472 | session_process = self.session_manager.get_session_by_route_key( 473 | RouteKey(packet.route_key) 474 | ) 475 | if session_process is not None: 476 | await session_process.send_meta({"type": "blur"}) 477 | 478 | async def on_request_deliver_chunk( 479 | self, packet: packets.RequestDeliverChunk 480 | ) -> None: 481 | """The Ganglion server requested a chunk of a file. Forward that to the running app session. 482 | 483 | When the meta is sent to the Textual app, it will be handled inside the WebDriver. 484 | """ 485 | route_key = RouteKey(packet.route_key) 486 | session_process = self.session_manager.get_session_by_route_key(route_key) 487 | if session_process is not None: 488 | meta = { 489 | "type": "deliver_chunk_request", 490 | "key": packet.delivery_key, 491 | "size": packet.chunk_size, 492 | } 493 | await session_process.send_meta(meta) 494 | -------------------------------------------------------------------------------- /src/textual_web/identity.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SEPARATOR = "-" 4 | IDENTITY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTUVWYZ" 5 | IDENTITY_SIZE = 12 6 | 7 | 8 | def generate(size: int = IDENTITY_SIZE) -> str: 9 | """Generate a random identifier.""" 10 | alphabet = IDENTITY_ALPHABET 11 | return "".join(alphabet[byte % 31] for byte in os.urandom(size)) 12 | -------------------------------------------------------------------------------- /src/textual_web/packets.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is auto-generated from packets.yml and packets.py.template 3 | 4 | Time: Wed Aug 21 10:16:06 2024 5 | Version: 1 6 | 7 | To regenerate run `make packets.py` (in src directory) 8 | 9 | **Do not hand edit.** 10 | 11 | 12 | """ 13 | 14 | from __future__ import annotations 15 | 16 | from enum import IntEnum 17 | from operator import attrgetter 18 | from typing import ClassVar, Type 19 | 20 | import rich.repr 21 | 22 | MAX_STRING = 20 23 | 24 | 25 | def abbreviate_repr(input: object) -> str: 26 | """Abbreviate any long strings.""" 27 | if isinstance(input, (bytes, str)) and len(input) > MAX_STRING: 28 | cropped = len(input) - MAX_STRING 29 | return f"{input[:MAX_STRING]!r}+{cropped}" 30 | return repr(input) 31 | 32 | 33 | class PacketType(IntEnum): 34 | """Enumeration of packet types.""" 35 | 36 | # A null packet (never sent). 37 | NULL = 0 38 | # Request packet data to be returned via a Pong. 39 | PING = 1 # See Ping() 40 | 41 | # Response to a Ping packet. The data from Ping should be sent back in the Pong. 42 | PONG = 2 # See Pong() 43 | 44 | # A message to be written to debug logs. This is a debugging aid, and will be disabled in production. 45 | LOG = 3 # See Log() 46 | 47 | # Info message to be written in to logs. Unlike Log, these messages will be used in production. 48 | INFO = 4 # See Info() 49 | 50 | # Declare the apps exposed. 51 | DECLARE_APPS = 5 # See DeclareApps() 52 | 53 | # Notification sent by a client when an app session was opened 54 | SESSION_OPEN = 6 # See SessionOpen() 55 | 56 | # Close an existing app session. 57 | SESSION_CLOSE = 7 # See SessionClose() 58 | 59 | # Data for a session. 60 | SESSION_DATA = 8 # See SessionData() 61 | 62 | # Session ping 63 | ROUTE_PING = 9 # See RoutePing() 64 | 65 | # Session pong 66 | ROUTE_PONG = 10 # See RoutePong() 67 | 68 | # Notify the client that the terminal has change dimensions. 69 | NOTIFY_TERMINAL_SIZE = 11 # See NotifyTerminalSize() 70 | 71 | # App has focus. 72 | FOCUS = 12 # See Focus() 73 | 74 | # App was blurred. 75 | BLUR = 13 # See Blur() 76 | 77 | # Open a URL in the browser. 78 | OPEN_URL = 14 # See OpenUrl() 79 | 80 | # A message that has been binary encoded. 81 | BINARY_ENCODED_MESSAGE = 15 # See BinaryEncodedMessage() 82 | 83 | # The app indicates to the server that it is ready to send a file. 84 | DELIVER_FILE_START = 16 # See DeliverFileStart() 85 | 86 | # The server requests a chunk of a file from the running app. 87 | REQUEST_DELIVER_CHUNK = 17 # See RequestDeliverChunk() 88 | 89 | 90 | class Packet(tuple): 91 | """Base class for a packet. 92 | 93 | Should never be sent. Use one of the derived classes. 94 | 95 | """ 96 | 97 | sender: ClassVar[str] = "both" 98 | handler_name: ClassVar[str] = "" 99 | type: ClassVar[PacketType] = PacketType.NULL 100 | 101 | _attributes: ClassVar[list[tuple[str, Type]]] = [] 102 | _attribute_count = 0 103 | _get_handler = attrgetter("foo") 104 | 105 | 106 | # PacketType.PING (1) 107 | class Ping(Packet): 108 | """Request packet data to be returned via a Pong. 109 | 110 | Args: 111 | data (bytes): Opaque data. 112 | 113 | """ 114 | 115 | sender: ClassVar[str] = "both" 116 | """Permitted sender, should be "client", "server", or "both".""" 117 | handler_name: ClassVar[str] = "on_ping" 118 | """Name of the method used to handle this packet.""" 119 | type: ClassVar[PacketType] = PacketType.PING 120 | """The packet type enumeration.""" 121 | 122 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 123 | ("data", bytes), 124 | ] 125 | _attribute_count = 1 126 | _get_handler = attrgetter("on_ping") 127 | 128 | def __new__(cls, data: bytes) -> "Ping": 129 | return tuple.__new__(cls, (PacketType.PING, data)) 130 | 131 | @classmethod 132 | def build(cls, data: bytes) -> "Ping": 133 | """Build and validate a packet from its attributes.""" 134 | if not isinstance(data, bytes): 135 | raise TypeError( 136 | f'packets.Ping Type of "data" incorrect; expected bytes, found {type(data)}' 137 | ) 138 | return tuple.__new__(cls, (PacketType.PING, data)) 139 | 140 | def __repr__(self) -> str: 141 | _type, data = self 142 | return f"Ping({abbreviate_repr(data)})" 143 | 144 | def __rich_repr__(self) -> rich.repr.Result: 145 | yield "data", self.data 146 | 147 | @property 148 | def data(self) -> bytes: 149 | """Opaque data.""" 150 | return self[1] 151 | 152 | 153 | # PacketType.PONG (2) 154 | class Pong(Packet): 155 | """Response to a Ping packet. The data from Ping should be sent back in the Pong. 156 | 157 | Args: 158 | data (bytes): Data received from PING 159 | 160 | """ 161 | 162 | sender: ClassVar[str] = "both" 163 | """Permitted sender, should be "client", "server", or "both".""" 164 | handler_name: ClassVar[str] = "on_pong" 165 | """Name of the method used to handle this packet.""" 166 | type: ClassVar[PacketType] = PacketType.PONG 167 | """The packet type enumeration.""" 168 | 169 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 170 | ("data", bytes), 171 | ] 172 | _attribute_count = 1 173 | _get_handler = attrgetter("on_pong") 174 | 175 | def __new__(cls, data: bytes) -> "Pong": 176 | return tuple.__new__(cls, (PacketType.PONG, data)) 177 | 178 | @classmethod 179 | def build(cls, data: bytes) -> "Pong": 180 | """Build and validate a packet from its attributes.""" 181 | if not isinstance(data, bytes): 182 | raise TypeError( 183 | f'packets.Pong Type of "data" incorrect; expected bytes, found {type(data)}' 184 | ) 185 | return tuple.__new__(cls, (PacketType.PONG, data)) 186 | 187 | def __repr__(self) -> str: 188 | _type, data = self 189 | return f"Pong({abbreviate_repr(data)})" 190 | 191 | def __rich_repr__(self) -> rich.repr.Result: 192 | yield "data", self.data 193 | 194 | @property 195 | def data(self) -> bytes: 196 | """Data received from PING""" 197 | return self[1] 198 | 199 | 200 | # PacketType.LOG (3) 201 | class Log(Packet): 202 | """A message to be written to debug logs. This is a debugging aid, and will be disabled in production. 203 | 204 | Args: 205 | message (str): Message to log. 206 | 207 | """ 208 | 209 | sender: ClassVar[str] = "both" 210 | """Permitted sender, should be "client", "server", or "both".""" 211 | handler_name: ClassVar[str] = "on_log" 212 | """Name of the method used to handle this packet.""" 213 | type: ClassVar[PacketType] = PacketType.LOG 214 | """The packet type enumeration.""" 215 | 216 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 217 | ("message", str), 218 | ] 219 | _attribute_count = 1 220 | _get_handler = attrgetter("on_log") 221 | 222 | def __new__(cls, message: str) -> "Log": 223 | return tuple.__new__(cls, (PacketType.LOG, message)) 224 | 225 | @classmethod 226 | def build(cls, message: str) -> "Log": 227 | """Build and validate a packet from its attributes.""" 228 | if not isinstance(message, str): 229 | raise TypeError( 230 | f'packets.Log Type of "message" incorrect; expected str, found {type(message)}' 231 | ) 232 | return tuple.__new__(cls, (PacketType.LOG, message)) 233 | 234 | def __repr__(self) -> str: 235 | _type, message = self 236 | return f"Log({abbreviate_repr(message)})" 237 | 238 | def __rich_repr__(self) -> rich.repr.Result: 239 | yield "message", self.message 240 | 241 | @property 242 | def message(self) -> str: 243 | """Message to log.""" 244 | return self[1] 245 | 246 | 247 | # PacketType.INFO (4) 248 | class Info(Packet): 249 | """Info message to be written in to logs. Unlike Log, these messages will be used in production. 250 | 251 | Args: 252 | message (str): Info message 253 | 254 | """ 255 | 256 | sender: ClassVar[str] = "server" 257 | """Permitted sender, should be "client", "server", or "both".""" 258 | handler_name: ClassVar[str] = "on_info" 259 | """Name of the method used to handle this packet.""" 260 | type: ClassVar[PacketType] = PacketType.INFO 261 | """The packet type enumeration.""" 262 | 263 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 264 | ("message", str), 265 | ] 266 | _attribute_count = 1 267 | _get_handler = attrgetter("on_info") 268 | 269 | def __new__(cls, message: str) -> "Info": 270 | return tuple.__new__(cls, (PacketType.INFO, message)) 271 | 272 | @classmethod 273 | def build(cls, message: str) -> "Info": 274 | """Build and validate a packet from its attributes.""" 275 | if not isinstance(message, str): 276 | raise TypeError( 277 | f'packets.Info Type of "message" incorrect; expected str, found {type(message)}' 278 | ) 279 | return tuple.__new__(cls, (PacketType.INFO, message)) 280 | 281 | def __repr__(self) -> str: 282 | _type, message = self 283 | return f"Info({abbreviate_repr(message)})" 284 | 285 | def __rich_repr__(self) -> rich.repr.Result: 286 | yield "message", self.message 287 | 288 | @property 289 | def message(self) -> str: 290 | """Info message""" 291 | return self[1] 292 | 293 | 294 | # PacketType.DECLARE_APPS (5) 295 | class DeclareApps(Packet): 296 | """Declare the apps exposed. 297 | 298 | Args: 299 | apps (list): Apps served by this client. 300 | 301 | """ 302 | 303 | sender: ClassVar[str] = "client" 304 | """Permitted sender, should be "client", "server", or "both".""" 305 | handler_name: ClassVar[str] = "on_declare_apps" 306 | """Name of the method used to handle this packet.""" 307 | type: ClassVar[PacketType] = PacketType.DECLARE_APPS 308 | """The packet type enumeration.""" 309 | 310 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 311 | ("apps", list), 312 | ] 313 | _attribute_count = 1 314 | _get_handler = attrgetter("on_declare_apps") 315 | 316 | def __new__(cls, apps: list) -> "DeclareApps": 317 | return tuple.__new__(cls, (PacketType.DECLARE_APPS, apps)) 318 | 319 | @classmethod 320 | def build(cls, apps: list) -> "DeclareApps": 321 | """Build and validate a packet from its attributes.""" 322 | if not isinstance(apps, list): 323 | raise TypeError( 324 | f'packets.DeclareApps Type of "apps" incorrect; expected list, found {type(apps)}' 325 | ) 326 | return tuple.__new__(cls, (PacketType.DECLARE_APPS, apps)) 327 | 328 | def __repr__(self) -> str: 329 | _type, apps = self 330 | return f"DeclareApps({abbreviate_repr(apps)})" 331 | 332 | def __rich_repr__(self) -> rich.repr.Result: 333 | yield "apps", self.apps 334 | 335 | @property 336 | def apps(self) -> list: 337 | """Apps served by this client.""" 338 | return self[1] 339 | 340 | 341 | # PacketType.SESSION_OPEN (6) 342 | class SessionOpen(Packet): 343 | """Notification sent by a client when an app session was opened 344 | 345 | Args: 346 | session_id (str): Session ID 347 | app_id (str): Application identity. 348 | application_slug (str): Application slug. 349 | route_key (str): Route key 350 | width (int): Terminal width. 351 | height (int): Terminal height. 352 | 353 | """ 354 | 355 | sender: ClassVar[str] = "server" 356 | """Permitted sender, should be "client", "server", or "both".""" 357 | handler_name: ClassVar[str] = "on_session_open" 358 | """Name of the method used to handle this packet.""" 359 | type: ClassVar[PacketType] = PacketType.SESSION_OPEN 360 | """The packet type enumeration.""" 361 | 362 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 363 | ("session_id", str), 364 | ("app_id", str), 365 | ("application_slug", str), 366 | ("route_key", str), 367 | ("width", int), 368 | ("height", int), 369 | ] 370 | _attribute_count = 6 371 | _get_handler = attrgetter("on_session_open") 372 | 373 | def __new__( 374 | cls, 375 | session_id: str, 376 | app_id: str, 377 | application_slug: str, 378 | route_key: str, 379 | width: int, 380 | height: int, 381 | ) -> "SessionOpen": 382 | return tuple.__new__( 383 | cls, 384 | ( 385 | PacketType.SESSION_OPEN, 386 | session_id, 387 | app_id, 388 | application_slug, 389 | route_key, 390 | width, 391 | height, 392 | ), 393 | ) 394 | 395 | @classmethod 396 | def build( 397 | cls, 398 | session_id: str, 399 | app_id: str, 400 | application_slug: str, 401 | route_key: str, 402 | width: int, 403 | height: int, 404 | ) -> "SessionOpen": 405 | """Build and validate a packet from its attributes.""" 406 | if not isinstance(session_id, str): 407 | raise TypeError( 408 | f'packets.SessionOpen Type of "session_id" incorrect; expected str, found {type(session_id)}' 409 | ) 410 | if not isinstance(app_id, str): 411 | raise TypeError( 412 | f'packets.SessionOpen Type of "app_id" incorrect; expected str, found {type(app_id)}' 413 | ) 414 | if not isinstance(application_slug, str): 415 | raise TypeError( 416 | f'packets.SessionOpen Type of "application_slug" incorrect; expected str, found {type(application_slug)}' 417 | ) 418 | if not isinstance(route_key, str): 419 | raise TypeError( 420 | f'packets.SessionOpen Type of "route_key" incorrect; expected str, found {type(route_key)}' 421 | ) 422 | if not isinstance(width, int): 423 | raise TypeError( 424 | f'packets.SessionOpen Type of "width" incorrect; expected int, found {type(width)}' 425 | ) 426 | if not isinstance(height, int): 427 | raise TypeError( 428 | f'packets.SessionOpen Type of "height" incorrect; expected int, found {type(height)}' 429 | ) 430 | return tuple.__new__( 431 | cls, 432 | ( 433 | PacketType.SESSION_OPEN, 434 | session_id, 435 | app_id, 436 | application_slug, 437 | route_key, 438 | width, 439 | height, 440 | ), 441 | ) 442 | 443 | def __repr__(self) -> str: 444 | _type, session_id, app_id, application_slug, route_key, width, height = self 445 | return f"SessionOpen({abbreviate_repr(session_id)}, {abbreviate_repr(app_id)}, {abbreviate_repr(application_slug)}, {abbreviate_repr(route_key)}, {abbreviate_repr(width)}, {abbreviate_repr(height)})" 446 | 447 | def __rich_repr__(self) -> rich.repr.Result: 448 | yield "session_id", self.session_id 449 | yield "app_id", self.app_id 450 | yield "application_slug", self.application_slug 451 | yield "route_key", self.route_key 452 | yield "width", self.width 453 | yield "height", self.height 454 | 455 | @property 456 | def session_id(self) -> str: 457 | """Session ID""" 458 | return self[1] 459 | 460 | @property 461 | def app_id(self) -> str: 462 | """Application identity.""" 463 | return self[2] 464 | 465 | @property 466 | def application_slug(self) -> str: 467 | """Application slug.""" 468 | return self[3] 469 | 470 | @property 471 | def route_key(self) -> str: 472 | """Route key""" 473 | return self[4] 474 | 475 | @property 476 | def width(self) -> int: 477 | """Terminal width.""" 478 | return self[5] 479 | 480 | @property 481 | def height(self) -> int: 482 | """Terminal height.""" 483 | return self[6] 484 | 485 | 486 | # PacketType.SESSION_CLOSE (7) 487 | class SessionClose(Packet): 488 | """Close an existing app session. 489 | 490 | Args: 491 | session_id (str): Session identity 492 | route_key (str): Route key 493 | 494 | """ 495 | 496 | sender: ClassVar[str] = "server" 497 | """Permitted sender, should be "client", "server", or "both".""" 498 | handler_name: ClassVar[str] = "on_session_close" 499 | """Name of the method used to handle this packet.""" 500 | type: ClassVar[PacketType] = PacketType.SESSION_CLOSE 501 | """The packet type enumeration.""" 502 | 503 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 504 | ("session_id", str), 505 | ("route_key", str), 506 | ] 507 | _attribute_count = 2 508 | _get_handler = attrgetter("on_session_close") 509 | 510 | def __new__(cls, session_id: str, route_key: str) -> "SessionClose": 511 | return tuple.__new__(cls, (PacketType.SESSION_CLOSE, session_id, route_key)) 512 | 513 | @classmethod 514 | def build(cls, session_id: str, route_key: str) -> "SessionClose": 515 | """Build and validate a packet from its attributes.""" 516 | if not isinstance(session_id, str): 517 | raise TypeError( 518 | f'packets.SessionClose Type of "session_id" incorrect; expected str, found {type(session_id)}' 519 | ) 520 | if not isinstance(route_key, str): 521 | raise TypeError( 522 | f'packets.SessionClose Type of "route_key" incorrect; expected str, found {type(route_key)}' 523 | ) 524 | return tuple.__new__(cls, (PacketType.SESSION_CLOSE, session_id, route_key)) 525 | 526 | def __repr__(self) -> str: 527 | _type, session_id, route_key = self 528 | return ( 529 | f"SessionClose({abbreviate_repr(session_id)}, {abbreviate_repr(route_key)})" 530 | ) 531 | 532 | def __rich_repr__(self) -> rich.repr.Result: 533 | yield "session_id", self.session_id 534 | yield "route_key", self.route_key 535 | 536 | @property 537 | def session_id(self) -> str: 538 | """Session identity""" 539 | return self[1] 540 | 541 | @property 542 | def route_key(self) -> str: 543 | """Route key""" 544 | return self[2] 545 | 546 | 547 | # PacketType.SESSION_DATA (8) 548 | class SessionData(Packet): 549 | """Data for a session. 550 | 551 | Args: 552 | route_key (str): Route index. 553 | data (bytes): Data for a remote app 554 | 555 | """ 556 | 557 | sender: ClassVar[str] = "both" 558 | """Permitted sender, should be "client", "server", or "both".""" 559 | handler_name: ClassVar[str] = "on_session_data" 560 | """Name of the method used to handle this packet.""" 561 | type: ClassVar[PacketType] = PacketType.SESSION_DATA 562 | """The packet type enumeration.""" 563 | 564 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 565 | ("route_key", str), 566 | ("data", bytes), 567 | ] 568 | _attribute_count = 2 569 | _get_handler = attrgetter("on_session_data") 570 | 571 | def __new__(cls, route_key: str, data: bytes) -> "SessionData": 572 | return tuple.__new__(cls, (PacketType.SESSION_DATA, route_key, data)) 573 | 574 | @classmethod 575 | def build(cls, route_key: str, data: bytes) -> "SessionData": 576 | """Build and validate a packet from its attributes.""" 577 | if not isinstance(route_key, str): 578 | raise TypeError( 579 | f'packets.SessionData Type of "route_key" incorrect; expected str, found {type(route_key)}' 580 | ) 581 | if not isinstance(data, bytes): 582 | raise TypeError( 583 | f'packets.SessionData Type of "data" incorrect; expected bytes, found {type(data)}' 584 | ) 585 | return tuple.__new__(cls, (PacketType.SESSION_DATA, route_key, data)) 586 | 587 | def __repr__(self) -> str: 588 | _type, route_key, data = self 589 | return f"SessionData({abbreviate_repr(route_key)}, {abbreviate_repr(data)})" 590 | 591 | def __rich_repr__(self) -> rich.repr.Result: 592 | yield "route_key", self.route_key 593 | yield "data", self.data 594 | 595 | @property 596 | def route_key(self) -> str: 597 | """Route index.""" 598 | return self[1] 599 | 600 | @property 601 | def data(self) -> bytes: 602 | """Data for a remote app""" 603 | return self[2] 604 | 605 | 606 | # PacketType.ROUTE_PING (9) 607 | class RoutePing(Packet): 608 | """Session ping 609 | 610 | Args: 611 | route_key (str): Route index. 612 | data (str): Opaque data. 613 | 614 | """ 615 | 616 | sender: ClassVar[str] = "server" 617 | """Permitted sender, should be "client", "server", or "both".""" 618 | handler_name: ClassVar[str] = "on_route_ping" 619 | """Name of the method used to handle this packet.""" 620 | type: ClassVar[PacketType] = PacketType.ROUTE_PING 621 | """The packet type enumeration.""" 622 | 623 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 624 | ("route_key", str), 625 | ("data", str), 626 | ] 627 | _attribute_count = 2 628 | _get_handler = attrgetter("on_route_ping") 629 | 630 | def __new__(cls, route_key: str, data: str) -> "RoutePing": 631 | return tuple.__new__(cls, (PacketType.ROUTE_PING, route_key, data)) 632 | 633 | @classmethod 634 | def build(cls, route_key: str, data: str) -> "RoutePing": 635 | """Build and validate a packet from its attributes.""" 636 | if not isinstance(route_key, str): 637 | raise TypeError( 638 | f'packets.RoutePing Type of "route_key" incorrect; expected str, found {type(route_key)}' 639 | ) 640 | if not isinstance(data, str): 641 | raise TypeError( 642 | f'packets.RoutePing Type of "data" incorrect; expected str, found {type(data)}' 643 | ) 644 | return tuple.__new__(cls, (PacketType.ROUTE_PING, route_key, data)) 645 | 646 | def __repr__(self) -> str: 647 | _type, route_key, data = self 648 | return f"RoutePing({abbreviate_repr(route_key)}, {abbreviate_repr(data)})" 649 | 650 | def __rich_repr__(self) -> rich.repr.Result: 651 | yield "route_key", self.route_key 652 | yield "data", self.data 653 | 654 | @property 655 | def route_key(self) -> str: 656 | """Route index.""" 657 | return self[1] 658 | 659 | @property 660 | def data(self) -> str: 661 | """Opaque data.""" 662 | return self[2] 663 | 664 | 665 | # PacketType.ROUTE_PONG (10) 666 | class RoutePong(Packet): 667 | """Session pong 668 | 669 | Args: 670 | route_key (str): Route index. 671 | data (str): Opaque data. 672 | 673 | """ 674 | 675 | sender: ClassVar[str] = "both" 676 | """Permitted sender, should be "client", "server", or "both".""" 677 | handler_name: ClassVar[str] = "on_route_pong" 678 | """Name of the method used to handle this packet.""" 679 | type: ClassVar[PacketType] = PacketType.ROUTE_PONG 680 | """The packet type enumeration.""" 681 | 682 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 683 | ("route_key", str), 684 | ("data", str), 685 | ] 686 | _attribute_count = 2 687 | _get_handler = attrgetter("on_route_pong") 688 | 689 | def __new__(cls, route_key: str, data: str) -> "RoutePong": 690 | return tuple.__new__(cls, (PacketType.ROUTE_PONG, route_key, data)) 691 | 692 | @classmethod 693 | def build(cls, route_key: str, data: str) -> "RoutePong": 694 | """Build and validate a packet from its attributes.""" 695 | if not isinstance(route_key, str): 696 | raise TypeError( 697 | f'packets.RoutePong Type of "route_key" incorrect; expected str, found {type(route_key)}' 698 | ) 699 | if not isinstance(data, str): 700 | raise TypeError( 701 | f'packets.RoutePong Type of "data" incorrect; expected str, found {type(data)}' 702 | ) 703 | return tuple.__new__(cls, (PacketType.ROUTE_PONG, route_key, data)) 704 | 705 | def __repr__(self) -> str: 706 | _type, route_key, data = self 707 | return f"RoutePong({abbreviate_repr(route_key)}, {abbreviate_repr(data)})" 708 | 709 | def __rich_repr__(self) -> rich.repr.Result: 710 | yield "route_key", self.route_key 711 | yield "data", self.data 712 | 713 | @property 714 | def route_key(self) -> str: 715 | """Route index.""" 716 | return self[1] 717 | 718 | @property 719 | def data(self) -> str: 720 | """Opaque data.""" 721 | return self[2] 722 | 723 | 724 | # PacketType.NOTIFY_TERMINAL_SIZE (11) 725 | class NotifyTerminalSize(Packet): 726 | """Notify the client that the terminal has change dimensions. 727 | 728 | Args: 729 | session_id (str): Session identity. 730 | width (int): Width of the terminal. 731 | height (int): Height of the terminal. 732 | 733 | """ 734 | 735 | sender: ClassVar[str] = "server" 736 | """Permitted sender, should be "client", "server", or "both".""" 737 | handler_name: ClassVar[str] = "on_notify_terminal_size" 738 | """Name of the method used to handle this packet.""" 739 | type: ClassVar[PacketType] = PacketType.NOTIFY_TERMINAL_SIZE 740 | """The packet type enumeration.""" 741 | 742 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 743 | ("session_id", str), 744 | ("width", int), 745 | ("height", int), 746 | ] 747 | _attribute_count = 3 748 | _get_handler = attrgetter("on_notify_terminal_size") 749 | 750 | def __new__(cls, session_id: str, width: int, height: int) -> "NotifyTerminalSize": 751 | return tuple.__new__( 752 | cls, (PacketType.NOTIFY_TERMINAL_SIZE, session_id, width, height) 753 | ) 754 | 755 | @classmethod 756 | def build(cls, session_id: str, width: int, height: int) -> "NotifyTerminalSize": 757 | """Build and validate a packet from its attributes.""" 758 | if not isinstance(session_id, str): 759 | raise TypeError( 760 | f'packets.NotifyTerminalSize Type of "session_id" incorrect; expected str, found {type(session_id)}' 761 | ) 762 | if not isinstance(width, int): 763 | raise TypeError( 764 | f'packets.NotifyTerminalSize Type of "width" incorrect; expected int, found {type(width)}' 765 | ) 766 | if not isinstance(height, int): 767 | raise TypeError( 768 | f'packets.NotifyTerminalSize Type of "height" incorrect; expected int, found {type(height)}' 769 | ) 770 | return tuple.__new__( 771 | cls, (PacketType.NOTIFY_TERMINAL_SIZE, session_id, width, height) 772 | ) 773 | 774 | def __repr__(self) -> str: 775 | _type, session_id, width, height = self 776 | return f"NotifyTerminalSize({abbreviate_repr(session_id)}, {abbreviate_repr(width)}, {abbreviate_repr(height)})" 777 | 778 | def __rich_repr__(self) -> rich.repr.Result: 779 | yield "session_id", self.session_id 780 | yield "width", self.width 781 | yield "height", self.height 782 | 783 | @property 784 | def session_id(self) -> str: 785 | """Session identity.""" 786 | return self[1] 787 | 788 | @property 789 | def width(self) -> int: 790 | """Width of the terminal.""" 791 | return self[2] 792 | 793 | @property 794 | def height(self) -> int: 795 | """Height of the terminal.""" 796 | return self[3] 797 | 798 | 799 | # PacketType.FOCUS (12) 800 | class Focus(Packet): 801 | """App has focus. 802 | 803 | Args: 804 | route_key (str): Route key. 805 | 806 | """ 807 | 808 | sender: ClassVar[str] = "both" 809 | """Permitted sender, should be "client", "server", or "both".""" 810 | handler_name: ClassVar[str] = "on_focus" 811 | """Name of the method used to handle this packet.""" 812 | type: ClassVar[PacketType] = PacketType.FOCUS 813 | """The packet type enumeration.""" 814 | 815 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 816 | ("route_key", str), 817 | ] 818 | _attribute_count = 1 819 | _get_handler = attrgetter("on_focus") 820 | 821 | def __new__(cls, route_key: str) -> "Focus": 822 | return tuple.__new__(cls, (PacketType.FOCUS, route_key)) 823 | 824 | @classmethod 825 | def build(cls, route_key: str) -> "Focus": 826 | """Build and validate a packet from its attributes.""" 827 | if not isinstance(route_key, str): 828 | raise TypeError( 829 | f'packets.Focus Type of "route_key" incorrect; expected str, found {type(route_key)}' 830 | ) 831 | return tuple.__new__(cls, (PacketType.FOCUS, route_key)) 832 | 833 | def __repr__(self) -> str: 834 | _type, route_key = self 835 | return f"Focus({abbreviate_repr(route_key)})" 836 | 837 | def __rich_repr__(self) -> rich.repr.Result: 838 | yield "route_key", self.route_key 839 | 840 | @property 841 | def route_key(self) -> str: 842 | """Route key.""" 843 | return self[1] 844 | 845 | 846 | # PacketType.BLUR (13) 847 | class Blur(Packet): 848 | """App was blurred. 849 | 850 | Args: 851 | route_key (str): Route key. 852 | 853 | """ 854 | 855 | sender: ClassVar[str] = "both" 856 | """Permitted sender, should be "client", "server", or "both".""" 857 | handler_name: ClassVar[str] = "on_blur" 858 | """Name of the method used to handle this packet.""" 859 | type: ClassVar[PacketType] = PacketType.BLUR 860 | """The packet type enumeration.""" 861 | 862 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 863 | ("route_key", str), 864 | ] 865 | _attribute_count = 1 866 | _get_handler = attrgetter("on_blur") 867 | 868 | def __new__(cls, route_key: str) -> "Blur": 869 | return tuple.__new__(cls, (PacketType.BLUR, route_key)) 870 | 871 | @classmethod 872 | def build(cls, route_key: str) -> "Blur": 873 | """Build and validate a packet from its attributes.""" 874 | if not isinstance(route_key, str): 875 | raise TypeError( 876 | f'packets.Blur Type of "route_key" incorrect; expected str, found {type(route_key)}' 877 | ) 878 | return tuple.__new__(cls, (PacketType.BLUR, route_key)) 879 | 880 | def __repr__(self) -> str: 881 | _type, route_key = self 882 | return f"Blur({abbreviate_repr(route_key)})" 883 | 884 | def __rich_repr__(self) -> rich.repr.Result: 885 | yield "route_key", self.route_key 886 | 887 | @property 888 | def route_key(self) -> str: 889 | """Route key.""" 890 | return self[1] 891 | 892 | 893 | # PacketType.OPEN_URL (14) 894 | class OpenUrl(Packet): 895 | """Open a URL in the browser. 896 | 897 | Args: 898 | route_key (str): Route key. 899 | url (str): URL to open. 900 | new_tab (bool): Open in new tab. 901 | 902 | """ 903 | 904 | sender: ClassVar[str] = "client" 905 | """Permitted sender, should be "client", "server", or "both".""" 906 | handler_name: ClassVar[str] = "on_open_url" 907 | """Name of the method used to handle this packet.""" 908 | type: ClassVar[PacketType] = PacketType.OPEN_URL 909 | """The packet type enumeration.""" 910 | 911 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 912 | ("route_key", str), 913 | ("url", str), 914 | ("new_tab", bool), 915 | ] 916 | _attribute_count = 3 917 | _get_handler = attrgetter("on_open_url") 918 | 919 | def __new__(cls, route_key: str, url: str, new_tab: bool) -> "OpenUrl": 920 | return tuple.__new__(cls, (PacketType.OPEN_URL, route_key, url, new_tab)) 921 | 922 | @classmethod 923 | def build(cls, route_key: str, url: str, new_tab: bool) -> "OpenUrl": 924 | """Build and validate a packet from its attributes.""" 925 | if not isinstance(route_key, str): 926 | raise TypeError( 927 | f'packets.OpenUrl Type of "route_key" incorrect; expected str, found {type(route_key)}' 928 | ) 929 | if not isinstance(url, str): 930 | raise TypeError( 931 | f'packets.OpenUrl Type of "url" incorrect; expected str, found {type(url)}' 932 | ) 933 | if not isinstance(new_tab, bool): 934 | raise TypeError( 935 | f'packets.OpenUrl Type of "new_tab" incorrect; expected bool, found {type(new_tab)}' 936 | ) 937 | return tuple.__new__(cls, (PacketType.OPEN_URL, route_key, url, new_tab)) 938 | 939 | def __repr__(self) -> str: 940 | _type, route_key, url, new_tab = self 941 | return f"OpenUrl({abbreviate_repr(route_key)}, {abbreviate_repr(url)}, {abbreviate_repr(new_tab)})" 942 | 943 | def __rich_repr__(self) -> rich.repr.Result: 944 | yield "route_key", self.route_key 945 | yield "url", self.url 946 | yield "new_tab", self.new_tab 947 | 948 | @property 949 | def route_key(self) -> str: 950 | """Route key.""" 951 | return self[1] 952 | 953 | @property 954 | def url(self) -> str: 955 | """URL to open.""" 956 | return self[2] 957 | 958 | @property 959 | def new_tab(self) -> bool: 960 | """Open in new tab.""" 961 | return self[3] 962 | 963 | 964 | # PacketType.BINARY_ENCODED_MESSAGE (15) 965 | class BinaryEncodedMessage(Packet): 966 | """A message that has been binary encoded. 967 | 968 | Args: 969 | route_key (str): Route key. 970 | data (bytes): The binary encoded bytes. 971 | 972 | """ 973 | 974 | sender: ClassVar[str] = "client" 975 | """Permitted sender, should be "client", "server", or "both".""" 976 | handler_name: ClassVar[str] = "on_binary_encoded_message" 977 | """Name of the method used to handle this packet.""" 978 | type: ClassVar[PacketType] = PacketType.BINARY_ENCODED_MESSAGE 979 | """The packet type enumeration.""" 980 | 981 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 982 | ("route_key", str), 983 | ("data", bytes), 984 | ] 985 | _attribute_count = 2 986 | _get_handler = attrgetter("on_binary_encoded_message") 987 | 988 | def __new__(cls, route_key: str, data: bytes) -> "BinaryEncodedMessage": 989 | return tuple.__new__(cls, (PacketType.BINARY_ENCODED_MESSAGE, route_key, data)) 990 | 991 | @classmethod 992 | def build(cls, route_key: str, data: bytes) -> "BinaryEncodedMessage": 993 | """Build and validate a packet from its attributes.""" 994 | if not isinstance(route_key, str): 995 | raise TypeError( 996 | f'packets.BinaryEncodedMessage Type of "route_key" incorrect; expected str, found {type(route_key)}' 997 | ) 998 | if not isinstance(data, bytes): 999 | raise TypeError( 1000 | f'packets.BinaryEncodedMessage Type of "data" incorrect; expected bytes, found {type(data)}' 1001 | ) 1002 | return tuple.__new__(cls, (PacketType.BINARY_ENCODED_MESSAGE, route_key, data)) 1003 | 1004 | def __repr__(self) -> str: 1005 | _type, route_key, data = self 1006 | return f"BinaryEncodedMessage({abbreviate_repr(route_key)}, {abbreviate_repr(data)})" 1007 | 1008 | def __rich_repr__(self) -> rich.repr.Result: 1009 | yield "route_key", self.route_key 1010 | yield "data", self.data 1011 | 1012 | @property 1013 | def route_key(self) -> str: 1014 | """Route key.""" 1015 | return self[1] 1016 | 1017 | @property 1018 | def data(self) -> bytes: 1019 | """The binary encoded bytes.""" 1020 | return self[2] 1021 | 1022 | 1023 | # PacketType.DELIVER_FILE_START (16) 1024 | class DeliverFileStart(Packet): 1025 | """The app indicates to the server that it is ready to send a file. 1026 | 1027 | Args: 1028 | route_key (str): Route key. 1029 | delivery_key (str): Delivery key. 1030 | file_name (str): File name. 1031 | open_method (str): Open method. 1032 | mime_type (str): MIME type. 1033 | encoding (str): Encoding. 1034 | 1035 | """ 1036 | 1037 | sender: ClassVar[str] = "client" 1038 | """Permitted sender, should be "client", "server", or "both".""" 1039 | handler_name: ClassVar[str] = "on_deliver_file_start" 1040 | """Name of the method used to handle this packet.""" 1041 | type: ClassVar[PacketType] = PacketType.DELIVER_FILE_START 1042 | """The packet type enumeration.""" 1043 | 1044 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 1045 | ("route_key", str), 1046 | ("delivery_key", str), 1047 | ("file_name", str), 1048 | ("open_method", str), 1049 | ("mime_type", str), 1050 | ("encoding", str), 1051 | ] 1052 | _attribute_count = 6 1053 | _get_handler = attrgetter("on_deliver_file_start") 1054 | 1055 | def __new__( 1056 | cls, 1057 | route_key: str, 1058 | delivery_key: str, 1059 | file_name: str, 1060 | open_method: str, 1061 | mime_type: str, 1062 | encoding: str, 1063 | ) -> "DeliverFileStart": 1064 | return tuple.__new__( 1065 | cls, 1066 | ( 1067 | PacketType.DELIVER_FILE_START, 1068 | route_key, 1069 | delivery_key, 1070 | file_name, 1071 | open_method, 1072 | mime_type, 1073 | encoding, 1074 | ), 1075 | ) 1076 | 1077 | @classmethod 1078 | def build( 1079 | cls, 1080 | route_key: str, 1081 | delivery_key: str, 1082 | file_name: str, 1083 | open_method: str, 1084 | mime_type: str, 1085 | encoding: str, 1086 | ) -> "DeliverFileStart": 1087 | """Build and validate a packet from its attributes.""" 1088 | if not isinstance(route_key, str): 1089 | raise TypeError( 1090 | f'packets.DeliverFileStart Type of "route_key" incorrect; expected str, found {type(route_key)}' 1091 | ) 1092 | if not isinstance(delivery_key, str): 1093 | raise TypeError( 1094 | f'packets.DeliverFileStart Type of "delivery_key" incorrect; expected str, found {type(delivery_key)}' 1095 | ) 1096 | if not isinstance(file_name, str): 1097 | raise TypeError( 1098 | f'packets.DeliverFileStart Type of "file_name" incorrect; expected str, found {type(file_name)}' 1099 | ) 1100 | if not isinstance(open_method, str): 1101 | raise TypeError( 1102 | f'packets.DeliverFileStart Type of "open_method" incorrect; expected str, found {type(open_method)}' 1103 | ) 1104 | if not isinstance(mime_type, str): 1105 | raise TypeError( 1106 | f'packets.DeliverFileStart Type of "mime_type" incorrect; expected str, found {type(mime_type)}' 1107 | ) 1108 | if not isinstance(encoding, str): 1109 | raise TypeError( 1110 | f'packets.DeliverFileStart Type of "encoding" incorrect; expected str, found {type(encoding)}' 1111 | ) 1112 | return tuple.__new__( 1113 | cls, 1114 | ( 1115 | PacketType.DELIVER_FILE_START, 1116 | route_key, 1117 | delivery_key, 1118 | file_name, 1119 | open_method, 1120 | mime_type, 1121 | encoding, 1122 | ), 1123 | ) 1124 | 1125 | def __repr__(self) -> str: 1126 | _type, route_key, delivery_key, file_name, open_method, mime_type, encoding = ( 1127 | self 1128 | ) 1129 | return f"DeliverFileStart({abbreviate_repr(route_key)}, {abbreviate_repr(delivery_key)}, {abbreviate_repr(file_name)}, {abbreviate_repr(open_method)}, {abbreviate_repr(mime_type)}, {abbreviate_repr(encoding)})" 1130 | 1131 | def __rich_repr__(self) -> rich.repr.Result: 1132 | yield "route_key", self.route_key 1133 | yield "delivery_key", self.delivery_key 1134 | yield "file_name", self.file_name 1135 | yield "open_method", self.open_method 1136 | yield "mime_type", self.mime_type 1137 | yield "encoding", self.encoding 1138 | 1139 | @property 1140 | def route_key(self) -> str: 1141 | """Route key.""" 1142 | return self[1] 1143 | 1144 | @property 1145 | def delivery_key(self) -> str: 1146 | """Delivery key.""" 1147 | return self[2] 1148 | 1149 | @property 1150 | def file_name(self) -> str: 1151 | """File name.""" 1152 | return self[3] 1153 | 1154 | @property 1155 | def open_method(self) -> str: 1156 | """Open method.""" 1157 | return self[4] 1158 | 1159 | @property 1160 | def mime_type(self) -> str: 1161 | """MIME type.""" 1162 | return self[5] 1163 | 1164 | @property 1165 | def encoding(self) -> str: 1166 | """Encoding.""" 1167 | return self[6] 1168 | 1169 | 1170 | # PacketType.REQUEST_DELIVER_CHUNK (17) 1171 | class RequestDeliverChunk(Packet): 1172 | """The server requests a chunk of a file from the running app. 1173 | 1174 | Args: 1175 | route_key (str): Route key. 1176 | delivery_key (str): Delivery key. 1177 | chunk_size (int): Chunk size. 1178 | 1179 | """ 1180 | 1181 | sender: ClassVar[str] = "server" 1182 | """Permitted sender, should be "client", "server", or "both".""" 1183 | handler_name: ClassVar[str] = "on_request_deliver_chunk" 1184 | """Name of the method used to handle this packet.""" 1185 | type: ClassVar[PacketType] = PacketType.REQUEST_DELIVER_CHUNK 1186 | """The packet type enumeration.""" 1187 | 1188 | _attributes: ClassVar[list[tuple[str, Type]]] = [ 1189 | ("route_key", str), 1190 | ("delivery_key", str), 1191 | ("chunk_size", int), 1192 | ] 1193 | _attribute_count = 3 1194 | _get_handler = attrgetter("on_request_deliver_chunk") 1195 | 1196 | def __new__( 1197 | cls, route_key: str, delivery_key: str, chunk_size: int 1198 | ) -> "RequestDeliverChunk": 1199 | return tuple.__new__( 1200 | cls, (PacketType.REQUEST_DELIVER_CHUNK, route_key, delivery_key, chunk_size) 1201 | ) 1202 | 1203 | @classmethod 1204 | def build( 1205 | cls, route_key: str, delivery_key: str, chunk_size: int 1206 | ) -> "RequestDeliverChunk": 1207 | """Build and validate a packet from its attributes.""" 1208 | if not isinstance(route_key, str): 1209 | raise TypeError( 1210 | f'packets.RequestDeliverChunk Type of "route_key" incorrect; expected str, found {type(route_key)}' 1211 | ) 1212 | if not isinstance(delivery_key, str): 1213 | raise TypeError( 1214 | f'packets.RequestDeliverChunk Type of "delivery_key" incorrect; expected str, found {type(delivery_key)}' 1215 | ) 1216 | if not isinstance(chunk_size, int): 1217 | raise TypeError( 1218 | f'packets.RequestDeliverChunk Type of "chunk_size" incorrect; expected int, found {type(chunk_size)}' 1219 | ) 1220 | return tuple.__new__( 1221 | cls, (PacketType.REQUEST_DELIVER_CHUNK, route_key, delivery_key, chunk_size) 1222 | ) 1223 | 1224 | def __repr__(self) -> str: 1225 | _type, route_key, delivery_key, chunk_size = self 1226 | return f"RequestDeliverChunk({abbreviate_repr(route_key)}, {abbreviate_repr(delivery_key)}, {abbreviate_repr(chunk_size)})" 1227 | 1228 | def __rich_repr__(self) -> rich.repr.Result: 1229 | yield "route_key", self.route_key 1230 | yield "delivery_key", self.delivery_key 1231 | yield "chunk_size", self.chunk_size 1232 | 1233 | @property 1234 | def route_key(self) -> str: 1235 | """Route key.""" 1236 | return self[1] 1237 | 1238 | @property 1239 | def delivery_key(self) -> str: 1240 | """Delivery key.""" 1241 | return self[2] 1242 | 1243 | @property 1244 | def chunk_size(self) -> int: 1245 | """Chunk size.""" 1246 | return self[3] 1247 | 1248 | 1249 | # A mapping of the packet id on to the packet class 1250 | PACKET_MAP: dict[int, type[Packet]] = { 1251 | 1: Ping, 1252 | 2: Pong, 1253 | 3: Log, 1254 | 4: Info, 1255 | 5: DeclareApps, 1256 | 6: SessionOpen, 1257 | 7: SessionClose, 1258 | 8: SessionData, 1259 | 9: RoutePing, 1260 | 10: RoutePong, 1261 | 11: NotifyTerminalSize, 1262 | 12: Focus, 1263 | 13: Blur, 1264 | 14: OpenUrl, 1265 | 15: BinaryEncodedMessage, 1266 | 16: DeliverFileStart, 1267 | 17: RequestDeliverChunk, 1268 | } 1269 | 1270 | # A mapping of the packet name on to the packet class 1271 | PACKET_NAME_MAP: dict[str, type[Packet]] = { 1272 | "ping": Ping, 1273 | "pong": Pong, 1274 | "log": Log, 1275 | "info": Info, 1276 | "declareapps": DeclareApps, 1277 | "sessionopen": SessionOpen, 1278 | "sessionclose": SessionClose, 1279 | "sessiondata": SessionData, 1280 | "routeping": RoutePing, 1281 | "routepong": RoutePong, 1282 | "notifyterminalsize": NotifyTerminalSize, 1283 | "focus": Focus, 1284 | "blur": Blur, 1285 | "openurl": OpenUrl, 1286 | "binaryencodedmessage": BinaryEncodedMessage, 1287 | "deliverfilestart": DeliverFileStart, 1288 | "requestdeliverchunk": RequestDeliverChunk, 1289 | } 1290 | 1291 | 1292 | class Handlers: 1293 | """Base class for handlers.""" 1294 | 1295 | async def dispatch_packet(self, packet: Packet) -> None: 1296 | """Dispatch a packet to the appropriate handler. 1297 | 1298 | Args: 1299 | packet (Packet): A packet object. 1300 | 1301 | """ 1302 | 1303 | await packet._get_handler(self)(packet) 1304 | 1305 | async def on_ping(self, packet: Ping) -> None: 1306 | """Request packet data to be returned via a Pong.""" 1307 | await self.on_default(packet) 1308 | 1309 | async def on_pong(self, packet: Pong) -> None: 1310 | """Response to a Ping packet. The data from Ping should be sent back in the Pong.""" 1311 | await self.on_default(packet) 1312 | 1313 | async def on_log(self, packet: Log) -> None: 1314 | """A message to be written to debug logs. This is a debugging aid, and will be disabled in production.""" 1315 | await self.on_default(packet) 1316 | 1317 | async def on_info(self, packet: Info) -> None: 1318 | """Info message to be written in to logs. Unlike Log, these messages will be used in production.""" 1319 | await self.on_default(packet) 1320 | 1321 | async def on_declare_apps(self, packet: DeclareApps) -> None: 1322 | """Declare the apps exposed.""" 1323 | await self.on_default(packet) 1324 | 1325 | async def on_session_open(self, packet: SessionOpen) -> None: 1326 | """Notification sent by a client when an app session was opened""" 1327 | await self.on_default(packet) 1328 | 1329 | async def on_session_close(self, packet: SessionClose) -> None: 1330 | """Close an existing app session.""" 1331 | await self.on_default(packet) 1332 | 1333 | async def on_session_data(self, packet: SessionData) -> None: 1334 | """Data for a session.""" 1335 | await self.on_default(packet) 1336 | 1337 | async def on_route_ping(self, packet: RoutePing) -> None: 1338 | """Session ping""" 1339 | await self.on_default(packet) 1340 | 1341 | async def on_route_pong(self, packet: RoutePong) -> None: 1342 | """Session pong""" 1343 | await self.on_default(packet) 1344 | 1345 | async def on_notify_terminal_size(self, packet: NotifyTerminalSize) -> None: 1346 | """Notify the client that the terminal has change dimensions.""" 1347 | await self.on_default(packet) 1348 | 1349 | async def on_focus(self, packet: Focus) -> None: 1350 | """App has focus.""" 1351 | await self.on_default(packet) 1352 | 1353 | async def on_blur(self, packet: Blur) -> None: 1354 | """App was blurred.""" 1355 | await self.on_default(packet) 1356 | 1357 | async def on_open_url(self, packet: OpenUrl) -> None: 1358 | """Open a URL in the browser.""" 1359 | await self.on_default(packet) 1360 | 1361 | async def on_binary_encoded_message(self, packet: BinaryEncodedMessage) -> None: 1362 | """A message that has been binary encoded.""" 1363 | await self.on_default(packet) 1364 | 1365 | async def on_deliver_file_start(self, packet: DeliverFileStart) -> None: 1366 | """The app indicates to the server that it is ready to send a file.""" 1367 | await self.on_default(packet) 1368 | 1369 | async def on_request_deliver_chunk(self, packet: RequestDeliverChunk) -> None: 1370 | """The server requests a chunk of a file from the running app.""" 1371 | await self.on_default(packet) 1372 | 1373 | async def on_default(self, packet: Packet) -> None: 1374 | """Called when a packet is not handled.""" 1375 | 1376 | 1377 | if __name__ == "__main__": 1378 | print("packets.py imported successfully") 1379 | -------------------------------------------------------------------------------- /src/textual_web/poller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | from collections import deque 6 | import os 7 | import selectors 8 | from threading import Thread, Event 9 | 10 | 11 | @dataclass 12 | class Write: 13 | """Data in a write queue.""" 14 | 15 | data: bytes 16 | position: int = 0 17 | done_event: asyncio.Event = field(default_factory=asyncio.Event) 18 | 19 | 20 | class Poller(Thread): 21 | """A thread which reads from file descriptors and posts read data to a queue.""" 22 | 23 | def __init__(self) -> None: 24 | super().__init__() 25 | self._loop: asyncio.AbstractEventLoop | None = None 26 | self._selector = selectors.DefaultSelector() 27 | self._read_queues: dict[int, asyncio.Queue[bytes | None]] = {} 28 | self._write_queues: dict[int, deque[Write]] = {} 29 | self._exit_event = Event() 30 | 31 | def add_file(self, file_descriptor: int) -> asyncio.Queue: 32 | """Add a file descriptor to the poller. 33 | 34 | Args: 35 | file_descriptor: File descriptor. 36 | 37 | Returns: 38 | Async queue. 39 | """ 40 | self._selector.register( 41 | file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE 42 | ) 43 | queue = self._read_queues[file_descriptor] = asyncio.Queue() 44 | return queue 45 | 46 | def remove_file(self, file_descriptor: int) -> None: 47 | """Remove a file descriptor from the poller. 48 | 49 | Args: 50 | file_descriptor: File descriptor. 51 | """ 52 | self._selector.unregister(file_descriptor) 53 | self._read_queues.pop(file_descriptor, None) 54 | self._write_queues.pop(file_descriptor, None) 55 | 56 | async def write(self, file_descriptor: int, data: bytes) -> None: 57 | """Write data to a file descriptor. 58 | 59 | Args: 60 | file_descriptor: File descriptor. 61 | data: Data to write. 62 | """ 63 | if file_descriptor not in self._write_queues: 64 | self._write_queues[file_descriptor] = deque() 65 | new_write = Write(data) 66 | self._write_queues[file_descriptor].append(new_write) 67 | self._selector.modify( 68 | file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE 69 | ) 70 | await new_write.done_event.wait() 71 | 72 | def set_loop(self, loop: asyncio.AbstractEventLoop) -> None: 73 | """Set the asyncio loop. 74 | 75 | Args: 76 | loop: Async loop. 77 | """ 78 | self._loop = loop 79 | 80 | def run(self) -> None: 81 | """Run the Poller thread.""" 82 | 83 | readable_events = selectors.EVENT_READ 84 | writeable_events = selectors.EVENT_WRITE 85 | 86 | loop = self._loop 87 | selector = self._selector 88 | assert loop is not None 89 | while not self._exit_event.is_set(): 90 | events = selector.select(1) 91 | 92 | for selector_key, event_mask in events: 93 | file_descriptor = selector_key.fileobj 94 | assert isinstance(file_descriptor, int) 95 | 96 | queue = self._read_queues.get(file_descriptor, None) 97 | if queue is not None: 98 | if event_mask & readable_events: 99 | try: 100 | data = os.read(file_descriptor, 1024 * 32) or None 101 | except Exception: 102 | loop.call_soon_threadsafe(queue.put_nowait, None) 103 | else: 104 | loop.call_soon_threadsafe(queue.put_nowait, data) 105 | 106 | if event_mask & writeable_events: 107 | write_queue = self._write_queues.get(file_descriptor, None) 108 | if write_queue: 109 | write = write_queue[0] 110 | bytes_written = os.write( 111 | file_descriptor, write.data[write.position :] 112 | ) 113 | if bytes_written == len(write.data): 114 | write_queue.popleft() 115 | loop.call_soon_threadsafe(write.done_event.set) 116 | else: 117 | write.position += bytes_written 118 | else: 119 | selector.modify(file_descriptor, readable_events) 120 | 121 | def exit(self) -> None: 122 | """Exit and block until finished.""" 123 | for queue in self._read_queues.values(): 124 | queue.put_nowait(None) 125 | self._exit_event.set() 126 | self.join() 127 | self._read_queues.clear() 128 | self._write_queues.clear() 129 | -------------------------------------------------------------------------------- /src/textual_web/retry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import AsyncGenerator 4 | from asyncio import Event, TimeoutError, wait_for 5 | from random import random 6 | import logging 7 | 8 | log = logging.getLogger("textual-web") 9 | 10 | 11 | class Retry: 12 | """Manage exponential backoff.""" 13 | 14 | def __init__( 15 | self, 16 | done_event: Event | None = None, 17 | min_wait: float = 2.0, 18 | max_wait: float = 16.0, 19 | ) -> None: 20 | """ 21 | Args: 22 | done_event: An event to exit the retry loop. 23 | min_wait: Minimum delay in seconds. 24 | max_wait: Maximum delay in seconds. 25 | """ 26 | self.min_wait = min_wait 27 | self.max_wait = max_wait 28 | self._done_event = Event() if done_event is None else done_event 29 | self.retry_count = 0 30 | 31 | def success(self) -> None: 32 | """Call when connection was successful.""" 33 | self.retry_count = 0 34 | 35 | def done(self) -> None: 36 | """Exit retry loop.""" 37 | self._done_event.set() 38 | 39 | async def __aiter__(self) -> AsyncGenerator[int, object]: 40 | """Async iterator to manage timeouts.""" 41 | while not self._done_event.is_set(): 42 | self.retry_count = self.retry_count + 1 43 | yield self.retry_count 44 | 45 | retry_squared = self.retry_count**2 46 | sleep_for = random() * max(self.min_wait, min(self.max_wait, retry_squared)) 47 | 48 | log.debug("Retrying after %dms", int(sleep_for * 1000.0)) 49 | 50 | try: 51 | await wait_for(self._done_event.wait(), sleep_for) 52 | except TimeoutError: 53 | pass 54 | -------------------------------------------------------------------------------- /src/textual_web/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | import asyncio 5 | from .types import Meta 6 | 7 | 8 | class SessionConnector: 9 | """Connect a session with a client.""" 10 | 11 | async def on_data(self, data: bytes) -> None: 12 | """Handle data from session. 13 | 14 | Args: 15 | data: Bytes to handle. 16 | """ 17 | 18 | async def on_meta(self, meta: Meta) -> None: 19 | """Handle meta from session. 20 | 21 | Args: 22 | meta: Mapping of meta information. 23 | """ 24 | 25 | async def on_binary_encoded_message(self, payload: bytes) -> None: 26 | """Handle binary encoded data from the process. 27 | 28 | Args: 29 | payload: Binary encoded data to handle. 30 | """ 31 | 32 | async def on_close(self) -> None: 33 | """Handle session close.""" 34 | 35 | 36 | class Session(ABC): 37 | """Virtual base class for a session.""" 38 | 39 | def __init__(self) -> None: 40 | self._connector = SessionConnector() 41 | 42 | @abstractmethod 43 | async def open(self, width: int = 80, height: int = 24) -> None: 44 | """Open the session.""" 45 | ... 46 | 47 | @abstractmethod 48 | async def start(self, connector: SessionConnector) -> asyncio.Task: 49 | """Start the session. 50 | 51 | Returns: 52 | Running task. 53 | """ 54 | ... 55 | 56 | @abstractmethod 57 | async def close(self) -> None: 58 | """Close the session.""" 59 | 60 | @abstractmethod 61 | async def wait(self) -> None: 62 | """Wait for session to end.""" 63 | 64 | @abstractmethod 65 | async def set_terminal_size(self, width: int, height: int) -> None: 66 | """Set the terminal size. 67 | 68 | Args: 69 | width: New width. 70 | height: New height. 71 | """ 72 | ... 73 | 74 | @abstractmethod 75 | async def send_bytes(self, data: bytes) -> bool: 76 | """Send bytes to the process. 77 | 78 | Args: 79 | data: Bytes to send. 80 | 81 | Returns: 82 | True on success, or False if the data was not sent. 83 | """ 84 | ... 85 | 86 | @abstractmethod 87 | async def send_meta(self, data: Meta) -> bool: 88 | """Send meta to the process. 89 | 90 | Args: 91 | meta: Meta information. 92 | 93 | Returns: 94 | True on success, or False if the data was not sent. 95 | """ 96 | ... 97 | -------------------------------------------------------------------------------- /src/textual_web/session_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from pathlib import Path 6 | import platform 7 | 8 | from . import config 9 | from .identity import generate 10 | 11 | from .app_session import AppSession 12 | from .session import Session 13 | 14 | from .poller import Poller 15 | from .types import SessionID, RouteKey 16 | from ._two_way_dict import TwoWayDict 17 | 18 | WINDOWS = platform.system() == "Windows" 19 | 20 | 21 | log = logging.getLogger("textual-web") 22 | 23 | 24 | if not WINDOWS: 25 | from .terminal_session import TerminalSession 26 | 27 | 28 | class SessionManager: 29 | """Manage sessions (Textual apps or terminals).""" 30 | 31 | def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None: 32 | self.poller = poller 33 | self.path = path 34 | self.apps = apps 35 | self.apps_by_slug = {app.slug: app for app in apps} 36 | self.sessions: dict[SessionID, Session] = {} 37 | self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict() 38 | 39 | def add_app( 40 | self, name: str, command: str, slug: str, terminal: bool = False 41 | ) -> None: 42 | """Add a new app 43 | 44 | Args: 45 | name: Name of the app. 46 | command: Command to run the app. 47 | slug: Slug used in URL, or blank to auto-generate on server. 48 | """ 49 | slug = slug or generate().lower() 50 | new_app = config.App( 51 | name=name, slug=slug, path="./", command=command, terminal=terminal 52 | ) 53 | self.apps.append(new_app) 54 | self.apps_by_slug[slug] = new_app 55 | 56 | def on_session_end(self, session_id: SessionID) -> None: 57 | """Called by sessions.""" 58 | self.sessions.pop(session_id) 59 | route_key = self.routes.get_key(session_id) 60 | if route_key is not None: 61 | del self.routes[route_key] 62 | 63 | async def close_all(self, timeout: float = 3.0) -> None: 64 | """Close app sessions. 65 | 66 | Args: 67 | timeout: Time (in seconds) to wait before giving up. 68 | 69 | """ 70 | sessions = list(self.sessions.values()) 71 | 72 | if not sessions: 73 | return 74 | log.info("Closing %s session(s)", len(sessions)) 75 | 76 | async def do_close() -> int: 77 | """Close all sessions, return number unclosed after timeout 78 | 79 | Returns: 80 | Number of sessions not yet closed. 81 | """ 82 | 83 | async def close_wait(session: Session) -> None: 84 | await session.close() 85 | await session.wait() 86 | 87 | _done, remaining = await asyncio.wait( 88 | [asyncio.create_task(close_wait(session)) for session in sessions], 89 | timeout=timeout, 90 | ) 91 | return len(remaining) 92 | 93 | remaining = await do_close() 94 | if remaining: 95 | log.warning("%s session(s) didn't close after %s seconds", timeout) 96 | 97 | async def new_session( 98 | self, 99 | slug: str, 100 | session_id: SessionID, 101 | route_key: RouteKey, 102 | devtools: bool = False, 103 | size: tuple[int, int] = (80, 24), 104 | ) -> Session | None: 105 | """Create a new seession. 106 | 107 | Args: 108 | slug: Slug for app. 109 | session_id: Session identity. 110 | route_key: Route key. 111 | devtools: Enable devtools in Textual apps 112 | 113 | Returns: 114 | New session, or `None` if no app / terminal configured. 115 | """ 116 | app = self.apps_by_slug.get(slug) 117 | if app is None: 118 | return None 119 | 120 | session_process: Session 121 | if app.terminal: 122 | if WINDOWS: 123 | log.warn( 124 | "Sorry, textual-web does not currently support terminals on Windows" 125 | ) 126 | return None 127 | else: 128 | session_process = TerminalSession( 129 | self.poller, 130 | session_id, 131 | app.command, 132 | ) 133 | else: 134 | session_process = AppSession( 135 | self.path, 136 | app.command, 137 | session_id, 138 | devtools=devtools, 139 | ) 140 | self.sessions[session_id] = session_process 141 | self.routes[route_key] = session_id 142 | 143 | await session_process.open(*size) 144 | 145 | return session_process 146 | 147 | async def close_session(self, session_id: SessionID) -> None: 148 | """Close a session. 149 | 150 | Args: 151 | session_id: Session identity. 152 | """ 153 | session_process = self.sessions.get(session_id, None) 154 | if session_process is None: 155 | return 156 | await session_process.close() 157 | 158 | def get_session(self, session_id: SessionID) -> Session | None: 159 | """Get a session from a session ID. 160 | 161 | Args: 162 | session_id: Session identity. 163 | 164 | Returns: 165 | A session or `None` if it doesn't exist. 166 | """ 167 | return self.sessions.get(session_id) 168 | 169 | def get_session_by_route_key(self, route_key: RouteKey) -> Session | None: 170 | """Get a session from a route key. 171 | 172 | Args: 173 | route_key: A route key. 174 | 175 | Returns: 176 | A session or `None` if it doesn't exist. 177 | 178 | """ 179 | session_id = self.routes.get(route_key) 180 | if session_id is not None: 181 | return self.sessions.get(session_id) 182 | else: 183 | return None 184 | -------------------------------------------------------------------------------- /src/textual_web/slugify.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unicodedata 3 | 4 | 5 | def slugify(value: str, allow_unicode=False) -> str: 6 | """ 7 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated 8 | dashes to single dashes. Remove characters that aren't alphanumerics, 9 | underscores, or hyphens. Convert to lowercase. Also strip leading and 10 | trailing whitespace, dashes, and underscores. 11 | """ 12 | value = str(value) 13 | if allow_unicode: 14 | value = unicodedata.normalize("NFKC", value) 15 | else: 16 | value = ( 17 | unicodedata.normalize("NFKD", value) 18 | .encode("ascii", "ignore") 19 | .decode("ascii") 20 | ) 21 | value = re.sub(r"[^\w\s-]", "", value.lower()) 22 | return re.sub(r"[-\s]+", "-", value).strip("-_") 23 | -------------------------------------------------------------------------------- /src/textual_web/terminal_session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import array 5 | import fcntl 6 | import logging 7 | import os 8 | import pty 9 | import signal 10 | import termios 11 | 12 | from importlib_metadata import version 13 | import rich.repr 14 | 15 | from .poller import Poller 16 | from .session import Session, SessionConnector 17 | from .types import Meta, SessionID 18 | 19 | log = logging.getLogger("textual-web") 20 | 21 | @rich.repr.auto 22 | class TerminalSession(Session): 23 | """A session that manages a terminal.""" 24 | 25 | def __init__( 26 | self, 27 | poller: Poller, 28 | session_id: SessionID, 29 | command: str, 30 | ) -> None: 31 | self.poller = poller 32 | self.session_id = session_id 33 | self.command = command or os.environ.get("SHELL", "sh") 34 | self.master_fd: int | None = None 35 | self.pid: int | None = None 36 | self._task: asyncio.Task | None = None 37 | super().__init__() 38 | 39 | def __rich_repr__(self) -> rich.repr.Result: 40 | yield "session_id", self.session_id 41 | yield "command", self.command 42 | 43 | async def open(self, width: int = 80, height: int = 24) -> None: 44 | pid, master_fd = pty.fork() 45 | self.pid = pid 46 | self.master_fd = master_fd 47 | if pid == pty.CHILD: 48 | os.environ["TERM_PROGRAM"] = "textual-web" 49 | os.environ["TERM_PROGRAM_VERSION"] = version("textual-web") 50 | argv = [self.command] 51 | try: 52 | os.execlp(argv[0], *argv) ## Exits the app 53 | except Exception: 54 | os._exit(0) 55 | self._set_terminal_size(width, height) 56 | 57 | def _set_terminal_size(self, width: int, height: int) -> None: 58 | buf = array.array("h", [height, width, 0, 0]) 59 | assert self.master_fd is not None 60 | fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, buf) 61 | 62 | async def set_terminal_size(self, width: int, height: int) -> None: 63 | self._set_terminal_size(width, height) 64 | 65 | async def start(self, connector: SessionConnector) -> asyncio.Task: 66 | self._connector = connector 67 | assert self.master_fd is not None 68 | assert self._task is None 69 | self._task = self._task = asyncio.create_task(self.run()) 70 | return self._task 71 | 72 | async def run(self) -> None: 73 | assert self.master_fd is not None 74 | queue = self.poller.add_file(self.master_fd) 75 | on_data = self._connector.on_data 76 | on_close = self._connector.on_close 77 | try: 78 | while True: 79 | data = await queue.get() or None 80 | if data is None: 81 | break 82 | await on_data(data) 83 | except Exception: 84 | log.exception("error in terminal.run") 85 | finally: 86 | await on_close() 87 | os.close(self.master_fd) 88 | self.poller.remove_file(self.master_fd) 89 | self.master_fd = None 90 | 91 | async def send_bytes(self, data: bytes) -> bool: 92 | if self.master_fd is None: 93 | return False 94 | await self.poller.write(self.master_fd, data) 95 | return True 96 | 97 | async def send_meta(self, data: Meta) -> bool: 98 | return True 99 | 100 | async def close(self) -> None: 101 | if self.pid is not None: 102 | os.kill(self.pid, signal.SIGHUP) 103 | 104 | async def wait(self) -> None: 105 | if self._task is not None: 106 | await self._task 107 | -------------------------------------------------------------------------------- /src/textual_web/types.py: -------------------------------------------------------------------------------- 1 | from typing import NewType, Union, Dict 2 | 3 | 4 | AppID = NewType("AppID", str) 5 | Meta = Dict[str, Union[str, None, int, bool]] 6 | RouteKey = NewType("RouteKey", str) 7 | SessionID = NewType("SessionID", str) 8 | -------------------------------------------------------------------------------- /src/textual_web/web.py: -------------------------------------------------------------------------------- 1 | """ 2 | An optional web interface to control textual-web 3 | 4 | Note: Currently just a stub. 5 | 6 | """ 7 | 8 | import logging 9 | 10 | import asyncio 11 | from aiohttp import web 12 | 13 | 14 | log = logging.getLogger("textual-web") 15 | 16 | 17 | async def run_web_interface(connected_event: asyncio.Event) -> web.Application: 18 | """Run the web interface.""" 19 | 20 | async def health_check(request) -> web.Response: 21 | await asyncio.wait_for(connected_event.wait(), 5.0) 22 | return web.Response(text="Hello, world") 23 | 24 | app = web.Application() 25 | app.add_routes([web.get("/health-check/", health_check)]) 26 | 27 | runner = web.AppRunner(app) 28 | await runner.setup() 29 | site = web.TCPSite(runner, "0.0.0.0", 8080) 30 | await site.start() 31 | log.info("Web interface started on port 8080") 32 | return app 33 | --------------------------------------------------------------------------------