├── .gitignore ├── API.md ├── CODE_OF_CONDUCT.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── cabal.project ├── clients ├── haskell │ ├── CHANGELOG.md │ ├── LICENSE │ ├── Setup.hs │ ├── enabled-events.json │ ├── myxine-client.cabal │ ├── src │ │ ├── Myxine.hs │ │ └── Myxine │ │ │ ├── ConjMap.hs │ │ │ ├── Direct.hs │ │ │ ├── Event.hs │ │ │ ├── Handlers.hs │ │ │ ├── Internal │ │ │ ├── Event.hs │ │ │ └── TH.hs │ │ │ ├── Page.hs │ │ │ ├── Reactive.hs │ │ │ └── Target.hs │ ├── stack.yaml │ ├── stack.yaml.lock │ └── test │ │ └── Test.hs └── python │ ├── LICENSE │ ├── MANIFEST.in │ ├── README.md │ ├── myxine │ ├── __init__.py │ └── myxine.py │ └── setup.py ├── core ├── Cargo.toml ├── LICENSE ├── README.md └── src │ ├── lib.rs │ ├── page.rs │ ├── page │ ├── content.rs │ ├── query.rs │ └── subscription.rs │ └── session.rs ├── enabled-events.json ├── enabled-events.svg ├── examples ├── haskell │ ├── CHANGELOG.md │ ├── Counter.hs │ ├── LICENSE │ ├── Setup.hs │ ├── Todo.hs │ ├── Toggles.hs │ ├── circles.hs │ └── myxine-client-examples.cabal ├── python │ ├── angles.py │ ├── circles.py │ └── follow.py └── rust │ ├── angles.rs │ └── spin.rs ├── images ├── eptatretus_polytrema.jpg └── myxine_glutinosa.png ├── notes └── events.json ├── scripts ├── generate-event-enum.py ├── generate-event-map.py └── scrape-event-info.js └── server ├── Cargo.toml ├── LICENSE ├── README.md ├── deps └── diffhtml.min.js └── src ├── connect.js ├── dynamic.html ├── dynamic.js ├── enabled-events.json ├── main.rs ├── params.rs └── server.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS things 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | virtenv/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # Haskell ignores... 141 | dist 142 | dist-* 143 | cabal-dev 144 | *.o 145 | *.hi 146 | *.hie 147 | *.chi 148 | *.chs.h 149 | *.dyn_o 150 | *.dyn_hi 151 | .hpc 152 | .hsenv 153 | .cabal-sandbox/ 154 | cabal.sandbox.config 155 | *.prof 156 | *.aux 157 | *.hp 158 | *.eventlog 159 | .stack-work/ 160 | cabal.project.local 161 | cabal.project.local~ 162 | .HTF/ 163 | .ghc.environment.* 164 | TAGS 165 | 166 | 167 | # Generated by Cargo 168 | # will have compiled files and executables 169 | /target/ 170 | 171 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 172 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 173 | Cargo.lock 174 | 175 | # These are backup files generated by rustfmt 176 | **/*.rs.bk 177 | 178 | # Node.js ignores: 179 | 180 | # Logs 181 | logs 182 | *.log 183 | npm-debug.log* 184 | yarn-debug.log* 185 | yarn-error.log* 186 | lerna-debug.log* 187 | 188 | # Diagnostic reports (https://nodejs.org/api/report.html) 189 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 190 | 191 | # Runtime data 192 | pids 193 | *.pid 194 | *.seed 195 | *.pid.lock 196 | 197 | # Directory for instrumented libs generated by jscoverage/JSCover 198 | lib-cov 199 | 200 | # Coverage directory used by tools like istanbul 201 | coverage 202 | *.lcov 203 | 204 | # nyc test coverage 205 | .nyc_output 206 | 207 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 208 | .grunt 209 | 210 | # Bower dependency directory (https://bower.io/) 211 | bower_components 212 | 213 | # node-waf configuration 214 | .lock-wscript 215 | 216 | # Compiled binary addons (https://nodejs.org/api/addons.html) 217 | build/Release 218 | 219 | # Dependency directories 220 | node_modules/ 221 | jspm_packages/ 222 | 223 | # TypeScript v1 declaration files 224 | typings/ 225 | 226 | # TypeScript cache 227 | *.tsbuildinfo 228 | 229 | # Optional npm cache directory 230 | .npm 231 | 232 | # Optional eslint cache 233 | .eslintcache 234 | 235 | # Microbundle cache 236 | .rpt2_cache/ 237 | .rts2_cache_cjs/ 238 | .rts2_cache_es/ 239 | .rts2_cache_umd/ 240 | 241 | # Optional REPL history 242 | .node_repl_history 243 | 244 | # Output of 'npm pack' 245 | *.tgz 246 | 247 | # Yarn Integrity file 248 | .yarn-integrity 249 | 250 | # dotenv environment variables file 251 | .env 252 | .env.test 253 | 254 | # parcel-bundler cache (https://parceljs.org/) 255 | .cache 256 | 257 | # Next.js build output 258 | .next 259 | 260 | # Nuxt.js build / generate output 261 | .nuxt 262 | dist 263 | 264 | # Gatsby files 265 | .cache/ 266 | # Comment in the public line in if your project uses Gatsby and not Next.js 267 | # https://nextjs.org/blog/next-9-1#public-directory-support 268 | # public 269 | 270 | # vuepress build output 271 | .vuepress/dist 272 | 273 | # Serverless directories 274 | .serverless/ 275 | 276 | # FuseBox cache 277 | .fusebox/ 278 | 279 | # DynamoDB Local files 280 | .dynamodb/ 281 | 282 | # TernJS port file 283 | .tern-port 284 | 285 | # Stores VSCode versions used for testing VSCode extensions 286 | .vscode-test 287 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [kwf@very.science](mailto:kwf@very.science). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 121 | 122 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 123 | enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | [homepage]: https://www.contributor-covenant.org 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | https://www.contributor-covenant.org/faq. Translations are available at 129 | https://www.contributor-covenant.org/translations. 130 | 131 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "server", 4 | "core", 5 | ] 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kenny Foner and Galois, Inc. 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 | # Myxine: a slithery sea-friend to help you _get GUI fast_ 2 | 3 | 4 | 5 | 8 | 12 | 13 |
6 | woodcut sketch of myxine glutinosa, the hagfish 7 | 9 |

Hagfish, a.k.a. myxine glutinosa, are eel-like sea creatures best known for their ability to make a lot of slime.

10 |

By analogy, myxine quickly reduces the friction in creating a dynamic graphical interface, helping you to get GUI fast in any language under the sea.

11 |
14 | 15 |

16 | 17 | 18 | 19 | 20 | 21 |

22 | 23 | Myxine is a local web server that enables you to create interactive applications 24 | in your web browser from the comfort of your favorite programming language. It's 25 | designed to satisfy three explicit goals: 26 | 27 | 1. To enable programmers who don't necessarily specialize in UI design to build 28 | appealing interactive applications without learning a complex UI framework. 29 | 2. To make it easy to write correct, efficient, and idiomatic bindings to Myxine 30 | from almost any programming language. 31 | 3. To be as fast as possible while consuming as few resources as possible. 32 | 33 | **Here's how it works:** 34 | 35 | 1. You start Myxine and open your browser to a page it is serving. 36 | 2. From your programming language of choice, you send some HTML to Myxine, and 37 | it instantly appears, replacing the contents of that page's ``. 38 | 3. You then request a subscription to whichever browser events in which you're 39 | interested, and Myxine notifies you when each one occurs, eliding those you 40 | don't care about. 41 | 4. You can then react to those events, updating the page again to reflect what 42 | you'd like it to look like now. Rinse and repeat! 43 | 44 | Myxine handles the hard work of optimizing this process to minimize latency and 45 | computational load: it can handle thousands of requests per second and 46 | translate them into smooth, flicker-free animations at up to 60 frames per 47 | second. 48 | 49 | ## Installing 50 | 51 | To install the Myxine server, you will need a recent version of the Rust 52 | programming langauge and its build tool, `cargo`. If you don't have it, [here's 53 | the quick-start for installing 54 | Rust](https://www.rust-lang.org/learn/get-started). After you have that set up, 55 | install the Myxine server: 56 | 57 | ```bash 58 | $ cargo install myxine 59 | ``` 60 | 61 | Installing a client library for Myxine will require steps specific to that 62 | library: consult the appropriate library's documentation to find out how to 63 | install it. 64 | 65 | ## Building interactive applications 66 | 67 | You can interact directly with the server via HTTP requests (see the [API 68 | documentation](API.md) for details), but most likely you will want to use a 69 | library of lightweight bindings in your language of choice. Currently, the two 70 | client libraries officially maintained by the Myxine project are those for 71 | [Python](https://pypi.org/project/myxine-client/) and 72 | [Haskell](https://hackage.haskell.org/package/myxine-client). 73 | 74 | If you're interested in writing Myxine bindings for a new language, you'll want 75 | to read the [API documentation](API.md), and perhaps reference one or more of 76 | the [existing client libraries](clients/). Don't be afraid to ask for help by 77 | [opening an issue](https://github.com/kwf/myxine/issues/new), and please 78 | do contribute back your work by submitting a pull request! 79 | 80 | ### An example in Python 81 | 82 | If a picture is worth a thousand words, how many pictures is a visual 83 | interaction worth? 84 | 85 | Below is a simple, complete Myxine application in Python. For this example to 86 | work, you will need to install the Python client library: 87 | 88 | ``` bash 89 | $ pip3 install myxine-client 90 | ``` 91 | 92 | To run the example, first make sure `myxine` is running on your computer: 93 | 94 | ``` bash 95 | $ myxine 96 | Running at: http://127.0.0.1:1123 97 | ``` 98 | 99 | Then, in another terminal window, run the script: 100 | 101 | ``` bash 102 | $ ./examples/python/follow.py 103 | ``` 104 | 105 | Finally, navigate in your web browser to 106 | [http://localhost:1123](http://localhost:1123), and play around! You can press 107 | Ctrl+C in your terminal to stop the application. 108 | 109 | You can find this example and others in the [examples](examples/) directory, 110 | categorized in subdirectories by language of implementation. 111 | 112 | **Without further ado, `follow.py`:** 113 | 114 | ``` python 115 | #!/usr/bin/env python3 116 | 117 | import random 118 | import myxine 119 | 120 | class Page: 121 | # The model of the page 122 | def __init__(self): 123 | self.x, self.y = 150, 150 124 | self.hue = random.uniform(0, 360) 125 | self.radius = 75 126 | 127 | # Draw the page's model as a fragment of HTML 128 | def draw(self): 129 | circle_style = f''' 130 | position: absolute; 131 | height: {round(self.radius*2)}px; 132 | width: {round(self.radius*2)}px; 133 | top: {self.y}px; 134 | left: {self.x}px; 135 | transform: translate(-50%, -50%); 136 | border-radius: 50%; 137 | border: {round(self.radius/2)}px solid hsl({round(self.hue)}, 80%, 80%); 138 | background: hsl({round(self.hue+120)}, 80%, 75%) 139 | ''' 140 | background_style = f''' 141 | position: absolute; 142 | overflow: hidden; 143 | width: 100vw; 144 | height: 100vh; 145 | background: hsl({round(self.hue-120)}, 80%, 90%); 146 | ''' 147 | instructions_style = f''' 148 | position: absolute; 149 | bottom: 30px; 150 | left: 30px; 151 | font-family: sans-serif; 152 | font-size: 22pt; 153 | user-select: none; 154 | ''' 155 | return f''' 156 |
157 |
158 | Move the mouse to move the circle
159 | Scroll to change the circle's size
160 | Ctrl + Scroll to change the color scheme
161 | Click to randomize the color scheme
162 |
163 |
164 |
165 | ''' 166 | 167 | # Change the page's model in response to a browser event 168 | def react(self, event): 169 | if event.event() == 'mousemove': 170 | self.x = event.clientX 171 | self.y = event.clientY 172 | elif event.event() == 'mousedown': 173 | self.hue = (self.hue + random.uniform(30, 330)) % 360 174 | elif event.event() == 'wheel': 175 | if event.ctrlKey: 176 | self.hue = (self.hue + event.deltaY * -0.1) % 360 177 | else: 178 | self.radius += event.deltaY * -0.2 179 | self.radius = min(max(self.radius, 12), 1000) 180 | 181 | # The page's event loop 182 | def run(self, path): 183 | myxine.update(path, self.draw()) # Draw the page in the browser. 184 | try: 185 | for event in myxine.events(path): # For each browser event, 186 | self.react(event) # update our model of the page, 187 | myxine.update(path, self.draw()) # then re-draw it in the browser. 188 | except KeyboardInterrupt: 189 | pass # Press Ctrl-C to quit. 190 | 191 | if __name__ == '__main__': 192 | Page().run('/') # Run the page on the root path. 193 | ``` 194 | 195 | In the above, you can see each of the steps of Myxine's interaction model 196 | represented as separate Python constructs: 197 | 198 | - The `Page` class has fields that track the state of the application. 199 | - The `draw` function is a pure function from the current application state to 200 | its representation as HTML. 201 | - The `react` function updates the `Page`'s state in response to some event that 202 | occurred in the browser. 203 | - The `run` function ties it all together by looping over all events in the 204 | browser, updating the application state in reaction to each, and sending a new 205 | HTML body to the browser, to be immediately displayed to you! 206 | 207 | While different client libraries may represent this pattern using different 208 | language-specific idioms, the basic structure is the same. And despite its 209 | simplicity, it's fast! 210 | 211 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: 2 | clients/haskell/ 3 | examples/haskell/ 4 | -------------------------------------------------------------------------------- /clients/haskell/CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaidfinch/myxine/3f57767ee3059400eea5fd890f5d1b0b388a0632/clients/haskell/CHANGELOG.md -------------------------------------------------------------------------------- /clients/haskell/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /clients/haskell/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /clients/haskell/enabled-events.json: -------------------------------------------------------------------------------- 1 | ../../enabled-events.json -------------------------------------------------------------------------------- /clients/haskell/myxine-client.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: myxine-client 3 | version: 0.0.1.2 4 | synopsis: A Haskell client for the Myxine GUI server 5 | homepage: https://github.com/kwf/myxine 6 | bug-reports: https://github.com/kwf/myxine/issues/new 7 | license: MIT 8 | license-file: LICENSE 9 | author: Kenny Foner 10 | maintainer: kwf@very.science 11 | copyright: Copyright (c) 2020 Kenny Foner and Galois, Inc. 12 | category: GUI 13 | stability: alpha 14 | extra-source-files: CHANGELOG.md, enabled-events.json 15 | description: 16 | [Myxine](https://github.com/kwf/myxine) is a language-agnostic local 17 | server that lets you build interactive applications in the browser using a 18 | RESTful API. This package defines high-level typed Haskell bindings for using 19 | Myxine to quickly prototype surprisingly high-performance GUIs. 20 | . 21 | Myxine itself runs as a local server, separately from these bindings. It is 22 | built in [Rust](https://www.rust-lang.org/learn/get-started), and can be 23 | installed using the standard Rust build tool @cargo@: 24 | . 25 | > $ cargo install myxine 26 | . 27 | This Haskell package does __not__ manage the @myxine@ server process; it 28 | assumes that it is already running in the background (either started by an 29 | end-user, or managed by your own Haskell application). 30 | . 31 | __Required extensions:__ This library relies on the __@OverloadedRecordFields@__ 32 | language extension, since a variety of browser event interfaces share field 33 | names/types. Without enabling it, you'll see many bewildering errors about 34 | ambiguous names. You may also find useful for concision the extensions 35 | __@NamedFieldPuns@__ and __@RecordWildCards@__. 36 | 37 | common deps 38 | build-depends: base >= 4.12.0.0 && <= 4.14.0.0, 39 | req >= 3.1 && <= 3.3, 40 | aeson ^>= 1.4, 41 | text ^>= 1.2, 42 | mtl ^>= 2.2, 43 | transformers ^>= 0.5, 44 | bytestring ^>= 0.10, 45 | unordered-containers ^>= 0.2, 46 | containers ^>= 0.6, 47 | dependent-map ^>= 0.4, 48 | some ^>= 1.0, 49 | template-haskell >= 2.14.0.0 && <= 2.16.0.0, 50 | hashable ^>= 1.3, 51 | file-embed ^>= 0.0.11.1, 52 | http-client ^>= 0.6, 53 | http-types ^>= 0.12, 54 | modern-uri ^>= 0.3, 55 | constraints >= 0.10 && <= 0.12, 56 | salve ^>= 1.0, 57 | blaze-markup ^>= 0.8, 58 | blaze-html ^>= 0.9, 59 | spoon ^>= 0.3, 60 | async ^>= 2.2, 61 | lens ^>= 4.19 62 | 63 | common options 64 | default-language: Haskell2010 65 | ghc-options: -Wall 66 | -Wincomplete-uni-patterns 67 | -Wincomplete-record-updates 68 | -Wcompat 69 | -Widentities 70 | -Wredundant-constraints 71 | -fhide-source-paths 72 | -Wpartial-fields 73 | default-extensions: BlockArguments, 74 | DataKinds, 75 | DeriveAnyClass, 76 | DeriveGeneric, 77 | DerivingStrategies, 78 | DerivingVia, 79 | DeriveFunctor, 80 | DeriveFoldable, 81 | DeriveTraversable, 82 | DuplicateRecordFields, 83 | RecordWildCards, 84 | EmptyCase, 85 | FlexibleInstances, 86 | FlexibleContexts, 87 | GADTs, 88 | GeneralizedNewtypeDeriving, 89 | KindSignatures, 90 | LambdaCase, 91 | MultiParamTypeClasses, 92 | NamedFieldPuns, 93 | OverloadedStrings, 94 | RankNTypes, 95 | ScopedTypeVariables, 96 | StandaloneDeriving, 97 | TemplateHaskell, 98 | TupleSections, 99 | TypeApplications, 100 | ViewPatterns 101 | 102 | library 103 | import: deps, options 104 | exposed-modules: Myxine 105 | Myxine.Event 106 | Myxine.Direct 107 | Myxine.Handlers 108 | other-modules: Myxine.Reactive 109 | Myxine.Page 110 | Myxine.Target 111 | Myxine.ConjMap 112 | Myxine.Internal.TH 113 | Myxine.Internal.Event 114 | Paths_myxine_client 115 | autogen-modules: Paths_myxine_client 116 | hs-source-dirs: src 117 | 118 | test-suite myxine-client-test 119 | import: options 120 | type: exitcode-stdio-1.0 121 | hs-source-dirs: test 122 | main-is: Test.hs 123 | build-depends: myxine-client, 124 | text, 125 | bytestring 126 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description : High-level "model-view-controller" interface to the Myxine server 3 | 4 | This library implements typed bindings to the 5 | [Myxine](https://github.com/kwf/myxine) server for creating local 6 | interactive GUIs in the web browser. For more details on Myxine-the-program, 7 | see the package description of this library, or [its own 8 | homepage](https://github.com/kwf/myxine). 9 | 10 | This module defines a higher level interface which abstracts over the direct 11 | calls to the Myxine server to allow a more declarative style of programming. 12 | 13 | For a one-to-one set of bindings directly to the corresponding calls to the 14 | Myxine API see the module "Myxine.Direct". This is straightforward for small 15 | examples and tests, but can become cumbersome for building full interactive 16 | applications. 17 | -} 18 | module Myxine 19 | ( -- ** Required Extensions 20 | {-| This library relies on the extension __@OverloadedRecordFields@__, since a 21 | variety of browser event interfaces share field names/types. Without enabling 22 | it, you'll see many bewildering errors about ambiguous names. 23 | 24 | You may also find useful for concision the extensions __@NamedFieldPuns@__ and 25 | __@RecordWildCards@__. 26 | -} 27 | module Myxine.Page 28 | , module Myxine.Event 29 | ) where 30 | 31 | import Myxine.Event 32 | import Myxine.Direct 33 | import Myxine.Page 34 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/ConjMap.hs: -------------------------------------------------------------------------------- 1 | module Myxine.ConjMap 2 | ( ConjMap 3 | , empty 4 | , lookup 5 | , insert 6 | , union 7 | ) where 8 | 9 | import Prelude hiding (lookup) 10 | import Data.Maybe 11 | import Data.Hashable 12 | import qualified Data.HashSet as HashSet 13 | import qualified Data.HashMap.Strict as HashMap 14 | import Data.HashMap.Strict (HashMap) 15 | 16 | data ConjMap k a 17 | = ConjMap [a] (HashMap k (ConjMap k a)) 18 | deriving (Eq, Ord, Show, Functor, Foldable, Traversable) 19 | 20 | empty :: ConjMap k a 21 | empty = ConjMap [] HashMap.empty 22 | 23 | -- | Retrieve all items whose keys are a (non-strict) subset of the specified 24 | -- keys. 25 | lookup :: (Eq k, Hashable k) => [k] -> ConjMap k a -> [a] 26 | lookup = go . HashSet.fromList 27 | where 28 | go facts (ConjMap universal specific) = 29 | universal <> fromMaybe [] (goSpecific facts specific) 30 | 31 | goSpecific facts specific = 32 | foldMap (\fact -> go (HashSet.delete fact facts) <$> 33 | HashMap.lookup fact specific) facts 34 | 35 | -- | Add an item such that it can be retrieved only by giving a (non-strict) 36 | -- superset of all the specified keys. 37 | insert :: (Eq k, Hashable k) => [k] -> a -> ConjMap k a -> ConjMap k a 38 | insert patList a = 39 | goTree (HashSet.fromList patList) 40 | where 41 | -- Invariant: no pattern appears twice in a branch of a tree, although it 42 | -- can occur multiple times in separate branches 43 | goTree pats (ConjMap universal specific) 44 | | HashSet.null pats = ConjMap (a : universal) specific 45 | | otherwise = ConjMap universal (goNodes pats (HashMap.toList specific)) 46 | 47 | goNodes pats [] = freshNode pats 48 | goNodes pats ((k, t) : rest) 49 | | HashSet.member k pats = 50 | HashMap.fromList ((k, goTree (HashSet.delete k pats) t) : rest) 51 | | otherwise = 52 | HashMap.insert k t (goNodes pats rest) 53 | 54 | freshNode (HashSet.toList -> pats) = freshNode' pats 55 | where 56 | freshNode' [] = error "Internal error: addMatch: [] passed to freshNode" 57 | freshNode' [p] = HashMap.singleton p (ConjMap [a] HashMap.empty) 58 | freshNode' (p : ps) = HashMap.singleton p (ConjMap [] (freshNode' ps)) 59 | 60 | union :: (Eq k, Hashable k) => ConjMap k a -> ConjMap k a -> ConjMap k a 61 | union (ConjMap u s) (ConjMap u' s') = 62 | ConjMap (u <> u') (HashMap.unionWith union s s') 63 | 64 | instance (Eq k, Hashable k) => Semigroup (ConjMap k a) where 65 | (<>) = union 66 | 67 | instance (Eq k, Hashable k) => Monoid (ConjMap k a) where 68 | mempty = empty 69 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/Event.hs: -------------------------------------------------------------------------------- 1 | {- | Description : Types and properties of browser events 2 | 3 | These types are automatically generated from Myxine's master specification 4 | of supported events and interfaces, so they will always match those 5 | supported by the version of Myxine corresponding to the version of this 6 | library. However, Template Haskell does not allow programmatic generation 7 | of Haddock documentation, so we can't put proper inline documentation 8 | below. 9 | 10 | To aid in your reference, note that the name of each type below exactly 11 | matches the browser's name for events of that interface, and the names of 12 | each interface's properties exactly match the browser's names for them, 13 | except in the cases where those names are reserved keywords in Haskell. In 14 | those cases, we prepend the name of the interface (for instance, we use the 15 | property name @inputData@ instead of @data@). 16 | 17 | For more details on the meaning of each type below and its fields, refer to 18 | Myxine's documentation and/or the [MDN web API documentation for events and 19 | their interfaces](https://developer.mozilla.org/docs/Web/Events). 20 | -} 21 | {-# language StrictData #-} 22 | {-# options_ghc -Wno-name-shadowing -Wno-unused-matches #-} 23 | 24 | module Myxine.Event (module Myxine.Internal.Event) where 25 | 26 | import Myxine.Internal.Event hiding 27 | (decodeSomeEventType, eventPropertiesDict, encodeEventType) 28 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/Handlers.hs: -------------------------------------------------------------------------------- 1 | {-| Description : Declarative handlers for page events (usually not necessary 2 | unless you're making an alternative to the 'Myxine.Reactive' builder) 3 | 4 | In order to react to user events in the browser, we need to specify what effect 5 | each event of interest should have on the model in our 'Myxine.Page'. To do 6 | this, 'Myxine.runPage' asks that we construct up-front a set of 'Handlers' 7 | describing this. 8 | 9 | 'Handlers' is a 'Monoid': the 'mempty' 'Handlers' listens to no events. 10 | Singleton 'Handlers' can be created using the 'onEvent' function, and they can 11 | be joined together using '<>'. 12 | 13 | This module is useful when you are building your own page event handling 14 | abstraction, for instance, if 'Myxine.Reactive' isn't right for your purposes. 15 | However, it is not necessary to use this module directly if you are building a 16 | reactive page using that high-level abstraction. 17 | -} 18 | 19 | module Myxine.Handlers 20 | ( Handlers 21 | , onEvent 22 | , handle 23 | , handledEvents 24 | , focusHandlers 25 | , TargetFact 26 | , tagIs 27 | , attrIs 28 | , window 29 | , Propagation(..) 30 | ) where 31 | 32 | import Data.Maybe 33 | import qualified Data.Text as Text 34 | import Data.Text (Text) 35 | import Data.Dependent.Map (DMap) 36 | import qualified Data.Dependent.Map as DMap 37 | import Control.Lens 38 | import Control.Monad.State 39 | 40 | import Myxine.Event 41 | import Myxine.Direct 42 | import Myxine.Target 43 | import Myxine.ConjMap (ConjMap) 44 | import qualified Myxine.ConjMap as ConjMap 45 | 46 | -- | Create a handler for a specific event type by specifying the type of event 47 | -- and the monadic callback to be invoked when the event occurs. 48 | -- 49 | -- The provided callback will be given the properties @props@ of this particular 50 | -- event, and the current @model@ of a page. It has the option to do arbitrary 51 | -- 'IO', and to return a possibly-changed @model@. It also must specify whether 52 | -- or not the event should continue to propagate outwards to other handlers, by 53 | -- giving a 'Propagation' (either 'Bubble' or 'Stop'). 54 | -- 55 | -- The callback will only be invoked when an event occurs which matches the 56 | -- conjunction of the specified list of 'TargetFact's. For instance, to 57 | -- constrain a handler to only events on @
@ elements with @class="foo"@, we 58 | -- would use the 'TargetFact' @[tagIs "div", "class" `attrIs` "foo"]@. 59 | -- 60 | -- Notice that each variant of 'EventType' has a type-level index describing 61 | -- what kind of data is carried by events of that type. This means that, for 62 | -- instance, if you want to handle a 'Click' event, which has the type 63 | -- 'EventType MouseEvent', your event handler as created by 'Myxine.on' will be 64 | -- given access to a 'MouseEvent' data structure when it is invoked. That is to 65 | -- say: 66 | -- 67 | -- @ 68 | -- 'onEvent' 'Click' 69 | -- ['tagIs' "div", "class" \`'attrIs'\` "foo"] 70 | -- (\\properties@'MouseEvent'{} model -> 71 | -- do print properties 72 | -- print model 73 | -- pure (Bubble, model)) 74 | -- :: 'Show' model => 'Handlers' model 75 | -- @ 76 | -- 77 | -- A full listing of all available 'EventType's and their corresponding property 78 | -- records can be found in the below section on [types and properties of 79 | -- events](#Types). 80 | onEvent :: 81 | EventType props -> 82 | [TargetFact] -> 83 | (props -> model -> IO (Propagation, model)) -> 84 | Handlers model 85 | onEvent event eventFacts h = 86 | Handlers . DMap.singleton event . PerEventHandlers $ 87 | ConjMap.insert eventFacts h mempty 88 | {-# INLINE onEvent #-} 89 | 90 | -- | A 'TargetFact' specifying that the target must have the HTML tag given; 91 | -- otherwise, this handler will not fire. 92 | tagIs :: Text -> TargetFact 93 | tagIs t = HasTag (Text.toLower t) 94 | 95 | -- | A 'TargetFact' specifying that the target must have the HTML attribute 96 | -- given, with the exact value specified; otherwise, this handler will not fire. 97 | attrIs :: Text -> Text -> TargetFact 98 | attrIs a v = AttributeEquals a v 99 | 100 | -- | A 'TargetFact' specifying that the target must be the root DOM element, 101 | -- that is, the @window@ object. 102 | window :: TargetFact 103 | window = Window 104 | 105 | -- | Dispatch all the event handler callbacks for a given event type and its 106 | -- corresponding data. 107 | -- 108 | -- Event handlers for this event type will be called in the order they were 109 | -- registered (left to right) with the result of the previous handler fed as the 110 | -- input to the next one. If any event handler in the chain returns 'Stop', then 111 | -- propagation stops at the current 'Target' for that handler. 112 | handle :: Handlers model -> PageEvent -> model -> IO model 113 | handle (Handlers allHandlers) PageEvent{event, properties, targets} model = 114 | let PerEventHandlers targetMap = 115 | fromMaybe mempty (DMap.lookup event allHandlers) 116 | facts = map targetFacts targets ++ [[Window]] 117 | handlers = map (flip ConjMap.lookup targetMap) facts 118 | in processHandlers handlers model 119 | where 120 | processHandlers [ ] m = pure m 121 | processHandlers ([ ] : parents) m = processHandlers parents m 122 | processHandlers ((h : hs) : parents) m = 123 | do (propagation, m') <- h properties m 124 | case propagation of 125 | Bubble -> processHandlers (hs : parents) m' 126 | Stop -> processHandlers (hs : [ ]) m' 127 | {-# INLINE handle #-} 128 | 129 | -- | Extend a set of 'Handlers' that manipulate some smaller @model'@ to 130 | -- manipulate some larger @model@, using a 'Traversal'' between the two model 131 | -- types. Whenever a handler is invoked, it will be called with each extant 132 | -- target of the specified 'Traversal''. 133 | focusHandlers :: 134 | forall model model'. Traversal' model model' -> Handlers model' -> Handlers model 135 | focusHandlers l (Handlers m) = Handlers $ 136 | DMap.map (\(PerEventHandlers cm) -> PerEventHandlers (fmap zoomOut cm)) m 137 | where 138 | zoomOut :: 139 | (props -> model' -> IO (Propagation, model')) -> 140 | (props -> model -> IO (Propagation, model)) 141 | zoomOut h props model = do 142 | (finalModel, finalPropagation) <- 143 | flip runStateT mempty $ 144 | forOf l model \model' -> do 145 | (propagation, finalModel') <- liftIO (h props model') 146 | modify (propagation <>) 147 | pure finalModel' 148 | pure (finalPropagation, finalModel) 149 | 150 | -- | Get a list of all the events which are handled by these handlers. 151 | handledEvents :: Handlers model -> [Some EventType] 152 | handledEvents (Handlers handlers) = DMap.keys handlers 153 | 154 | -- | A set of handlers for events, possibly empty. Create new 'Handlers' using 155 | -- 'onEvent', and combine 'Handlers' together using their 'Monoid' instance. 156 | newtype Handlers model 157 | = Handlers (DMap EventType (PerEventHandlers model)) 158 | 159 | instance Semigroup (Handlers model) where 160 | Handlers hs <> Handlers hs' = 161 | Handlers (DMap.unionWithKey (const (<>)) hs hs') 162 | 163 | instance Monoid (Handlers model) where 164 | mempty = Handlers mempty 165 | 166 | -- | Indicator for whether an event should continue to be triggered on parent 167 | -- elements in the path. An event handler can signal that it wishes the event to 168 | -- stop propagating by returning 'Stop'. 169 | data Propagation 170 | = Bubble -- ^ Continue to trigger the event on parent elements 171 | | Stop -- ^ Continue to trigger the event for all handlers of this element, 172 | -- but stop before triggering it on any parent elements 173 | deriving (Eq, Ord, Show, Enum, Bounded) 174 | 175 | instance Semigroup Propagation where 176 | l <> r | l > r = l 177 | | otherwise = r 178 | 179 | instance Monoid Propagation where 180 | mempty = Bubble 181 | 182 | -- | A handler for a single event type with associated data @props@. 183 | newtype PerEventHandlers model props 184 | = PerEventHandlers (ConjMap TargetFact (props -> model -> IO (Propagation, model))) 185 | deriving newtype (Semigroup, Monoid) 186 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/Internal/Event.hs: -------------------------------------------------------------------------------- 1 | {-# language StrictData #-} 2 | {-# options_ghc -Wno-name-shadowing -Wno-unused-matches #-} 3 | {-# options_haddock not-home #-} 4 | 5 | module Myxine.Internal.Event where 6 | 7 | import Data.FileEmbed 8 | import Myxine.Internal.TH 9 | 10 | mkEventsAndInterfaces $(embedFile "enabled-events.json") 11 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/Internal/TH.hs: -------------------------------------------------------------------------------- 1 | {-# options_ghc -Wno-incomplete-uni-patterns #-} 2 | 3 | {-| * Internal Template-Haskell for generating events 4 | 5 | __Note:__ This module is used exclusively to template the various event and 6 | event interface data types used by this library. It is not intended for 7 | external use, and may not follow the PVP. 8 | -} 9 | module Myxine.Internal.TH (mkEventsAndInterfaces) where 10 | 11 | import qualified Data.Aeson as JSON 12 | import qualified Data.Aeson.Types as JSON 13 | import Data.Bifunctor 14 | import qualified Data.ByteString as ByteString.Strict 15 | import Data.ByteString.Lazy (ByteString) 16 | import qualified Data.Char as Char 17 | import Data.Some.Newtype (Some(..)) 18 | import Data.Either 19 | import Data.Foldable 20 | import Data.GADT.Compare 21 | import Data.GADT.Show 22 | import Data.HashMap.Lazy (HashMap) 23 | import qualified Data.HashMap.Lazy as HashMap 24 | import Data.HashSet (HashSet) 25 | import qualified Data.HashSet as HashSet 26 | import qualified Data.Kind 27 | import Data.List (sortBy, sortOn, sort, intercalate) 28 | import Data.Ord 29 | import Data.Text (Text) 30 | import Data.Traversable 31 | import Data.Constraint 32 | import Data.Type.Equality 33 | import qualified GHC.Generics as Generic 34 | import Language.Haskell.TH 35 | 36 | eventTypeName, decodeEventPropertiesName, decodeSomeEventTypeName, encodeEventTypeName :: Name 37 | eventTypeName = mkName "EventType" 38 | decodeEventPropertiesName = mkName "eventPropertiesDict" 39 | decodeSomeEventTypeName = mkName "decodeSomeEventType" 40 | encodeEventTypeName = mkName "encodeEventType" 41 | 42 | interfaceTypes :: HashMap String (Q Type) 43 | interfaceTypes = HashMap.fromList 44 | [ ("f64", [t|Double|]) 45 | , ("i64", [t|Int|]) 46 | , ("String", [t|Text|]) 47 | , ("bool", [t|Bool|]) 48 | , ("Option", [t|Maybe Double|]) 49 | , ("Option", [t|Maybe Int|]) 50 | , ("Option", [t|Maybe Text|]) 51 | , ("Option", [t|Maybe Bool|]) 52 | ] 53 | 54 | mkEventsAndInterfaces :: ByteString.Strict.ByteString -> Q [Dec] 55 | mkEventsAndInterfaces enabledEventsByteString = 56 | case JSON.eitherDecodeStrict' enabledEventsByteString of 57 | Right EnabledEvents{events, interfaces} -> do 58 | interfaceDecs <- mkInterfaces interfaces 59 | eventDecs <- mkEvents events 60 | pure $ interfaceDecs <> eventDecs 61 | Left err -> do 62 | reportError err 63 | pure [] 64 | 65 | data EnabledEvents 66 | = EnabledEvents 67 | { events :: Events 68 | , interfaces :: Interfaces 69 | } deriving (Eq, Ord, Show, Generic.Generic, JSON.FromJSON) 70 | 71 | newtype Events 72 | = Events (HashMap String EventInfo) 73 | deriving (Eq, Ord, Show) 74 | deriving newtype (JSON.FromJSON) 75 | 76 | data EventInfo 77 | = EventInfo 78 | { interface :: String 79 | , nameWords :: [String] 80 | } deriving (Eq, Ord, Show, Generic.Generic, JSON.FromJSON) 81 | 82 | newtype Interfaces 83 | = Interfaces (HashMap String Interface) 84 | deriving (Eq, Ord, Show, Generic.Generic) 85 | deriving newtype (JSON.FromJSON) 86 | 87 | data Interface 88 | = Interface 89 | { inherits :: Maybe String 90 | , properties :: Properties 91 | } deriving (Eq, Ord, Show, Generic.Generic, JSON.FromJSON) 92 | 93 | newtype Properties 94 | = Properties (HashMap String String) 95 | deriving (Eq, Ord, Show, Generic.Generic) 96 | deriving newtype (Semigroup, Monoid, JSON.FromJSON) 97 | 98 | allInterfaceProperties :: Interfaces -> String -> Either (Either (Maybe String) [String]) Properties 99 | allInterfaceProperties (Interfaces interfaces) = go HashSet.empty [] 100 | where 101 | go :: HashSet String -> [String] -> String -> Either (Either (Maybe String) [String]) Properties 102 | go seen seenList name 103 | | HashSet.member name seen = Left (Right (name : seenList)) 104 | | otherwise = do 105 | Interface{inherits, properties} <- 106 | maybe (Left (Left (if length seenList <= 1 then Just name else Nothing))) 107 | Right 108 | (HashMap.lookup name interfaces) 109 | rest <- maybe (pure mempty) (go (HashSet.insert name seen) (name : seenList)) inherits 110 | pure (properties <> rest) 111 | 112 | fillInterfaceProperties :: Interfaces -> Either [(String, Either (Maybe String) [String])] Interfaces 113 | fillInterfaceProperties i@(Interfaces interfaces) = 114 | if bad == [] 115 | then (Right good) 116 | else (Left bad) 117 | where 118 | good :: Interfaces 119 | bad :: [(String, Either (Maybe String) [String])] 120 | (bad, good) = 121 | second (Interfaces . HashMap.fromList) 122 | . partitionEithers 123 | . map (\(name, maybeInterface) -> 124 | either (Left . (name,)) (Right . (name,)) maybeInterface) 125 | $ results 126 | 127 | results :: [(String, Either (Either (Maybe String) [String]) Interface)] 128 | results = map (\(name, Interface{inherits}) -> 129 | (name, (\properties -> Interface{inherits, properties}) 130 | <$> allInterfaceProperties i name)) 131 | (HashMap.toList interfaces) 132 | 133 | mkEvents :: Events -> Q [Dec] 134 | mkEvents (Events events) = do 135 | cons <- for (sortBy (comparing (interface . snd) <> comparing fst) $ 136 | HashMap.toList events) 137 | \(eventName, EventInfo{interface, nameWords}) -> do 138 | let conName = concatMap (onFirst Char.toUpper) nameWords 139 | (eventName,) <$> 140 | gadtC [mkName conName] [] 141 | (appT (conT eventTypeName) 142 | (conT (mkName interface))) 143 | starArrowStar <- [t|Data.Kind.Type -> Data.Kind.Type|] 144 | dec <- dataD (pure []) eventTypeName [] (Just starArrowStar) (pure <$> map snd cons) [] 145 | eqInstance <- deriveEvent [t|Eq|] 146 | ordInstance <- deriveEvent [t|Ord|] 147 | showInstance <- deriveEvent [t|Show|] 148 | geqInstance <- mkEnumGEqInstance eventTypeName (map snd cons) 149 | gcompareInstance <- mkEnumGCompareInstance eventTypeName (map snd cons) 150 | gshowInstance <- 151 | [d|instance GShow $(conT eventTypeName) where gshowsPrec = showsPrec|] 152 | encodeEventType <- mkEncodeEventType cons 153 | decodeSomeEventType <- mkDecodeSomeEventType cons 154 | decodeEventProperties <- mkDecodeEventProperties (map snd cons) 155 | pure $ decodeSomeEventType <> decodeEventProperties <> encodeEventType <> 156 | [ dec 157 | , eqInstance 158 | , ordInstance 159 | , showInstance 160 | , geqInstance 161 | , gcompareInstance 162 | ] <> gshowInstance 163 | where 164 | deriveEvent typeclass = 165 | standaloneDerivD (pure []) [t|forall d. $typeclass ($(pure (ConT eventTypeName)) d)|] 166 | 167 | mkInterfaces :: Interfaces -> Q [Dec] 168 | mkInterfaces interfaces = 169 | case fillInterfaceProperties interfaces of 170 | Right (Interfaces filledInterfaces) -> 171 | concat <$> for (reverse . sortOn fst $ HashMap.toList filledInterfaces) 172 | \(name, interface) -> 173 | mkInterface name interface 174 | Left wrong -> do 175 | for_ wrong \(interface, err) -> 176 | case err of 177 | Left Nothing -> pure () 178 | Left (Just directUnknown) -> 179 | reportError $ "Unknown interface \"" <> directUnknown 180 | <> "\" inherited by \"" <> interface <> "\"" 181 | Right cyclic -> 182 | reportError $ "Cycle in interface inheritance: " 183 | <> intercalate " <: " (reverse cyclic) 184 | pure [] 185 | 186 | mkInterface :: String -> Interface -> Q [Dec] 187 | mkInterface interfaceName Interface{properties = Properties properties} = 188 | let propertyList = HashMap.toList properties 189 | badFields = 190 | filter (not . (flip HashMap.member interfaceTypes) . snd) propertyList 191 | in if badFields == [] 192 | then do 193 | fields <- sequence 194 | [ (propName, Bang NoSourceUnpackedness SourceStrict,) 195 | <$> interfaceTypes HashMap.! propType 196 | | (propName, propType) <- propertyList ] 197 | dec <- dataD (pure []) (mkName interfaceName) [] Nothing 198 | [recC (mkName interfaceName) $ 199 | pure . (\(n,s,t) -> (mkName (avoidKeywordProp interfaceName n), s, t)) <$> sort fields] 200 | [derivClause Nothing [[t|Eq|], [t|Ord|], [t|Show|]]] 201 | -- This "manually" derived FromJSON instance is necessary because Aeson 202 | -- doesn't guarantee stability of encoding. In particular, unit-like things 203 | -- are currently serialized as [] and not {}, even if they are record-like. 204 | preludeMaybe <- [t|Maybe|] 205 | o <- newName "o" 206 | fromJSON <- 207 | [d| instance JSON.FromJSON $(conT (mkName interfaceName)) where 208 | parseJSON (JSON.Object $(varP o)) = 209 | $(doE $ [ let name' = avoidKeywordProp interfaceName name 210 | get = case ty of 211 | -- This lets Maybe fields really be optional 212 | AppT c _ | c == preludeMaybe -> [|(JSON..:?)|] 213 | _ -> [|(JSON..:)|] 214 | in bindS (varP (mkName name')) [|$get $(varE o) $(litE (stringL name))|] 215 | | (name, _, ty) <- fields ] 216 | <> [ noBindS [|pure $(recConE (mkName interfaceName) 217 | [ let name' = avoidKeywordProp interfaceName name 218 | in pure (mkName name', VarE (mkName name')) 219 | | (name, _, _) <- fields ]) |] ]) 220 | parseJSON invalid = 221 | JSON.prependFailure $(litE (stringL ("parsing " <> interfaceName <> " failed, "))) 222 | (JSON.typeMismatch "Object" invalid) 223 | |] 224 | pure $ [dec] <> fromJSON 225 | else do 226 | for_ badFields \(propName, propType) -> 227 | reportError $ 228 | "Unrecognized type \"" <> propType <> "\" for event interface property \"" 229 | <> propName <> "\" of interface \"" <> interfaceName <> "\"" 230 | <>": must be one of [" 231 | <> intercalate ", " (map show (HashMap.keys interfaceTypes)) 232 | <> "]" 233 | pure [] 234 | 235 | mkEnumGEqInstance :: Name -> [Con] -> Q Dec 236 | mkEnumGEqInstance name cons = do 237 | true <- [|Just Refl|] 238 | false <- [|Nothing|] 239 | clauses <- for cons \(GadtC [con] _ _) -> 240 | pure (Clause [ConP con [], ConP con []] (NormalB true) []) 241 | let defaultClause = Clause [WildP, WildP] (NormalB false) [] 242 | dec <- instanceD (pure []) [t|GEq $(conT name)|] 243 | [pure (FunD 'geq (clauses <> [defaultClause]))] 244 | pure dec 245 | 246 | mkEnumGCompareInstance :: Name -> [Con] -> Q Dec 247 | mkEnumGCompareInstance name cons = do 248 | arg1 <- newName "a" 249 | arg2 <- newName "b" 250 | cases <- for (diagonalize cons) 251 | \(less, GadtC [con] _ _, greater) -> 252 | match (conP con []) (normalB (caseE (varE arg2) 253 | (concat [ map (\(GadtC [l] _ _) -> match (conP l []) (normalB [|GLT|]) []) less 254 | , [ match (conP con []) (normalB [|GEQ|]) [] ] 255 | , map (\(GadtC [g] _ _) -> match (conP g []) (normalB [|GGT|]) []) greater ]))) [] 256 | dec <- instanceD (pure []) [t|GCompare $(conT name)|] 257 | [funD 'gcompare [clause [varP arg1, varP arg2] 258 | (normalB (caseE (varE arg1) (pure <$> cases))) []]] 259 | pure dec 260 | 261 | mkEncodeEventType :: [(String, Con)] -> Q [Dec] 262 | mkEncodeEventType cons = do 263 | sig <- sigD encodeEventTypeName [t|forall d. $(conT eventTypeName) d -> ByteString|] 264 | dec <- funD encodeEventTypeName 265 | [ clause [conP con []] (normalB (litE (stringL string))) [] 266 | | (string, GadtC [con] _ _) <- cons ] 267 | let prag = PragmaD (InlineP encodeEventTypeName Inline FunLike AllPhases) 268 | pure [sig, dec, prag] 269 | 270 | -- | Make the @decodeEventProperties@ function 271 | mkDecodeEventProperties :: [Con] -> Q [Dec] 272 | mkDecodeEventProperties cons = do 273 | let event = pure (ConT eventTypeName) 274 | let cases = flip map cons \(GadtC [con] _ _) -> 275 | match (conP con []) (normalB [|Dict|]) [] 276 | sig <- sigD decodeEventPropertiesName [t| forall d. $event d -> Dict (JSON.FromJSON d, Show d)|] 277 | arg <- newName "event" 278 | dec <- funD decodeEventPropertiesName 279 | [clause [varP arg] (normalB (caseE (varE arg) cases)) []] 280 | let prag = PragmaD (InlineP decodeEventPropertiesName Inline FunLike AllPhases) 281 | pure [sig, dec, prag] 282 | 283 | mkDecodeSomeEventType :: [(String, Con)] -> Q [Dec] 284 | mkDecodeSomeEventType cons = do 285 | allEvents <- newName "allEvents" 286 | let list = 287 | [ [|($(litE (stringL string)), Some $(conE con))|] 288 | | (string, GadtC [con] _ _) <- cons ] 289 | allEventsSig <- sigD allEvents [t|HashMap Text (Some $(conT eventTypeName))|] 290 | allEventsDec <- funD allEvents [clause [] (normalB [|HashMap.fromList $(listE list)|]) []] 291 | sig <- sigD decodeSomeEventTypeName 292 | [t|Text -> Maybe (Some $(conT eventTypeName))|] 293 | dec <- funD decodeSomeEventTypeName 294 | [clause [] (normalB [|flip HashMap.lookup $(varE allEvents)|]) 295 | [pure allEventsSig, pure allEventsDec]] 296 | pure [sig, dec] 297 | 298 | -- | Given an interface and a property for it, rename that property if necessary 299 | -- to avoid clashing with reserved Haskell keywords 300 | avoidKeywordProp :: String -> String -> String 301 | avoidKeywordProp interface propName 302 | | HashSet.member propName keywords = 303 | onFirst Char.toLower (removeMatchingTail "Event" interface) 304 | <> onFirst Char.toUpper propName 305 | | otherwise = propName 306 | where 307 | removeMatchingTail m i = 308 | let reversed = reverse i 309 | in if m == reverse (take (length m) reversed) 310 | then reverse (drop (length m) reversed) 311 | else i 312 | 313 | -- | Apply a function to the first element of a list 314 | onFirst :: (a -> a) -> [a] -> [a] 315 | onFirst _ [] = [] 316 | onFirst f (c:cs) = f c : cs 317 | 318 | -- | Get all zipper positions into a list 319 | diagonalize :: [a] -> [([a], a, [a])] 320 | diagonalize [] = [] 321 | diagonalize (a : as) = go ([], a, as) 322 | where 323 | go :: ([a], a, [a]) -> [([a], a, [a])] 324 | go (l, c, []) = [(l, c, [])] 325 | go current@(l, c, r:rs) = current : go (c:l, r, rs) 326 | 327 | -- | All reserved keywords in Haskell, including all extensions 328 | -- Source: https://github.com/ghc/ghc/blob/master/compiler/parser/Lexer.x#L875-L934 329 | keywords :: HashSet String 330 | keywords = HashSet.fromList 331 | ["as", "case", "class", "data", "default", "deriving", "do", "else", "hiding", 332 | "if", "import", "in", "infix", "infixl", "infixr", "instance", "let", 333 | "module", "newtype", "of", "qualified", "then", "type", "where", "forall", 334 | "mdo", "family", "role", "pattern", "static", "stock", "anyclass", "via", 335 | "group", "by", "using", "foreign", "export", "label", "dynamic", "safe", 336 | "interruptible", "unsafe", "stdcall", "ccall", "capi", "prim", "javascript", 337 | "unit", "dependency", "signature", "rec", "proc"] 338 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/Reactive.hs: -------------------------------------------------------------------------------- 1 | module Myxine.Reactive 2 | ( Reactive, ReactiveM, reactive, title, markup, 3 | on', on, Propagation(..), (@@), (##), target, this 4 | ) where 5 | 6 | import Text.Blaze.Html5 (Html, ToMarkup(..), string, (!), dataAttribute) 7 | import Text.Blaze.Renderer.Text 8 | import Text.Blaze.Internal (Attributable) 9 | 10 | import Data.String 11 | import Data.List (intercalate) 12 | import Control.Monad.State 13 | import Control.Monad.Reader 14 | import Data.Monoid 15 | import Data.Text (Text) 16 | import Data.List.NonEmpty (NonEmpty(..), (<|)) 17 | import qualified Data.List.NonEmpty as NonEmpty 18 | import qualified Data.Text.Lazy as Text 19 | import Control.Spoon (teaspoonWithHandles) 20 | import qualified Control.Exception as Exception 21 | import Control.Lens hiding ((<|)) 22 | 23 | import Myxine.Event 24 | import qualified Myxine.Direct as Direct (PageContent, pageBody, pageTitle) 25 | import Myxine.Handlers 26 | 27 | -- | The builder state for a reactive component. 28 | data ReactiveBuilder model = 29 | ReactiveBuilder 30 | { location :: !(NonEmpty Word) 31 | -- ^ The current location in the tree of listener scopes we've created. 32 | -- Calls to 'on' refer to the enclosing scope (that is, the tail of this 33 | -- list). 34 | , handlers :: !(Handlers model) 35 | -- ^ The accumulated handlers for events built up so far. 36 | , pageMarkup :: !Html 37 | -- ^ The accumulated markup for the page built up so far. 38 | , pageTitle :: !(Last Text) 39 | -- ^ The most-recently set title for the page (via 'title'). 40 | } 41 | 42 | -- | The underlying builder monad for the 'Reactive' type. 43 | -- 44 | -- This is almost always used with a return type of @()@, hence you will usually 45 | -- see it aliased as 'Reactive' @model@. 46 | newtype ReactiveM model a = 47 | ReactiveM (ReaderT model (State (ReactiveBuilder model)) a) 48 | deriving newtype (Functor, Applicative, Monad, MonadReader model) 49 | 50 | -- | The 'Reactive' type interleaves the description of page markup with the 51 | -- specification of event handlers for the page. 52 | -- 53 | -- It is a 'Monoid' and its underlying type 'ReactiveM' is a 'Monad', which 54 | -- means that just like the Blaze templating library, it can be (and is designed 55 | -- to be!) used in @do@-notation. Importantly, it is also a 'MonadReader' 56 | -- @model@, where the current model is returned by 'ask'. 57 | type Reactive model = ReactiveM model () 58 | 59 | -- | Wrap an inner reactive component in some enclosing HTML. Any listeners 60 | -- created via 'on' in the wrapped component will be scoped to only events that 61 | -- occur within this chunk of HTML. 62 | -- 63 | -- In the following example, we install a 'Click' handler for the whole page, 64 | -- then build a @
@ with /another/ 'Click' handler inside that page, which 65 | -- returns 'Stop' from 'on'' to stop the 'Click' event from bubbling out to the 66 | -- outer handler when the inner @
@ is clicked. 67 | -- 68 | -- @ 69 | -- do 'on' 'Click' $ \\_ -> 70 | -- liftIO $ putStrLn "Clicked outside!" 71 | -- div ! style "background: lightblue;" '@@' do 72 | -- "Click here, or elsewhere..." 73 | -- 'on'' 'Click' $ \\_ -> do 74 | -- liftIO $ putStrLn "Clicked inside!" 75 | -- pure 'Stop' 76 | -- @ 77 | (@@) :: (Html -> Html) -> ReactiveM model a -> ReactiveM model a 78 | wrap @@ ReactiveM inner = ReactiveM do 79 | builder <- get 80 | model <- ask 81 | let originalLoc = location builder 82 | (result, builder') = 83 | runState 84 | (runReaderT inner model) 85 | builder 86 | { location = 0 <| originalLoc -- descend a level in location cursor 87 | , pageMarkup = mempty 88 | -- handlers & pageTitle are threaded through and preserved 89 | } 90 | put builder' 91 | { location = let (h :| t) = originalLoc in (h + 1 :| t) -- move sideways 92 | , pageMarkup = 93 | do pageMarkup builder 94 | wrapInTarget originalLoc $ wrap (pageMarkup builder') 95 | -- handlers & pageTitle are threaded through and preserved 96 | } 97 | pure result 98 | where 99 | wrapInTarget :: NonEmpty Word -> Html -> Html 100 | wrapInTarget loc = (! dataAttribute clientDataAttr (showLoc loc)) 101 | infixr 5 @@ 102 | 103 | -- | Create a scope for event listeners without wrapping any enclosing HTML. Any 104 | -- listeners created via 'on' will apply only to HTML that is written inside 105 | -- this block, rather than to the enclosing broader scope. 106 | -- 107 | -- >>> target === (id @@) 108 | target :: ReactiveM model a -> ReactiveM model a 109 | target = (id @@) 110 | 111 | -- | Return a piece of JavaScript code which looks up the object corresponding 112 | -- to the current scope's location in the page. This is suitable to be used in 113 | -- 'eval', for instance, to retrieve properties of a particular element. 114 | -- 115 | -- If there is no enclosing '@@', then this is the @window@ object; otherwise, 116 | -- it is the outermost HTML element object created by the first argument to the 117 | -- enclosing '@@'. If there are multiple elements at the root of the enclosing 118 | -- '@@', then the first of these is selected. 119 | -- 120 | -- For example, here's an input which reports its own contents: 121 | -- 122 | -- @ 123 | -- textbox :: Reactive Text 124 | -- textbox = input @@ do 125 | -- e <- this 126 | -- on Input \_ -> do 127 | -- value <- eval $ this <> ".value" 128 | -- put value 129 | -- @ 130 | this :: ReactiveM model Text 131 | this = ReactiveM do 132 | loc <- gets (NonEmpty.tail . location) 133 | pure case loc of 134 | [] -> "window" 135 | h : t -> 136 | let selector = "[data-" <> clientDataAttr <> "=\"" <> showLoc (h :| t) <> "\"]" 137 | in "(document.querySelector('" <> selector <> "'))" 138 | 139 | -- | Write an atomic piece of HTML (or anything that can be converted to it) to 140 | -- the page in this location. Event listeners for its enclosing scope can be 141 | -- added by sequential use of 'on'. If you need sub-pieces of this HTML to have 142 | -- their own scoped event listeners, use '@@' to build a composite component. 143 | -- 144 | -- >>> markup h === const (toMarkup h) @@ pure () 145 | markup :: ToMarkup h => h -> Reactive model 146 | markup h = const (toMarkup h) @@ pure () 147 | 148 | -- | Set the title for the page. If this function is called multiple times in 149 | -- one update, the most recent call is used. 150 | title :: Text -> Reactive model 151 | title t = ReactiveM (modify \wb -> wb { pageTitle = Last (Just t) }) 152 | 153 | -- | Listen to a particular event and react to it by modifying the model for the 154 | -- page. This function's returned 'Propagation' value specifies whether or not 155 | -- to propagate the event outwards to other enclosing contexts. The event target 156 | -- is scoped to the enclosing '@@', or the whole page if at the top level. 157 | -- 158 | -- When the specified 'EventType' occurs, the event handler will be called with 159 | -- that event type's corresponding property record, e.g. a 'Click' event's 160 | -- handler will receive a 'MouseEvent' record. A handler can modify the page's 161 | -- model via 'State'ful actions and perform arbitrary IO using 'liftIO'. In the 162 | -- context of a running page, a handler also has access to the 'eval' and 163 | -- 'evalBlock' functions to evaluate JavaScript in that page. 164 | -- 165 | -- __Exception behavior:__ This function catches @PatternMatchFail@ exceptions 166 | -- thrown by the passed function. That is, if there is a partial pattern match 167 | -- in the pure function from event properties to stateful update, the stateful 168 | -- update will be silently skipped. This is useful as a shorthand to select only 169 | -- events of a certain sort, for instance: 170 | -- 171 | -- @ 172 | -- 'on'' 'Click' \\'MouseEvent'{shiftKey = True} -> 173 | -- do putStrLn "Shift + Click!" 174 | -- pure 'Bubble' 175 | -- @ 176 | on' :: 177 | EventType props -> 178 | (props -> StateT model IO Propagation) -> 179 | Reactive model 180 | on' event reaction = ReactiveM do 181 | loc <- gets (NonEmpty.tail . location) 182 | let selector = 183 | case loc of 184 | [] -> window 185 | (h : t) -> ("data-" <> clientDataAttr) `attrIs` 186 | Text.toStrict (Text.pack (showLoc (h :| t))) 187 | modify \builder -> 188 | builder { handlers = mappend (handlers builder) $ 189 | onEvent event [selector] $ 190 | \props model -> 191 | -- We need to do a pure and impure catch, because GHC might 192 | -- decide to inline things inside the IO action, or it might 193 | -- not! So we check in both circumstances. 194 | case tryMatch (runStateT (reaction props) model) of 195 | Nothing -> pure (Bubble, model) 196 | Just io -> 197 | do result <- Exception.try @Exception.PatternMatchFail io 198 | case result of 199 | Left _ -> pure (Bubble, model) 200 | Right update -> pure update } 201 | where 202 | tryMatch = teaspoonWithHandles 203 | [Exception.Handler \(_ :: Exception.PatternMatchFail) -> pure Nothing] 204 | 205 | -- | Listen to a particular event and react to it by modifying the model for the 206 | -- page. This is a special case of 'on'' where the event is always allowed to 207 | -- bubble out to listeners in enclosing contexts. 208 | -- 209 | -- See the documentation for 'on''. 210 | on :: 211 | EventType props -> 212 | (props -> StateT model IO ()) -> 213 | Reactive model 214 | on event action = on' event (\props -> action props >> pure Bubble) 215 | 216 | -- | Focus a reactive page fragment to manipulate a piece of a larger model, 217 | -- using a 'Traversal'' to specify what part(s) of the larger model to 218 | -- manipulate. 219 | -- 220 | -- This is especially useful when creating generic components which can be 221 | -- re-used in the context of many different models. For instance, we can define 222 | -- a toggle button and specify separately which part of a model it toggles: 223 | -- 224 | -- @ 225 | -- toggle :: 'Reactive' Bool 226 | -- toggle = 227 | -- button '@@' do 228 | -- active <- 'ask' 229 | -- if active then \"ON\" else \"OFF\" 230 | -- 'on' 'Click' \\_ -> 'modify' not 231 | -- 232 | -- twoToggles :: 'Reactive' (Bool, Bool) 233 | -- twoToggles = do 234 | -- _1 '##' toggle 235 | -- _2 '##' toggle 236 | -- @ 237 | -- 238 | -- This function takes a 'Traversal'', which is strictly more general than a 239 | -- 'Lens''. This means you can use traversals with zero or more than one target, 240 | -- and this many replicas of the given 'Reactive' fragment will be generated, 241 | -- each separately controlling its corresponding portion of the model. This 242 | -- means the above example could also be phrased: 243 | -- 244 | -- @ 245 | -- twoToggles :: 'Reactive' (Bool, Bool) 246 | -- twoToggles = 'each' '##' toggle 247 | -- @ 248 | (##) :: Traversal' model model' -> Reactive model' -> Reactive model 249 | l ## ReactiveM action = 250 | ReactiveM $ 251 | ReaderT \model -> 252 | iforOf_ (indexing l) model \i model' -> 253 | StateT \b@ReactiveBuilder{handlers = priorHandlers} -> 254 | let b' = flip execState (b {handlers = mempty}) $ 255 | runReaderT action model' 256 | in Identity $ 257 | ((), b' { handlers = 258 | priorHandlers <> 259 | focusHandlers (indexing l . index i) (handlers b') 260 | }) 261 | -- TODO: make this more efficient using a pre-applied Traversal? 262 | infixr 5 ## 263 | 264 | -- | Evaluate a reactive component to produce a pair of 'Direct.PageContent' and 265 | -- 'Handlers'. This is the bridge between the 'Direct.runPage' abstraction and 266 | -- the 'Reactive' abstraction: use this to run a reactive component in a 267 | -- 'Myxine.Page'. 268 | reactive :: Reactive model -> model -> (Direct.PageContent, Handlers model) 269 | reactive (ReactiveM action) model = 270 | let ReactiveBuilder{handlers, pageMarkup, pageTitle = pageContentTitle} = 271 | execState (runReaderT action model) initialBuilder 272 | pageContentBody = renderMarkup pageMarkup 273 | in (Direct.pageBody (Text.toStrict pageContentBody) 274 | <> foldMap Direct.pageTitle pageContentTitle, 275 | handlers) 276 | where 277 | initialBuilder = ReactiveBuilder 278 | { location = (0 :| []) 279 | , handlers = mempty 280 | , pageMarkup = pure () 281 | , pageTitle = Last Nothing } 282 | 283 | -- | 'Reactive' pages can be combined using '<>', which concatenates their HTML 284 | -- content and merges their sets of 'Handlers'. 285 | instance Semigroup a => Semigroup (ReactiveM model a) where 286 | m <> n = (<>) <$> m <*> n 287 | 288 | -- | The empty 'Reactive' page, with no handlers and no content, is 'mempty'. 289 | instance Monoid a => Monoid (ReactiveM model a) where mempty = pure mempty 290 | 291 | -- | You can apply an HTML attribute to any 'Reactive' page using '!'. 292 | instance Attributable (ReactiveM model a) where 293 | w ! a = (! a) @@ w 294 | 295 | -- | You can apply an HTML attribute to any function between 'Reactive' pages 296 | -- using '!'. This is useful when building re-usable widget libraries, allowing 297 | -- their attributes to be modified after the fact but before they are filled 298 | -- with contents. 299 | instance Attributable (ReactiveM model a -> ReactiveM model a) where 300 | f ! a = (! a) . f 301 | 302 | -- | A string literal is a 'Reactive' page containing that selfsame text. 303 | instance (a ~ ()) => IsString (ReactiveM model a) where 304 | fromString = markup . string 305 | 306 | -- | The in-browser name for the data attribute holding our tracking id. This is 307 | -- not the same as the @id@ attribute, because this means the user is free to 308 | -- use the _real_ @id@ attribute as they please. 309 | clientDataAttr :: IsString a => a 310 | clientDataAttr = "myxine-client-widget-id" 311 | 312 | -- | Helper function to show a location in the page: add hyphens between every 313 | -- number. 314 | showLoc :: IsString a => (NonEmpty Word) -> a 315 | showLoc = fromString . intercalate "-" . map show . NonEmpty.toList 316 | -------------------------------------------------------------------------------- /clients/haskell/src/Myxine/Target.hs: -------------------------------------------------------------------------------- 1 | {-# options_haddock not-home #-} 2 | 3 | module Myxine.Target (Target, attribute, tag, TargetFact(..), targetFacts) where 4 | 5 | import Data.Hashable 6 | import Data.Text (Text) 7 | import qualified Data.Aeson as JSON 8 | import Data.HashMap.Lazy (HashMap) 9 | import qualified Data.HashMap.Lazy as HashMap 10 | import GHC.Generics 11 | 12 | -- | A 'Target' is a description of a single element node in the browser. When 13 | -- an event fires in the browser, Myxine tracks the path of nodes it touches, 14 | -- from the most specific element all the way up to the root. Each event handler 15 | -- is given access to this @['Target']@, ordered from most to least specific. 16 | -- 17 | -- For any 'Target', you can query the value of any of an 'attribute', or you 18 | -- can ask for the 'tag' of that element. 19 | data Target = Target 20 | { tagName :: Text 21 | , attributes :: HashMap Text Text 22 | } deriving (Eq, Ord, Show, Generic, JSON.FromJSON) 23 | 24 | -- | Get the value, if any, of some named attribute of a 'Target'. 25 | attribute :: Text -> Target -> Maybe Text 26 | attribute name Target{attributes} = HashMap.lookup name attributes 27 | {-# INLINE attribute #-} 28 | 29 | -- | Get the name of the HTML tag for this 'Target'. Note that unlike in the 30 | -- browser itself, Myxine returns tag names in lower case, rather than upper. 31 | tag :: Target -> Text 32 | tag Target{tagName} = tagName 33 | {-# INLINE tag #-} 34 | 35 | -- | A fact about a 'Target', such as it having a particular tag or having a 36 | -- particular attribute equal to a particular value. 37 | -- 38 | -- You can construct a 'TargetFact' using 'Myxine.tagIs', 'Myxine.attrIs', or 39 | -- 'Myxine.window'. 40 | data TargetFact 41 | = HasTag !Text 42 | | AttributeEquals !Text !Text 43 | | Window 44 | deriving (Eq, Ord, Show, Generic, Hashable) 45 | 46 | targetFacts :: Target -> [TargetFact] 47 | targetFacts Target{tagName, attributes} = 48 | HasTag tagName : map (uncurry AttributeEquals) (HashMap.toList attributes) 49 | -------------------------------------------------------------------------------- /clients/haskell/stack.yaml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by 'stack init' 2 | # 3 | # Some commonly used options have been documented as comments in this file. 4 | # For advanced use and comprehensive documentation of the format, please see: 5 | # https://docs.haskellstack.org/en/stable/yaml_configuration/ 6 | 7 | # Resolver to choose a 'specific' stackage snapshot or a compiler version. 8 | # A snapshot resolver dictates the compiler version and the set of packages 9 | # to be used for project dependencies. For example: 10 | # 11 | # resolver: lts-3.5 12 | # resolver: nightly-2015-09-21 13 | # resolver: ghc-7.10.2 14 | # 15 | # The location of a snapshot can be provided as a file or url. Stack assumes 16 | # a snapshot provided as a file might change, whereas a url resource does not. 17 | # 18 | # resolver: ./custom-snapshot.yaml 19 | # resolver: https://example.com/snapshots/2018-01-01.yaml 20 | resolver: lts-16.8 21 | 22 | # User packages to be built. 23 | # Various formats can be used as shown in the example below. 24 | # 25 | # packages: 26 | # - some-directory 27 | # - https://example.com/foo/bar/baz-0.0.2.tar.gz 28 | # - location: 29 | # git: https://github.com/commercialhaskell/stack.git 30 | # commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a 31 | # - location: https://github.com/commercialhaskell/stack/commit/e7b331f14bcffb8367cd58fbfc8b40ec7642100a 32 | # subdirs: 33 | # - auto-update 34 | # - wai 35 | packages: 36 | - . 37 | # Dependency packages to be pulled from upstream that are not in the resolver 38 | # using the same syntax as the packages field. 39 | # (e.g., acme-missiles-0.3) 40 | extra-deps: 41 | - dependent-map-0.4.0.0 42 | - constraints-extras-0.3.0.2 43 | - dependent-sum-0.7.1.0 44 | 45 | # Override default flag values for local packages and extra-deps 46 | # flags: {} 47 | 48 | # Extra package databases containing global packages 49 | # extra-package-dbs: [] 50 | 51 | # Control whether we use the GHC we find on the path 52 | # system-ghc: true 53 | # 54 | # Require a specific version of stack, using version ranges 55 | # require-stack-version: -any # Default 56 | # require-stack-version: ">=1.9" 57 | # 58 | # Override the architecture used by stack, especially useful on Windows 59 | # arch: i386 60 | # arch: x86_64 61 | # 62 | # Extra directories used by stack for building 63 | # extra-include-dirs: [/path/to/dir] 64 | # extra-lib-dirs: [/path/to/dir] 65 | # 66 | # Allow a newer minor version of GHC than the snapshot specifies 67 | # compiler-check: newer-minor 68 | 69 | allow-newer: true 70 | -------------------------------------------------------------------------------- /clients/haskell/stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: dependent-map-0.4.0.0@sha256:ca2b131046f4340a1c35d138c5a003fe4a5be96b14efc26291ed35fd08c62221,1657 9 | pantry-tree: 10 | size: 551 11 | sha256: 5defa30010904d2ad05a036f3eaf83793506717c93cbeb599f40db1a3632cfc5 12 | original: 13 | hackage: dependent-map-0.4.0.0 14 | - completed: 15 | hackage: constraints-extras-0.3.0.2@sha256:013b8d0392582c6ca068e226718a4fe8be8e22321cc0634f6115505bf377ad26,1853 16 | pantry-tree: 17 | size: 594 18 | sha256: 3ce1012bfb02e4d7def9df19ce80b8cd2b472c691b25b181d9960638673fecd1 19 | original: 20 | hackage: constraints-extras-0.3.0.2 21 | - completed: 22 | hackage: dependent-sum-0.7.1.0@sha256:5599aa89637db434431b1dd3fa7c34bc3d565ee44f0519bfbc877be1927c2531,2068 23 | pantry-tree: 24 | size: 290 25 | sha256: 9cbfb32b5a8a782b7a1c941803fd517633cb699159b851c1d82267a9e9391b50 26 | original: 27 | hackage: dependent-sum-0.7.1.0 28 | snapshots: 29 | - completed: 30 | size: 532379 31 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/16/8.yaml 32 | sha256: 2ad3210d2ad35f3176005d68369a18e4d984517bfaa2caade76f28ed0b2e0521 33 | original: lts-16.8 34 | -------------------------------------------------------------------------------- /clients/haskell/test/Test.hs: -------------------------------------------------------------------------------- 1 | module Main (main) where 2 | 3 | main :: IO () 4 | main = putStrLn "Test suite not yet implemented." 5 | -------------------------------------------------------------------------------- /clients/python/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /clients/python/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | -------------------------------------------------------------------------------- /clients/python/README.md: -------------------------------------------------------------------------------- 1 | # A Python client for Myxine 2 | 3 | [Myxine](https://github.com/kwf/myxine) is a language-agnostic local 4 | server that lets you build interactive applications in the browser using a 5 | RESTful API. This package defines simple Python bindings for using Myxine to 6 | quickly prototype surprisingly high-performance GUIs. 7 | 8 | Myxine itself runs as a local server, separately from these bindings. It is 9 | built in Rust, and can be installed using the standard Rust build tool cargo: 10 | 11 | ``` bash 12 | $ cargo install myxine 13 | ``` 14 | 15 | This Python package does not manage the myxine server process; it assumes that 16 | it is already running in the background (either started by an end-user, or 17 | managed by your own Python application). 18 | 19 | **Package versioning and stability:** This package should be considered in 20 | "alpha" stability at present. No compatibility between alpha versions is 21 | guaranteed. 22 | -------------------------------------------------------------------------------- /clients/python/myxine/__init__.py: -------------------------------------------------------------------------------- 1 | from .myxine import Target, Event, page_url, events, evaluate, update, static 2 | -------------------------------------------------------------------------------- /clients/python/myxine/myxine.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Iterator, Dict, List, Any 2 | import requests 3 | from dataclasses import dataclass 4 | from requests import RequestException 5 | import json 6 | from copy import deepcopy 7 | import urllib.parse 8 | from semantic_version import Version, SimpleSpec 9 | 10 | # The default port on which myxine operates; can be overridden in the below 11 | # functions if the server is running on another port. 12 | DEFAULT_PORT = 1123 13 | 14 | 15 | # The supported versions of the myxine server 16 | SUPPORTED_SERVER_VERSIONS = SimpleSpec('>=0.2,<0.3') 17 | 18 | 19 | # The global session for all requests 20 | __GLOBAL_SESSION = requests.Session() 21 | 22 | 23 | @dataclass 24 | class Target: 25 | """A Target corresponds to an element in the browser's document. It 26 | contains a tag name and a mapping from attribute name to attribute value. 27 | """ 28 | tag: str 29 | attributes: Dict[str, str] 30 | 31 | 32 | class Event: 33 | """An Event from a page has a type, a list of targets, and a set of 34 | properties keyed by strings, which may be any type. All properties of an 35 | event are accessible as fields of this object, though different event types 36 | may have different sets of fields. 37 | """ 38 | __event: str 39 | __targets: List[Target] 40 | __properties: Dict[str, Any] 41 | __finalized: bool = False 42 | 43 | def __getattr__(self, name: str) -> Any: 44 | value = self.__properties[name] 45 | if value is None: 46 | raise AttributeError 47 | return value 48 | 49 | def __setattr__(self, name: str, value: Any) -> None: 50 | if self.__finalized: 51 | raise ValueError("Event objects are immutable once created") 52 | super(Event, self).__setattr__(name, value) 53 | 54 | def __dir__(self) -> List[str]: 55 | fields = dir(super(Event, self)) + \ 56 | list(self.__properties.keys()) + \ 57 | ['event', 'targets'] 58 | return sorted(set(fields)) 59 | 60 | def event(self) -> str: 61 | """Returns the event name for this event.""" 62 | return self.__event 63 | 64 | def targets(self) -> List[Target]: 65 | """Returns the list of targets for this event, in order from most to 66 | least specific in the DOM tree.""" 67 | return deepcopy(self.__targets) 68 | 69 | def __init__(self, value: Dict[str, Any]) -> None: 70 | """Parse a JSON-encoded event. Returns None if it can't be parsed.""" 71 | try: 72 | self.__event = value['event'] 73 | self.__targets = [Target(tag=j['tagName'], 74 | attributes=j['attributes']) 75 | for j in value['targets']] 76 | self.__properties = value['properties'] 77 | except json.JSONDecodeError: 78 | raise ValueError("Could not parse event: " + str(value)) from None 79 | except KeyError: 80 | raise ValueError("Could not parse event: " + str(value)) from None 81 | self.__finalized = True 82 | 83 | 84 | def page_url(path: str, port: int = DEFAULT_PORT) -> str: 85 | """Normalize a port & path to give the localhost url for that location.""" 86 | if len(path) > 0 and path[0] == '/': 87 | path = path[1:] 88 | return 'http://localhost:' + str(port) + '/' + path 89 | 90 | 91 | def events(path: str, 92 | subscription: Optional[List[str]] = None, 93 | port: int = DEFAULT_PORT, 94 | ignore_server_version: bool = False) -> Iterator[Event]: 95 | """Subscribe to a stream of page events from a myxine server, returning an 96 | iterator over the events returned by the stream as they become available. 97 | """ 98 | base_url = page_url(path, port) 99 | try: 100 | # The base parameters of the request 101 | params: Dict[str, Any] 102 | if subscription is None: 103 | params = {'events': ''} 104 | else: 105 | params = {'events': subscription} 106 | params['next'] = '' # The first time around, /?next&events=... 107 | 108 | # The earliest event we will be willing to accept 109 | moment: str = '' 110 | 111 | while True: 112 | url = urllib.parse.urljoin(base_url, moment) 113 | response = __GLOBAL_SESSION.get(url, params=params) 114 | if response.encoding is None: 115 | response.encoding = 'utf-8' 116 | if not ignore_server_version: 117 | check_server_version(response) 118 | ignore_server_version = True 119 | event = Event(response.json()) 120 | if event is not None: 121 | yield event 122 | 123 | # Set up the next request 124 | moment = response.headers['Content-Location'] 125 | 126 | except RequestException: 127 | msg = "Connection issue with myxine server (is it running?)" 128 | raise ConnectionError(msg) from None 129 | 130 | 131 | def evaluate(path: str, *, 132 | expression: Optional[str] = None, 133 | statement: Optional[str] = None, 134 | port: int = DEFAULT_PORT, 135 | ignore_server_version: bool = False) -> None: 136 | """Evaluate the given JavaScript code in the context of the page.""" 137 | bad_args_err = \ 138 | ValueError('Input must be exactly one of a statement or an expression') 139 | if expression is not None: 140 | if statement is not None: 141 | raise bad_args_err 142 | url = page_url(path, port) 143 | params = {'evaluate': expression} 144 | data = b'' 145 | elif statement is not None: 146 | if expression is not None: 147 | raise bad_args_err 148 | url = page_url(path, port) + '?evaluate' 149 | params = {} 150 | data = statement.encode() 151 | else: 152 | raise bad_args_err 153 | try: 154 | r = __GLOBAL_SESSION.post(url, data=data, params=params) 155 | if not ignore_server_version: 156 | check_server_version(r) 157 | if r.status_code == 200: 158 | return r.json() 159 | raise ValueError(r.text) 160 | except RequestException: 161 | msg = "Connection issue with myxine server (is it running?)" 162 | raise ConnectionError(msg) from None 163 | 164 | 165 | def update(path: str, 166 | body: str, 167 | title: Optional[str] = None, 168 | port: int = DEFAULT_PORT, 169 | ignore_server_version: bool = False) -> None: 170 | """Set the contents of the page at the given path to a provided body and 171 | title. If body or title is not provided, clears those elements of the page. 172 | """ 173 | url = page_url(path, port) 174 | try: 175 | params = {'title': title} 176 | r = __GLOBAL_SESSION.post(url, data=body.encode(), params=params) 177 | if not ignore_server_version: 178 | check_server_version(r) 179 | except RequestException: 180 | msg = "Connection issue with myxine server (is it running?)" 181 | raise ConnectionError(msg) from None 182 | 183 | 184 | def static(path: str, 185 | body: bytes, 186 | content_type: str, 187 | port: int = DEFAULT_PORT, 188 | ignore_server_version: bool = False) -> None: 189 | """Set the contents of the page at the given path to the static content 190 | provided, as a bytestring. You must specify a content type, or else the 191 | browser won't necessarily know how to display this content. 192 | """ 193 | url = page_url(path, port) + '?static' 194 | try: 195 | headers = {'Content-Type': content_type} 196 | r = __GLOBAL_SESSION.post(url, data=body, headers=headers) 197 | if not ignore_server_version: 198 | check_server_version(r) 199 | except RequestException: 200 | msg = "Connection issue with myxine server (is it running?)" 201 | raise ConnectionError(msg) from None 202 | 203 | 204 | def check_server_version(response: requests.Response) -> None: 205 | """Check to make sure the Server header in the given response is valid for 206 | the versions supported by this version of the client library, and throw an 207 | exception if not. 208 | """ 209 | try: 210 | server_version = response.headers['server'] 211 | if server_version is not None: 212 | try: 213 | server, version_string = server_version.split('/') 214 | if server == "myxine": 215 | try: 216 | version = Version.coerce(version_string) 217 | if version in SUPPORTED_SERVER_VERSIONS: 218 | return 219 | else: 220 | msg = f"Unsupported version of the myxine server: \ 221 | {version}; supported versions are {str(SUPPORTED_SERVER_VERSIONS)}" 222 | raise ConnectionError(msg) from None 223 | except ValueError: 224 | msg = f"Could not parse myxine server version string: {version_string}" 225 | raise ConnectionError(msg) from None 226 | except ValueError: 227 | pass 228 | except KeyError: 229 | pass 230 | 231 | # Default: 232 | msg = "Server did not identify itself as a myxine server." 233 | raise ConnectionError(msg) from None 234 | -------------------------------------------------------------------------------- /clients/python/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pipenv install twine --dev 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | from typing import List, Dict 12 | 13 | from setuptools import find_packages, setup, Command 14 | 15 | # Package meta-data. 16 | NAME = 'myxine-client' 17 | DESCRIPTION = 'A Python client for the Myxine GUI server.' 18 | URL = 'https://github.com/kwf/myxine' 19 | EMAIL = 'kwf@very.science' 20 | AUTHOR = 'Kenny Foner' 21 | REQUIRES_PYTHON = '>=3.6.0' 22 | VERSION = '0.2.1' 23 | 24 | # What packages are required for this module to be executed? 25 | REQUIRED = [ 26 | 'requests', 27 | 'semantic_version' 28 | ] 29 | 30 | # What packages are optional? 31 | EXTRAS: Dict[str, str] = { 32 | # 'fancy feature': ['django'], 33 | } 34 | 35 | # The rest you shouldn't have to touch too much :) 36 | # ------------------------------------------------ 37 | # Except, perhaps the License and Trove Classifiers! 38 | # If you do change the License, remember to change the Trove Classifier for that! 39 | 40 | here = os.path.abspath(os.path.dirname(__file__)) 41 | 42 | # Import the README and use it as the long-description. 43 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 44 | try: 45 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 46 | long_description = '\n' + f.read() 47 | except FileNotFoundError: 48 | long_description = DESCRIPTION 49 | 50 | # Load the package's __version__.py module as a dictionary. 51 | about: Dict[str, str] = {} 52 | if not VERSION: 53 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_") 54 | with open(os.path.join(here, project_slug, '__version__.py')) as f: 55 | exec(f.read(), about) 56 | else: 57 | about['__version__'] = VERSION 58 | 59 | 60 | class UploadCommand(Command): 61 | """Support setup.py upload.""" 62 | 63 | description = 'Build and publish the package.' 64 | user_options: List[str] = [] 65 | 66 | @staticmethod 67 | def status(s): 68 | """Prints things in bold.""" 69 | print('\033[1m{0}\033[0m'.format(s)) 70 | 71 | def initialize_options(self): 72 | pass 73 | 74 | def finalize_options(self): 75 | pass 76 | 77 | def run(self): 78 | try: 79 | self.status('Removing previous builds…') 80 | rmtree(os.path.join(here, 'dist')) 81 | except OSError: 82 | pass 83 | 84 | self.status('Building Source and Wheel (universal) distribution…') 85 | os.system('{0} setup.py sdist bdist_wheel --universal' 86 | .format(sys.executable)) 87 | 88 | self.status('Uploading the package to PyPI via Twine…') 89 | os.system('twine upload dist/*') 90 | 91 | # self.status('Pushing git tags…') 92 | # os.system('git tag v{0}'.format(about['__version__'])) 93 | # os.system('git push --tags') 94 | 95 | sys.exit() 96 | 97 | 98 | # Where the magic happens: 99 | setup( 100 | name=NAME, 101 | version=about['__version__'], 102 | description=DESCRIPTION, 103 | long_description=long_description, 104 | long_description_content_type='text/markdown', 105 | author=AUTHOR, 106 | author_email=EMAIL, 107 | python_requires=REQUIRES_PYTHON, 108 | url=URL, 109 | packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), 110 | # If your package is a single module, use this instead of 'packages': 111 | # py_modules=['myxine'], 112 | 113 | # entry_points={ 114 | # 'console_scripts': ['mycli=mymodule:cli'], 115 | # }, 116 | install_requires=REQUIRED, 117 | extras_require=EXTRAS, 118 | include_package_data=True, 119 | license='MIT', 120 | classifiers=[ 121 | # Trove classifiers 122 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 123 | 'License :: OSI Approved :: MIT License', 124 | 'Programming Language :: Python', 125 | 'Programming Language :: Python :: 3', 126 | 'Programming Language :: Python :: 3.6', 127 | 'Programming Language :: Python :: Implementation :: CPython', 128 | 'Programming Language :: Python :: Implementation :: PyPy' 129 | ], 130 | # $ setup.py publish support. 131 | cmdclass={ 132 | 'upload': UploadCommand, 133 | }, 134 | ) 135 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "myxine-core" 3 | version = "0.1.1" 4 | description = "The core library powering the Myxine GUI server." 5 | keywords = ["GUI", "web", "interface", "scripting", "tool"] 6 | categories = ["command-line-utilities", "graphics", "gui", "visualization", "web-programming"] 7 | authors = ["Kenny Foner "] 8 | edition = "2018" 9 | homepage = "https://github.com/kwf/myxine" 10 | readme = "README.md" 11 | license = "MIT" 12 | 13 | [badges] 14 | maintenance = { status = "actively-developed" } 15 | 16 | [dependencies] 17 | hopscotch = "0.1.1" 18 | futures = "0.3" 19 | tokio = { version = "0.2.22", features = ["full"] } 20 | serde = { version = "1.0", features = ["derive"] } 21 | serde_json = "1.0" 22 | bytes = "0.5" 23 | uuid = { version = "0.8", features = ["v4", "serde"] } 24 | -------------------------------------------------------------------------------- /core/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | This package provides the core library to represent and manipulate dynamic 2 | webpages in the [Myxine](https://github.com/kwf/myxine) GUI server. 3 | 4 | **Stability:** While this package will adhere to semantic versioning, do not 5 | expect its API to be very stable for the time being. It is in flux during the 6 | development process of the Myxine server itself. 7 | -------------------------------------------------------------------------------- /core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This package is the heart of the [Myxine GUI 2 | //! server](https://github.com/kwf/myxine). It does not contain any code 3 | //! for directly implementing the web server or browser-side JavaScript. 4 | //! 5 | //! You probably only need to depend on this library if you are yourself 6 | //! creating an alternative to the Myxine server: to build clients to the Myxine 7 | //! server, see the documentation for its various client libraries. 8 | 9 | mod page; 10 | mod session; 11 | 12 | pub use page::subscription::{Event, Subscription}; 13 | pub use page::{content::Command, Page, RefreshMode, Response}; 14 | pub use session::{Config, Session}; 15 | -------------------------------------------------------------------------------- /core/src/page/content.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use serde::Serialize; 3 | use std::mem; 4 | use tokio::stream::{Stream, StreamExt}; 5 | use tokio::sync::broadcast; 6 | use uuid::Uuid; 7 | 8 | use super::RefreshMode; 9 | 10 | /// The `Content` of a page is either `Dynamic` or `Static`. If it's dynamic, it 11 | /// has a title, body, and a set of SSE event listeners who are waiting for 12 | /// updates to the page. If it's static, it just has a fixed content type and a 13 | /// byte array of contents to be returned when fetched. `Page`s can be changed 14 | /// from dynamic to static and vice-versa: when changing from dynamic to static, 15 | /// the change is instantly reflected in the client web browser; in the other 16 | /// direction, it requires a manual refresh (because a static page has no 17 | /// injected javascript to make it update itself). 18 | #[derive(Debug, Clone)] 19 | pub enum Content { 20 | Dynamic { 21 | title: String, 22 | body: String, 23 | updates: broadcast::Sender, 24 | other_commands: broadcast::Sender, 25 | }, 26 | Static { 27 | content_type: Option, 28 | raw_contents: Bytes, 29 | }, 30 | } 31 | 32 | /// A command sent directly to the code running in the browser page to tell it 33 | /// to update or perform some other action. 34 | #[derive(Debug, Clone, Serialize)] 35 | #[serde(rename_all = "camelCase", tag = "type")] 36 | pub enum Command { 37 | /// Reload the page completely, i.e. via `window.location.reload()`. 38 | #[serde(rename_all = "camelCase")] 39 | Reload, 40 | /// Update the page's dynamic content by setting `window.title` to the given 41 | /// title, and setting the contents of the `` to the given body, 42 | /// either by means of a DOM diff (if `diff == true`) or directly by setting 43 | /// `.innerHTML` (if `diff == false`). 44 | #[serde(rename_all = "camelCase")] 45 | Update { 46 | /// The new title of the page. 47 | title: String, 48 | /// The new body of the page. 49 | body: String, 50 | /// Whether to use some diffing method to increase efficiency in the 51 | /// update (this is usually `true` outside of some debugging contexts.) 52 | diff: bool, 53 | }, 54 | /// Evaluate some JavaScript code in the page. 55 | #[serde(rename_all = "camelCase")] 56 | Evaluate { 57 | /// The text of the JavaScript to evaluate. 58 | script: String, 59 | /// If `statement_mode == true`, then the given script is evaluated 60 | /// exactly as-is; otherwise, it is treated as an *expression* and 61 | /// wrapped in an implicit `return (...);`. 62 | statement_mode: bool, 63 | /// The unique id of the request for evaluation, which will be used to 64 | /// report the result once it is available. 65 | id: Uuid, 66 | }, 67 | } 68 | 69 | /// The maximum number of updates to buffer before dropping an update. This is 70 | /// set to 1, because dropped updates are okay (the most recent update will 71 | /// always get through once things quiesce). 72 | const UPDATE_BUFFER_SIZE: usize = 1; 73 | 74 | /// The maximum number of non-update commands to buffer before dropping one. 75 | /// This is set to a medium sized number, because we don't want to drop a reload 76 | /// command or an evaluate command. Unlike the update buffer, clients likely 77 | /// won't fill this one, because it's used only for occasional full-reload 78 | /// commands and for evaluating JavaScript, neither of which should be done at 79 | /// an absurd rate. 80 | const OTHER_COMMAND_BUFFER_SIZE: usize = 16; 81 | // NOTE: This memory is allocated all at once, which means that the choice of 82 | // buffer size impacts myxine's memory footprint. 83 | 84 | impl Content { 85 | /// Make a new empty (dynamic) page 86 | pub fn new() -> Content { 87 | Content::Dynamic { 88 | title: String::new(), 89 | body: String::new(), 90 | updates: broadcast::channel(UPDATE_BUFFER_SIZE).0, 91 | other_commands: broadcast::channel(OTHER_COMMAND_BUFFER_SIZE).0, 92 | } 93 | } 94 | 95 | /// Test if this page is empty, where "empty" means that it is dynamic, with 96 | /// an empty title, empty body, and no subscribers waiting on its page 97 | /// events: that is, it's identical to `Content::new()`. 98 | pub fn is_empty(&self) -> bool { 99 | match self { 100 | Content::Dynamic { 101 | title, 102 | body, 103 | ref updates, 104 | ref other_commands, 105 | } if title == "" && body == "" => { 106 | updates.receiver_count() == 0 && other_commands.receiver_count() == 0 107 | } 108 | _ => false, 109 | } 110 | } 111 | 112 | /// Add a client to the dynamic content of a page, if it is dynamic. If it 113 | /// is static, this has no effect and returns None. Otherwise, returns the 114 | /// Body stream to give to the new client. 115 | pub fn commands(&self) -> Option> { 116 | let result = match self { 117 | Content::Dynamic { 118 | updates, 119 | other_commands, 120 | .. 121 | } => { 122 | let merged = updates.subscribe().merge(other_commands.subscribe()); 123 | let stream_body = merged 124 | .filter_map(|result| { 125 | match result { 126 | // We ignore lagged items in the stream! If we don't 127 | // ignore these, we would terminate the Body on 128 | // every lag, which is undesirable. 129 | Err(broadcast::RecvError::Lagged(_)) => None, 130 | // Otherwise, if the stream is over, we end this stream. 131 | Err(broadcast::RecvError::Closed) => Some(Err(())), 132 | // But if the item is ok, forward it. 133 | Ok(item) => Some(Ok(item)), 134 | } 135 | }) 136 | .take_while(|i| i.is_ok()) 137 | .map(|i| i.unwrap()); 138 | Some(stream_body) 139 | } 140 | Content::Static { .. } => None, 141 | }; 142 | // Make sure the page is up to date 143 | self.refresh(RefreshMode::Diff); 144 | result 145 | } 146 | 147 | /// Tell all clients to refresh the contents of a page, if it is dynamic. 148 | /// This has no effect if it is (currently) static. 149 | pub fn refresh(&self, refresh: RefreshMode) { 150 | match self { 151 | Content::Dynamic { 152 | updates, 153 | other_commands, 154 | title, 155 | body, 156 | } => { 157 | let _ = match refresh { 158 | RefreshMode::FullReload => other_commands.send(Command::Reload), 159 | RefreshMode::SetBody | RefreshMode::Diff => updates.send(Command::Update { 160 | title: title.clone(), 161 | body: body.clone(), 162 | diff: refresh == RefreshMode::Diff, 163 | }), 164 | }; 165 | } 166 | Content::Static { .. } => (), 167 | }; 168 | } 169 | 170 | /// Set the contents of the page to be a static raw set of bytes with no 171 | /// self-refreshing functionality. All clients will be told to refresh their 172 | /// page to load the new static content (which will not be able to update 173 | /// itself until a client refreshes their page again). 174 | pub fn set_static(&mut self, content_type: Option, raw_contents: Bytes) { 175 | let mut content = Content::Static { 176 | content_type, 177 | raw_contents, 178 | }; 179 | mem::swap(&mut content, self); 180 | content.refresh(RefreshMode::FullReload); 181 | } 182 | 183 | /// Tell all clients to change the body, if necessary. This converts the 184 | /// page into a dynamic page, overwriting any static content that previously 185 | /// existed, if any. Returns `true` if the page content was changed (either 186 | /// converted from static, or altered whilst dynamic). 187 | pub fn set( 188 | &mut self, 189 | new_title: impl Into, 190 | new_body: impl Into, 191 | refresh: RefreshMode, 192 | ) -> bool { 193 | let mut changed = false; 194 | loop { 195 | match self { 196 | Content::Dynamic { 197 | ref mut title, 198 | ref mut body, 199 | .. 200 | } => { 201 | let new_title = new_title.into(); 202 | let new_body = new_body.into(); 203 | if new_title != *title || new_body != *body { 204 | changed = true; 205 | } 206 | *title = new_title; 207 | *body = new_body; 208 | break; // values have been set 209 | } 210 | Content::Static { .. } => { 211 | *self = Content::new(); 212 | changed = true; 213 | // and loop again to actually set 214 | } 215 | } 216 | } 217 | if changed { 218 | self.refresh(refresh); 219 | } 220 | changed 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /core/src/page/query.rs: -------------------------------------------------------------------------------- 1 | use futures::Future; 2 | use std::collections::HashMap; 3 | use tokio::sync::oneshot; 4 | use uuid::Uuid; 5 | 6 | /// A set of pending queries keyed by unique id, waiting to be responded to. 7 | #[derive(Debug)] 8 | pub struct Queries { 9 | pending: HashMap)>, 10 | } 11 | 12 | impl Queries { 13 | /// Create a new empty set of pending queries. 14 | pub fn new() -> Self { 15 | Queries { 16 | pending: HashMap::new(), 17 | } 18 | } 19 | 20 | /// Create an unfulfilled request and return its id and the future which 21 | /// waits on its fulfillment. 22 | pub fn request(&mut self, query: Q) -> (Uuid, impl Future>) { 23 | let id = Uuid::new_v4(); 24 | let (sender, recv) = oneshot::channel(); 25 | self.pending.insert(id, (query, sender)); 26 | (id, async { recv.await.ok() }) 27 | } 28 | 29 | /// Attempt to fulfill the request of the given id, returning the given 30 | /// response if there's an error sending it, or if there is no request with 31 | /// the specified id. 32 | pub fn respond(&mut self, id: Uuid, response: A) -> Result { 33 | if let Some((query, sender)) = self.pending.remove(&id) { 34 | sender.send(response)?; 35 | Ok(query) 36 | } else { 37 | Err(response) 38 | } 39 | } 40 | 41 | /// Get an iterator of all pending queries, paired with their ids. 42 | pub fn pending(&self) -> impl Iterator { 43 | self.pending.iter().map(|(id, (q, _))| (id, q)) 44 | } 45 | 46 | /// Cancel a pending request, so that it will never be answered, and any 47 | /// future response will do nothing. 48 | pub fn cancel(&mut self, id: Uuid) { 49 | self.pending.remove(&id); 50 | } 51 | 52 | /// Test if the set of pending queries is empty. 53 | pub fn is_empty(&self) -> bool { 54 | self.pending.is_empty() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /core/src/page/subscription.rs: -------------------------------------------------------------------------------- 1 | use futures::{Future, Stream}; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::Value; 4 | use std::collections::HashSet; 5 | use std::sync::Arc; 6 | use tokio::sync::{mpsc, oneshot}; 7 | 8 | /// An incoming event sent from the browser, intended to be forwarded directly 9 | /// to listeners. While there is more structure here than merely a triple of 10 | /// JSON values, we don't bother to parse it because the client will be parsing 11 | /// it again, so this will be a waste of resources. 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct Event { 14 | pub event: String, 15 | pub targets: Value, 16 | pub properties: Value, 17 | } 18 | 19 | /// A subscription to events is either a `Universal` subscription to all events, 20 | /// or a `Specific` set of events to which the client wishes to subscribe. 21 | #[derive(Debug, Clone, Eq, PartialEq)] 22 | pub enum Subscription { 23 | /// The client is interested in a specific set of events listed herein. 24 | /// 25 | /// An empty set indicates an interest in zero events, and is distinct from 26 | /// a universal subscription, which indicates an interest in every event. 27 | Specific(HashSet), 28 | /// The client is interested in all events, no matter what type they are. 29 | Universal, 30 | } 31 | 32 | impl Subscription { 33 | /// Make a subscription from a collection of events. 34 | pub fn from_events(events: impl Into>) -> Self { 35 | Subscription::Specific(events.into()) 36 | } 37 | 38 | /// Make a universal subscription to all events. 39 | pub fn universal() -> Self { 40 | Subscription::Universal 41 | } 42 | 43 | /// Tests whether a given event is a member of this subscription. 44 | pub fn matches_event(&self, event: &str) -> bool { 45 | match self { 46 | Subscription::Universal => true, 47 | Subscription::Specific(set) => set.contains(event), 48 | } 49 | } 50 | } 51 | 52 | #[derive(Debug)] 53 | pub struct Subscribers { 54 | sinks: Vec, 55 | } 56 | 57 | #[derive(Debug)] 58 | pub enum SinkSender { 59 | Persistent(mpsc::UnboundedSender>), 60 | Once { 61 | sender: oneshot::Sender), u64>>, 62 | after: u64, 63 | lagged: bool, 64 | }, 65 | } 66 | 67 | #[derive(Debug)] 68 | struct Sink { 69 | subscription: Subscription, 70 | sender: SinkSender, 71 | } 72 | 73 | impl Subscribers { 74 | /// Make a new empty set of subscribers. 75 | pub fn new() -> Subscribers { 76 | Subscribers { sinks: Vec::new() } 77 | } 78 | 79 | /// Returns `true` if there are no subscribers to any events in this set of 80 | /// subscribers, `false` otherwise. 81 | pub fn is_empty(&self) -> bool { 82 | self.sinks.is_empty() 83 | } 84 | 85 | /// Add a persistent subscriber for the given subscription. The returned 86 | /// stream will continue to produce events until the consumer disconnects or 87 | /// the server exits. 88 | pub fn add_subscriber(&mut self, subscription: Subscription) -> impl Stream> { 89 | // Create a new single-client SSE server (new clients will never be added 90 | // after this, because each event subscription is potentially unique). 91 | let (tx, rx) = mpsc::unbounded_channel(); 92 | let sender = SinkSender::Persistent(tx); 93 | 94 | // Insert the server into the sinks map 95 | self.sinks.push(Sink { 96 | sender, 97 | subscription, 98 | }); 99 | 100 | // Return the body, for sending to whoever subscribed 101 | rx 102 | } 103 | 104 | /// Add a one-off subscriber which gives the moment it was fulfilled, as 105 | /// well as the body of the event to which it corresponds. This `Body` will 106 | /// be a single valid JSON string. 107 | pub fn add_one_off( 108 | &mut self, 109 | subscription: Subscription, 110 | after: u64, 111 | lagged: bool, 112 | ) -> impl Future), u64>> { 113 | let (sender, receiver) = oneshot::channel(); 114 | self.sinks.push(Sink { 115 | sender: SinkSender::Once { 116 | sender, 117 | after, 118 | lagged, 119 | }, 120 | subscription, 121 | }); 122 | async move { 123 | receiver 124 | .await 125 | .expect("Receivers for one-off subscriptions shouldn't be dropped") 126 | } 127 | } 128 | 129 | /// Send an event to all subscribers to that event, giving each subscriber 130 | /// only those fields of the event which that subscriber cares about. If the 131 | /// list of subscribers has changed (that is, by client disconnection), 132 | /// returns the union of all now-current subscriptions. 133 | pub fn send_event(&mut self, moment: u64, event: Arc) { 134 | let mut i = 0; 135 | loop { 136 | if i >= self.sinks.len() { 137 | break; 138 | } 139 | if self.sinks[i].subscription.matches_event(&event.event) { 140 | let Sink { 141 | sender, 142 | subscription, 143 | } = self.sinks.swap_remove(i); 144 | match sender { 145 | SinkSender::Persistent(server) 146 | // NOTE: this is an if-guard, not an expression, so it 147 | // falls through to the default case if there are still 148 | // clients after the message is sent 149 | if server.send(event.clone()).is_err() => { 150 | // if the receiver of a persistent sink has been 151 | // dropped, remove it from the list 152 | }, 153 | SinkSender::Once{sender, after, lagged} if moment >= after => { 154 | // if the current moment is later than or equal to the 155 | // time this one-off sink is waiting for, dispatch it 156 | // and remove it from the list of sinks 157 | let result = if lagged { 158 | // If the original request was lagging, we don't 159 | // send back an actual response; we signal to the 160 | // client via a redirect that lag occurred. 161 | Err(moment) 162 | } else { 163 | Ok((moment, event.clone())) 164 | }; 165 | sender.send(result).unwrap_or(()); 166 | }, 167 | sender => { 168 | // otherwise, re-insert the sink and move forward, by 169 | // pushing the current sink onto the end of the vector 170 | // and swapping it back into place, then incrementing 171 | // the index by one 172 | self.sinks.push(Sink{sender, subscription}); 173 | let end = self.sinks.len() - 1; 174 | self.sinks.swap(i, end); 175 | i += 1; // move on to the next element 176 | }, 177 | } 178 | } else { 179 | i += 1; // move onto the next element 180 | } 181 | } 182 | 183 | // Shrink down the sinks so we don't bloat memory 184 | self.sinks.shrink_to_fit(); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /core/src/session.rs: -------------------------------------------------------------------------------- 1 | use futures::{future, pin_mut, select, FutureExt}; 2 | use std::collections::{hash_map::Entry, HashMap, HashSet}; 3 | use std::iter::Iterator; 4 | use std::sync::Arc; 5 | use std::time::{Duration, Instant}; 6 | use tokio::sync::{mpsc, Mutex}; 7 | use tokio::time; 8 | 9 | use crate::page::Page; 10 | 11 | /// The configuration for a session, describing the behavior of the heartbeat / 12 | /// page garbage collector, and the default parameters for each new page. 13 | #[derive(Debug, Clone)] 14 | pub struct Config { 15 | /// The delay between successive heartbeats to all pages. 16 | pub heartbeat_interval: Duration, 17 | /// The minimum time a page must remain untouched by any external method 18 | /// (including events coming in from the page itself) in order to be 19 | /// eligible for garbage collection. 20 | pub keep_alive_duration: Duration, 21 | /// The default size of the event buffer for each page. The larger this is, 22 | /// the more memory a page will consume, but clients will be able to lag 23 | /// by more events without dropping them. 24 | pub default_buffer_len: usize, 25 | } 26 | 27 | /// A collection of `Page`s, uniquely keyed by a `String` path, which are 28 | /// periodically pruned by a garbage collector thread to remove inactive and 29 | /// empty pages from the pool. 30 | pub struct Session { 31 | touch_path: mpsc::UnboundedSender, 32 | active_paths: Arc>>, 33 | pages: Arc>, 34 | default_buffer_len: usize, 35 | } 36 | 37 | /// The map from paths to pages tagged with last-touched instants. 38 | type PageMap = HashMap)>; 39 | 40 | impl Session { 41 | /// Create a new session, starting a thread to maintain heartbeats to any 42 | /// Pages created in this session. 43 | pub async fn start( 44 | Config { 45 | heartbeat_interval, 46 | keep_alive_duration, 47 | default_buffer_len, 48 | }: Config, 49 | ) -> Session { 50 | let (touch_path, recv_path) = mpsc::unbounded_channel(); 51 | let session = Session { 52 | touch_path, 53 | active_paths: Arc::new(Mutex::new(HashSet::new())), 54 | pages: Arc::new(Mutex::new(HashMap::new())), 55 | default_buffer_len, 56 | }; 57 | let heartbeat = heartbeat_loop( 58 | heartbeat_interval, 59 | keep_alive_duration, 60 | recv_path, 61 | session.active_paths.clone(), 62 | session.pages.clone(), 63 | ); 64 | tokio::spawn(heartbeat); 65 | session 66 | } 67 | 68 | /// Retrieve or create a page at this path. 69 | pub async fn page(&self, path: &str) -> Arc { 70 | let page = match self.pages.lock().await.entry(path.to_string()) { 71 | Entry::Vacant(e) => { 72 | let page = Arc::new(Page::new(self.default_buffer_len)); 73 | e.insert((Instant::now(), page.clone())); 74 | page 75 | } 76 | Entry::Occupied(mut e) => { 77 | let (last_access, page) = e.get_mut(); 78 | *last_access = Instant::now(); 79 | page.clone() 80 | } 81 | }; 82 | 83 | // Make sure to send heartbeats to this page now 84 | self.touch_path.send(path.to_string()).unwrap_or(()); 85 | 86 | page 87 | } 88 | } 89 | 90 | /// Send a heartbeat message to keep all page connections alive, simultaneously 91 | /// pruning all pages from memory which have no content and no subscribers. 92 | async fn heartbeat_loop( 93 | interval: Duration, 94 | keep_alive: Duration, 95 | mut recv_path: mpsc::UnboundedReceiver, 96 | active_paths: Arc>>, 97 | pages: Arc>, 98 | ) { 99 | // Receive all new paths into the set of known active paths 100 | let recv_paths = async { 101 | while let Some(path) = recv_path.recv().await { 102 | active_paths.lock().await.insert(path); 103 | } 104 | } 105 | .fuse(); 106 | 107 | // At the specified `HEARTBEAT_INTERVAL`, traverse all active paths, sending 108 | // heartbeats to all pages, and removing all pages which are identical to 109 | // the initial dynamic page (to free up memory). 110 | let heartbeat = async { 111 | loop { 112 | // Wait for next heartbeat interval... 113 | time::delay_for(interval).await; 114 | 115 | // Lock the active set of paths and send a heartbeat to each one, 116 | // noting which paths are identical to the empty page 117 | let mut paths = active_paths.lock().await; 118 | let pruned = Arc::new(Mutex::new(Vec::new())); 119 | future::join_all(paths.iter().map(|path| { 120 | let pruned = pruned.clone(); 121 | let pages = pages.clone(); 122 | async move { 123 | let mut pages = pages.lock().await; 124 | if let Some((path, (last_access, page))) = pages.remove_entry(path) { 125 | if last_access.elapsed() < keep_alive || !page.is_empty().await { 126 | pages.insert(path, (last_access, page)); 127 | } else { 128 | pruned.lock().await.push(path); 129 | } 130 | } 131 | } 132 | })) 133 | .await; 134 | 135 | // Remove all paths that are identical to the empty page 136 | for path in pruned.lock().await.iter() { 137 | paths.remove(path); 138 | } 139 | 140 | // Free memory for all the removed pages and paths 141 | paths.shrink_to_fit(); 142 | pages.lock().await.shrink_to_fit(); 143 | } 144 | } 145 | .fuse(); 146 | 147 | // Run them both concurrently, quit when session is dropped 148 | pin_mut!(recv_paths, heartbeat); 149 | select! { 150 | () = recv_paths => (), 151 | () = heartbeat => (), 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /enabled-events.json: -------------------------------------------------------------------------------- 1 | { 2 | "events": { 3 | "blur": { 4 | "bubbles": false, 5 | "interface": "FocusEvent", 6 | "nameWords": [ 7 | "blur" 8 | ] 9 | }, 10 | "change": { 11 | "bubbles": true, 12 | "interface": "Event", 13 | "nameWords": [ 14 | "change" 15 | ] 16 | }, 17 | "click": { 18 | "bubbles": true, 19 | "interface": "MouseEvent", 20 | "nameWords": [ 21 | "click" 22 | ] 23 | }, 24 | "contextmenu": { 25 | "bubbles": true, 26 | "interface": "MouseEvent", 27 | "nameWords": [ 28 | "context", 29 | "menu" 30 | ] 31 | }, 32 | "dblclick": { 33 | "bubbles": true, 34 | "interface": "MouseEvent", 35 | "nameWords": [ 36 | "dbl", 37 | "click" 38 | ] 39 | }, 40 | "dragend": { 41 | "bubbles": true, 42 | "interface": "DragEvent", 43 | "nameWords": [ 44 | "drag", 45 | "end" 46 | ] 47 | }, 48 | "dragenter": { 49 | "bubbles": true, 50 | "interface": "DragEvent", 51 | "nameWords": [ 52 | "drag", 53 | "enter" 54 | ] 55 | }, 56 | "dragleave": { 57 | "bubbles": true, 58 | "interface": "DragEvent", 59 | "nameWords": [ 60 | "drag", 61 | "leave" 62 | ] 63 | }, 64 | "dragstart": { 65 | "bubbles": true, 66 | "interface": "DragEvent", 67 | "nameWords": [ 68 | "drag", 69 | "start" 70 | ] 71 | }, 72 | "drop": { 73 | "bubbles": true, 74 | "interface": "DragEvent", 75 | "nameWords": [ 76 | "drop" 77 | ] 78 | }, 79 | "focus": { 80 | "bubbles": false, 81 | "interface": "FocusEvent", 82 | "nameWords": [ 83 | "focus" 84 | ] 85 | }, 86 | "focusin": { 87 | "bubbles": true, 88 | "interface": "FocusEvent", 89 | "nameWords": [ 90 | "focus", 91 | "in" 92 | ] 93 | }, 94 | "focusout": { 95 | "bubbles": true, 96 | "interface": "FocusEvent", 97 | "nameWords": [ 98 | "focus", 99 | "out" 100 | ] 101 | }, 102 | "fullscreenchange": { 103 | "bubbles": true, 104 | "interface": "Event", 105 | "nameWords": [ 106 | "fullscreen", 107 | "change" 108 | ] 109 | }, 110 | "fullscreenerror": { 111 | "bubbles": true, 112 | "interface": "Event", 113 | "nameWords": [ 114 | "fullscreen", 115 | "error" 116 | ] 117 | }, 118 | "hashchange": { 119 | "bubbles": true, 120 | "interface": "HashChangeEvent", 121 | "nameWords": [ 122 | "hash", 123 | "change" 124 | ] 125 | }, 126 | "input": { 127 | "bubbles": true, 128 | "interface": "InputEvent", 129 | "nameWords": [ 130 | "input" 131 | ] 132 | }, 133 | "keydown": { 134 | "bubbles": true, 135 | "interface": "KeyboardEvent", 136 | "nameWords": [ 137 | "key", 138 | "down" 139 | ] 140 | }, 141 | "keyup": { 142 | "bubbles": true, 143 | "interface": "KeyboardEvent", 144 | "nameWords": [ 145 | "key", 146 | "up" 147 | ] 148 | }, 149 | "languagechange": { 150 | "bubbles": false, 151 | "interface": "Event", 152 | "nameWords": [ 153 | "language", 154 | "change" 155 | ] 156 | }, 157 | "load": { 158 | "bubbles": false, 159 | "interface": "Event", 160 | "nameWords": [ 161 | "load" 162 | ] 163 | }, 164 | "mousedown": { 165 | "bubbles": true, 166 | "interface": "MouseEvent", 167 | "nameWords": [ 168 | "mouse", 169 | "down" 170 | ] 171 | }, 172 | "mousemove": { 173 | "bubbles": true, 174 | "interface": "MouseEvent", 175 | "nameWords": [ 176 | "mouse", 177 | "move" 178 | ] 179 | }, 180 | "mouseout": { 181 | "bubbles": true, 182 | "interface": "MouseEvent", 183 | "nameWords": [ 184 | "mouse", 185 | "out" 186 | ] 187 | }, 188 | "mouseover": { 189 | "bubbles": true, 190 | "interface": "MouseEvent", 191 | "nameWords": [ 192 | "mouse", 193 | "over" 194 | ] 195 | }, 196 | "mouseup": { 197 | "bubbles": true, 198 | "interface": "MouseEvent", 199 | "nameWords": [ 200 | "mouse", 201 | "up" 202 | ] 203 | }, 204 | "reset": { 205 | "bubbles": true, 206 | "interface": "Event", 207 | "nameWords": [ 208 | "reset" 209 | ] 210 | }, 211 | "resize": { 212 | "bubbles": false, 213 | "interface": "UIEvent", 214 | "nameWords": [ 215 | "resize" 216 | ] 217 | }, 218 | "scroll": { 219 | "bubbles": true, 220 | "interface": "Event", 221 | "nameWords": [ 222 | "scroll" 223 | ] 224 | }, 225 | "select": { 226 | "bubbles": true, 227 | "interface": "UIEvent", 228 | "nameWords": [ 229 | "select" 230 | ] 231 | }, 232 | "submit": { 233 | "bubbles": true, 234 | "interface": "Event", 235 | "nameWords": [ 236 | "submit" 237 | ] 238 | }, 239 | "unload": { 240 | "bubbles": false, 241 | "interface": "Event", 242 | "nameWords": [ 243 | "unload" 244 | ] 245 | }, 246 | "visibilitychange": { 247 | "bubbles": true, 248 | "interface": "Event", 249 | "nameWords": [ 250 | "visibility", 251 | "change" 252 | ] 253 | }, 254 | "wheel": { 255 | "bubbles": true, 256 | "interface": "WheelEvent", 257 | "nameWords": [ 258 | "wheel" 259 | ] 260 | } 261 | }, 262 | "interfaces": { 263 | "DragEvent": { 264 | "inherits": "MouseEvent", 265 | "properties": {} 266 | }, 267 | "Event": { 268 | "inherits": null, 269 | "properties": {} 270 | }, 271 | "FocusEvent": { 272 | "inherits": "UIEvent", 273 | "properties": {} 274 | }, 275 | "HashChangeEvent": { 276 | "inherits": "Event", 277 | "properties": { 278 | "newURL": "String", 279 | "oldURL": "String" 280 | } 281 | }, 282 | "InputEvent": { 283 | "inherits": "UIEvent", 284 | "properties": { 285 | "data": "Option", 286 | "inputType": "Option", 287 | "isComposing": "Option" 288 | } 289 | }, 290 | "KeyboardEvent": { 291 | "inherits": "UIEvent", 292 | "properties": { 293 | "altKey": "bool", 294 | "code": "String", 295 | "ctrlKey": "bool", 296 | "isComposing": "bool", 297 | "key": "String", 298 | "metaKey": "bool", 299 | "repeat": "bool", 300 | "shiftKey": "bool" 301 | } 302 | }, 303 | "MouseEvent": { 304 | "inherits": "UIEvent", 305 | "properties": { 306 | "altKey": "bool", 307 | "button": "i64", 308 | "buttons": "i64", 309 | "clientX": "f64", 310 | "clientY": "f64", 311 | "ctrlKey": "bool", 312 | "metaKey": "bool", 313 | "movementX": "f64", 314 | "movementY": "f64", 315 | "offsetX": "f64", 316 | "offsetY": "f64", 317 | "pageX": "f64", 318 | "pageY": "f64", 319 | "region": "Option", 320 | "screenX": "f64", 321 | "screenY": "f64", 322 | "shiftKey": "bool" 323 | } 324 | }, 325 | "UIEvent": { 326 | "inherits": "Event", 327 | "properties": { 328 | "detail": "Option" 329 | } 330 | }, 331 | "WheelEvent": { 332 | "inherits": "MouseEvent", 333 | "properties": { 334 | "deltaMode": "i64", 335 | "deltaX": "f64", 336 | "deltaY": "f64", 337 | "deltaZ": "f64" 338 | } 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /examples/haskell/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for myxine-client-examples 2 | 3 | ## 0.1.0.0 -- YYYY-mm-dd 4 | 5 | * First version. Released on an unsuspecting world. 6 | -------------------------------------------------------------------------------- /examples/haskell/Counter.hs: -------------------------------------------------------------------------------- 1 | {-# language OverloadedStrings, DuplicateRecordFields, NamedFieldPuns #-} 2 | {-# language BlockArguments, RecordWildCards, TemplateHaskell #-} 3 | {-# language RecursiveDo #-} 4 | module Main (main) where 5 | 6 | import Text.Blaze.Html5 ((!)) 7 | import qualified Text.Blaze.Html5 as H 8 | import qualified Text.Blaze.Html5.Attributes as A 9 | import Control.Lens 10 | import Control.Concurrent 11 | import Control.Monad 12 | import Control.Monad.IO.Class 13 | 14 | import Myxine 15 | 16 | data Counter = 17 | Counter { _highlighted :: Bool, _count :: Int } 18 | deriving (Show) 19 | $(makeLenses ''Counter) 20 | 21 | main :: IO () 22 | main = mdo 23 | interrupt <- 24 | countdown 1000000 (modifyPage page (count %~ max 0 . subtract 1)) 25 | page <- runPage mempty 26 | (pure Counter { _highlighted = False, _count = 0 }) 27 | (reactive (counter interrupt)) 28 | print =<< waitPage page 29 | 30 | interval :: Int -> IO () -> IO ThreadId 31 | interval i action = 32 | forkIO (forever (threadDelay i >> action)) 33 | 34 | countdown :: Int -> IO () -> IO (IO ()) 35 | countdown i action = 36 | do interrupts <- newChan 37 | _ <- forkIO $ forever do 38 | tid <- interval i action 39 | () <- readChan interrupts 40 | killThread tid 41 | pure (writeChan interrupts ()) 42 | 43 | counter :: IO () -> Reactive Counter 44 | counter interrupt = do 45 | isHighlighted <- view highlighted 46 | H.button 47 | ! A.style ("background: " <> (if isHighlighted then "red" else "yellow") <> ";" 48 | <> "font-size: 50pt; margin: 40pt;") 49 | @@ do 50 | on Click \_ -> 51 | do count += 1 52 | liftIO interrupt 53 | on MouseOver \_ -> highlighted .= True 54 | on MouseOut \_ -> highlighted .= False 55 | markup =<< view count 56 | -------------------------------------------------------------------------------- /examples/haskell/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /examples/haskell/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /examples/haskell/Todo.hs: -------------------------------------------------------------------------------- 1 | {-# language OverloadedStrings, DuplicateRecordFields, NamedFieldPuns #-} 2 | {-# language BlockArguments, RecordWildCards, TemplateHaskell #-} 3 | 4 | module Main (main) where 5 | 6 | import Text.Blaze.Html5 ((!), ToMarkup(..)) 7 | import qualified Text.Blaze.Html5 as H 8 | import qualified Text.Blaze.Html5.Attributes as A 9 | import Data.String 10 | import Data.Text (Text) 11 | import qualified Data.Text as Text 12 | import Control.Monad.State 13 | import Control.Monad.Reader 14 | import Control.Lens 15 | import Myxine 16 | 17 | data Priority = Low | Medium | High 18 | 19 | data Task = Task { _priority :: Priority, _name :: Text, _completed :: Bool } 20 | 21 | $(makeLenses ''Task) 22 | 23 | instance ToMarkup Priority where 24 | toMarkup = \case 25 | Low -> H.span ! A.style "color: blue" $ "!" 26 | Medium -> H.span ! A.style "color: goldenrod" $ "!!" 27 | High -> H.span ! A.style "color: red" $ "!!!" 28 | 29 | todoList :: WithinPage => Reactive [Task] 30 | todoList = H.div ! A.style "padding: 20pt; padding-left: 40pt" @@ do 31 | markup $ H.h3 "To do:" 32 | H.button ! A.style "margin-left: 2em" @@ do 33 | markup $ H.span ! A.style "font-size: 12pt" $ "Add Task" 34 | on Click \_ -> 35 | modify (Task { _priority = Low, _name = "", _completed = False } :) 36 | H.ul ! A.style "list-style-type: none;" @@ each ## do 37 | done <- view completed 38 | when (not done) $ H.li @@ taskItem 39 | markup $ H.h3 "Completed:" 40 | H.ul ! A.style "list-style-type: none;" @@ each ## do 41 | done <- view completed 42 | when done $ H.li @@ taskItem 43 | 44 | taskItem :: WithinPage => Reactive Task 45 | taskItem = H.div @@ do 46 | completed ## checkBox 47 | name ## H.span ! A.style "margin-left: 3pt" @@ textInputBox 48 | priority ## H.span ! A.style "font-size: 14pt" @@ priorityToggle 49 | 50 | checkBox :: Reactive Bool 51 | checkBox = 52 | H.span ! A.style "font-weight: bold; cursor: pointer; font-size: 14pt" @@ do 53 | checked <- ask 54 | if checked then "☑" else "☐" 55 | on Click \_ -> modify not 56 | 57 | textInputBox :: WithinPage => Reactive Text 58 | textInputBox = target do 59 | currentValue <- asks (fromString . Text.unpack) 60 | markup $ H.input ! A.value currentValue 61 | e <- this 62 | on Input \_ -> do 63 | value <- eval $ e <> ".value" 64 | put value 65 | 66 | priorityToggle :: Reactive Priority 67 | priorityToggle = do 68 | H.span ! A.style "font-weight: bold; cursor: pointer; padding: 6pt" @@ do 69 | markup =<< ask 70 | on Click \_ -> 71 | modify \case 72 | Low -> Medium 73 | Medium -> High 74 | High -> Low 75 | 76 | main :: IO () 77 | main = do 78 | page <- runPage mempty (pure []) (reactive todoList) 79 | _ <- waitPage page 80 | pure () 81 | -------------------------------------------------------------------------------- /examples/haskell/Toggles.hs: -------------------------------------------------------------------------------- 1 | {-# language OverloadedStrings, DuplicateRecordFields, NamedFieldPuns #-} 2 | {-# language BlockArguments, RecordWildCards, TemplateHaskell #-} 3 | 4 | module Main (main) where 5 | 6 | import Text.Blaze.Html5 ((!)) 7 | import qualified Text.Blaze.Html5 as H 8 | import qualified Text.Blaze.Html5.Attributes as A 9 | import Data.String 10 | import Control.Monad.IO.Class 11 | import Control.Monad.State 12 | import Control.Monad.Reader 13 | import Data.Foldable 14 | import Control.Lens 15 | import Myxine 16 | 17 | import qualified Data.Sequence as Seq 18 | 19 | main :: IO () 20 | main = do 21 | page <- runPage mempty (pure (Seq.empty)) (reactive toggles) 22 | print =<< waitPage page 23 | 24 | toggle :: Reactive Bool 25 | toggle = do 26 | active <- ask 27 | H.button ! A.style (if active then "background: green; color: white" 28 | else "background: white") 29 | @@ do markup $ if active then H.span "ON" else H.span "OFF" 30 | on Click \_ -> modify not 31 | 32 | toggles :: Reactive (Seq.Seq Bool) 33 | toggles = do 34 | H.span @@ do 35 | H.button ! A.style "background: white; font-size: 20pt" @@ do 36 | "+" 37 | on Click \_ -> modify (Seq.|> False) 38 | H.button ! A.style "background: white; font-size: 20pt" @@ do 39 | "-" 40 | on Click \_ -> modify \case 41 | Seq.Empty -> Seq.Empty 42 | i Seq.:|> _ -> i 43 | H.div @@ do 44 | each ## toggle 45 | -------------------------------------------------------------------------------- /examples/haskell/circles.hs: -------------------------------------------------------------------------------- 1 | {-# language OverloadedStrings, DuplicateRecordFields, NamedFieldPuns #-} 2 | {-# language BlockArguments, RecordWildCards, TemplateHaskell #-} 3 | module Main (main) where 4 | 5 | -- Generally helpful: 6 | import Prelude hiding (div, span) 7 | import Text.Blaze.Html5 hiding (main, style, title, map, html) 8 | import Text.Blaze.Html5.Attributes hiding (span, title) 9 | import Data.String 10 | import Control.Monad.IO.Class 11 | import Control.Monad.State 12 | import Control.Monad.Reader 13 | import Control.Lens 14 | 15 | -- Myxine itself: 16 | import Myxine 17 | 18 | -- Specific to this application: 19 | import Data.Word 20 | import Data.UUID (UUID) 21 | import Data.Map (Map) 22 | import qualified Data.Map as Map 23 | import System.Random (randomIO) 24 | import Data.List (intercalate) 25 | import System.Exit 26 | 27 | data Circle = Circle 28 | { _identity :: UUID 29 | , _x :: Double 30 | , _y :: Double 31 | , _z :: Int 32 | , _r :: Double 33 | , _hue :: Word8 34 | } deriving (Eq, Ord, Show) 35 | $(makeLenses ''Circle) 36 | 37 | data Circles = Circles 38 | { _current :: Maybe Circle 39 | , _rest :: Map UUID Circle 40 | } deriving (Eq, Ord, Show) 41 | $(makeLenses ''Circles) 42 | 43 | main :: IO () 44 | main = do 45 | page <- runPage mempty 46 | (pure Circles { _current = Nothing, _rest = mempty }) 47 | (reactive allCircles) 48 | print =<< waitPage page 49 | 50 | allCircles :: Reactive Circles 51 | allCircles = do 52 | circles <- ask 53 | title $ "Circles: " <> fromString (show (count circles)) 54 | if circles^.current == Nothing && null (circles^.rest) 55 | then 56 | markup $ 57 | div ! styles canvasStyles $ 58 | span ! styles greetingStyles $ 59 | "Click and drag to make art!" 60 | else 61 | div ! styles canvasStyles @@ 62 | maybe mempty drawCircle (circles^.current) <> 63 | mconcat (reverse (map drawCircle (Map.elems (circles^.rest)))) 64 | on MouseDown \MouseEvent{clientX, clientY, shiftKey = False} -> 65 | do randomHue <- liftIO randomIO 66 | randomUUID <- liftIO randomIO 67 | current .= Just (Circle randomUUID clientX clientY (count circles) 0 randomHue) 68 | on MouseMove \MouseEvent{clientX, clientY, buttons = 1} -> 69 | zoom (current._Just) do 70 | circle <- get 71 | r .= sqrt ((circle^.x - clientX)**2 + (circle^.y - clientY)**2) 72 | on MouseUp \MouseEvent{} -> do 73 | use current >>= \case 74 | Nothing -> pure () 75 | Just circle -> 76 | do current .= Nothing 77 | rest %= Map.insert (circle^.identity) circle 78 | on KeyUp \KeyboardEvent 79 | { key = "c" 80 | , ctrlKey = True 81 | , shiftKey = False 82 | , altKey = False 83 | , metaKey = False 84 | } -> liftIO exitFailure -- end the program on Ctrl-C in the browser too! 85 | where 86 | count circles = 87 | maybe 0 (const 1) (circles^.current) + length (circles^.rest) 88 | canvasStyles = 89 | [ ("position", "relative") 90 | , ("padding", "0px") 91 | , ("height", "100vh") 92 | , ("width", "100vw") 93 | , ("overflow", "hidden") 94 | , ("background", "black") ] 95 | greetingStyles = 96 | [ ("transform", "translate(-50%, -100%)") 97 | , ("text-align", "center") 98 | , ("select", "none") 99 | , ("width", "100vw") 100 | , ("position", "absolute") 101 | , ("top", "50%") 102 | , ("left", "50%") 103 | , ("font-family", "Helvetica Neue") 104 | , ("font-size", "50pt") 105 | , ("color", "darkgrey") ] 106 | 107 | drawCircle :: Circle -> Reactive Circles 108 | drawCircle circle = 109 | div ! styles circleStyles @@ do 110 | on MouseOver \MouseEvent{buttons = 0} -> 111 | zoom (rest . at (circle^.identity) . _Just) do 112 | randomHue <- liftIO randomIO 113 | hue .= randomHue 114 | on' MouseDown \MouseEvent{shiftKey = True} -> 115 | do rest %= fmap \c -> 116 | if c^.identity == circle^.identity 117 | then c & z .~ 0 118 | else c & z +~ 1 119 | pure Stop 120 | where 121 | radius = fromIntegral (round (circle^.r) :: Int) 122 | diameter = radius * 2 123 | borderWidth = 3 124 | background = "hsla(" <> show (circle^.hue) <> ", 100%, 70%, 55%)" 125 | borderColor = "hsla(" <> show (circle^.hue) <> ", 50%, 50%, 100%)" 126 | circleStyles = 127 | [ ("position", "absolute") 128 | , ("top", show (circle^.y - radius - borderWidth/2) <> "px") 129 | , ("left", show (circle^.x - radius - borderWidth/2) <> "px") 130 | , ("width", show diameter <> "px") 131 | , ("height", show diameter <> "px") 132 | , ("z-index", show (circle^.z)) 133 | , ("background", background) 134 | , ("border", show borderWidth <> "px solid " <> borderColor) 135 | , ("border-radius", show radius <> "px") 136 | , ("box-shadow", "0px 0px 25px " <> borderColor) 137 | ] 138 | 139 | styles :: [(String, String)] -> Attribute 140 | styles pairs = 141 | style . fromString $ 142 | intercalate "; " $ (\(attr, val) -> attr <> ": " <> val) <$> pairs 143 | -------------------------------------------------------------------------------- /examples/haskell/myxine-client-examples.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: myxine-client-examples 3 | version: 0.1.0.0 4 | synopsis: Examples of use for the Haskell client to the Myxine UI server 5 | homepage: https://github.com/kwf/myxine 6 | bug-reports: https://github.com/kwf/myxine/issues/new 7 | license: MIT 8 | license-file: LICENSE 9 | author: Kenny Foner 10 | maintainer: kwf@very.science 11 | copyright: Copyright (c) 2020 Kenny Foner and Galois, Inc. 12 | category: GUI 13 | extra-source-files: CHANGELOG.md 14 | description: 15 | This package is a collection of examples of using the Haskell language 16 | bindings to the Myxine GUI server. For more information on Myxine itself, see 17 | [its homepage](https://github.com/kwf/myxine). To read the library 18 | documentation for the Haskell bindings used here, see [the documentation on 19 | Hackage](https://hackage.haskell.org/package/myxine-client). 20 | 21 | common options 22 | default-language: Haskell2010 23 | ghc-options: -Wall 24 | -Wincomplete-record-updates 25 | -Wcompat 26 | -Widentities 27 | -Wredundant-constraints 28 | -fhide-source-paths 29 | -Wpartial-fields 30 | default-extensions: BlockArguments, 31 | DataKinds, 32 | DeriveAnyClass, 33 | DeriveGeneric, 34 | DerivingStrategies, 35 | DerivingVia, 36 | DuplicateRecordFields, 37 | RecordWildCards, 38 | EmptyCase, 39 | GADTs, 40 | GeneralizedNewtypeDeriving, 41 | KindSignatures, 42 | LambdaCase, 43 | NamedFieldPuns, 44 | OverloadedStrings, 45 | RankNTypes, 46 | ScopedTypeVariables, 47 | StandaloneDeriving, 48 | TemplateHaskell, 49 | TupleSections, 50 | TypeApplications, 51 | ViewPatterns 52 | 53 | common deps 54 | build-depends: base >= 4.12.0.0 && <= 4.14.0.0, 55 | random ^>= 1.1, 56 | text ^>= 1.2, 57 | blaze-markup ^>= 0.8, 58 | blaze-html ^>= 0.9, 59 | lens ^>= 4.19, 60 | mtl ^>= 2.2, 61 | uuid ^>= 1.3, 62 | containers ^>= 0.6, 63 | myxine-client == 0.0.1.0 64 | 65 | executable circles 66 | import: options, deps 67 | main-is: Circles.hs 68 | 69 | executable counter 70 | import: options, deps 71 | main-is: Counter.hs 72 | 73 | executable toggles 74 | import: options, deps 75 | main-is: Toggles.hs 76 | 77 | executable todo 78 | import: options, deps 79 | main-is: Todo.hs 80 | -------------------------------------------------------------------------------- /examples/python/angles.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | from math import * 3 | import myxine 4 | 5 | class Page: 6 | # Keep track of where the path is 7 | def __init__(self, path): 8 | self.path = path 9 | self.w, self.h = 1, 1 10 | self.x, self.y = 0.5, 0.5 - 0.000001 11 | myxine.update(self.path, self.draw()) 12 | self.w, self.h = None, None 13 | 14 | def update(self, event): 15 | if event.event() == 'mousemove': 16 | self.x = event.clientX 17 | self.y = event.clientY 18 | if self.w is None or self.h is None: 19 | self.resize() 20 | myxine.update(self.path, self.draw()) 21 | elif event.event() == 'resize': 22 | self.resize() 23 | myxine.update(self.path, self.draw()) 24 | 25 | def draw(self): 26 | angle = degrees(atan2(self.y - self.h/2, 27 | self.x - self.w/2)) + 90 28 | if angle < 0: angle = angle + 360 29 | ratio_from_edge = \ 30 | 1 - (abs(self.y - self.h/2) + 31 | abs(self.x - self.w/2)) / (self.h/2 + self.w/2) 32 | saturation = 100 * ratio_from_edge 33 | lightness = 100 - 50 * ratio_from_edge 34 | 35 | container_style = f''' 36 | background: hsl({round(angle)}, {round(saturation)}%, {round(lightness)}%); 37 | overflow: hidden; 38 | margin: 0px; 39 | padding: 0px; 40 | height: 100vh; 41 | width: 100vw; 42 | text-align: center; 43 | position: relative; 44 | ''' 45 | span_style = f''' 46 | transform: translate(-50%, -50%) rotate({round(angle, 2)}deg); 47 | position: absolute; 48 | top: 50%; 49 | font-family: Helvetica Neue; 50 | font-weight: 200; 51 | font-size: 250pt; 52 | color: white; 53 | background: rgba(0, 0, 0, 0.4); 54 | border-radius: {300 * ratio_from_edge}pt; 55 | border: none; 56 | padding: 100pt; 57 | width: 550pt; 58 | text-shadow: 0 0 25pt black; 59 | ''' 60 | html = f''' 61 |
62 | 63 | {round(angle)}° 64 | 65 |
''' 66 | return html 67 | 68 | def resize(self): 69 | self.w, self.h = \ 70 | myxine.evaluate(self.path, expression='[window.innerWidth, window.innerHeight]') 71 | 72 | def run(self): 73 | for event in myxine.events(self.path): 74 | self.update(event) 75 | 76 | def main(): 77 | try: 78 | path = '/' 79 | print('Running at:', myxine.page_url(path)) 80 | Page(path).run() 81 | except KeyboardInterrupt: pass 82 | 83 | if __name__ == '__main__': main() 84 | -------------------------------------------------------------------------------- /examples/python/circles.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | from math import * 3 | from uuid import * 4 | import random 5 | import myxine 6 | 7 | class Circle: 8 | def __init__(self, *, x, y, r): 9 | self.hue = round(random.uniform(0, 360)) # random hue 10 | self.x = x # x-coordinate for origin 11 | self.y = y # y-coordinate for origin 12 | self.r = r # radius of circle 13 | 14 | def draw(self, current=False): 15 | border_width = 2 16 | radius = round(self.r) 17 | diameter = radius * 2 18 | return f'''
''' 26 | 27 | class State: 28 | current = None # The currently-in-progress circle, if any 29 | rest = [] # The already-drawn circles 30 | (x, y) = (0, 0) # The current mouse location 31 | 32 | def update(self, event): 33 | if event.event() == 'mousedown': 34 | if self.current is not None: 35 | self.rest.append(self.current) 36 | self.current = Circle(x = self.x, y = self.y, r = 0) 37 | elif event.event() == 'mouseup': 38 | if self.current is not None: 39 | self.rest.append(self.current) 40 | self.current = None 41 | elif event.event() == 'mousemove': 42 | self.x = event.clientX 43 | self.y = event.clientY 44 | if self.current is not None: 45 | self.current.r = sqrt((self.x - self.current.x)**2 + 46 | (self.y - self.current.y)**2) 47 | 48 | def draw(self): 49 | circles = [] 50 | for circle in self.rest: 51 | circles.append(circle.draw()) 52 | if self.current is not None: 53 | circles.append(self.current.draw(current=True)) 54 | if circles != []: 55 | content = ''.join(circles) 56 | else: 57 | content = ''' 63 | Click & drag to make art! 64 | ''' 65 | return f'''
{content}
''' 68 | 69 | def main(): 70 | try: 71 | path = '/' 72 | print('Running at:', myxine.page_url(path)) 73 | 74 | # Make a new state object 75 | state = State() 76 | 77 | # Draw the page for the first time 78 | myxine.update(path, state.draw()) 79 | 80 | # Iterate over all page events, updating the page each time 81 | for event in myxine.events(path): 82 | state.update(event) 83 | myxine.update(path, state.draw()) 84 | 85 | # You can kill the program with a keyboard interrupt 86 | except KeyboardInterrupt: pass 87 | except Exception as e: print('Exception: ', e) 88 | 89 | if __name__ == '__main__': main() 90 | -------------------------------------------------------------------------------- /examples/python/follow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import random 4 | import myxine 5 | 6 | class Page: 7 | # The model of the page 8 | def __init__(self): 9 | self.x, self.y = 150, 150 10 | self.hue = random.uniform(0, 360) 11 | self.radius = 75 12 | 13 | # Draw the page's model as a fragment of HTML 14 | def draw(self): 15 | circle_style = f''' 16 | position: absolute; 17 | height: {round(self.radius*2)}px; 18 | width: {round(self.radius*2)}px; 19 | top: {self.y}px; 20 | left: {self.x}px; 21 | transform: translate(-50%, -50%); 22 | border-radius: 50%; 23 | border: {round(self.radius/2)}px solid hsl({round(self.hue)}, 80%, 80%); 24 | background: hsl({round(self.hue+120)}, 80%, 75%) 25 | ''' 26 | background_style = f''' 27 | position: absolute; 28 | overflow: hidden; 29 | width: 100vw; 30 | height: 100vh; 31 | background: hsl({round(self.hue-120)}, 80%, 90%); 32 | ''' 33 | instructions_style = f''' 34 | position: absolute; 35 | bottom: 30px; 36 | left: 30px; 37 | font-family: sans-serif; 38 | font-size: 22pt; 39 | user-select: none; 40 | ''' 41 | return f''' 42 |
43 |
44 | Move the mouse to move the circle
45 | Scroll to change the circle's size
46 | Ctrl + Scroll to change the color scheme
47 | Click to randomize the color scheme
48 |
49 |
50 |
51 | ''' 52 | 53 | # Change the page's model in response to a browser event 54 | def react(self, event): 55 | if event.event() == 'mousemove': 56 | self.x = event.clientX 57 | self.y = event.clientY 58 | elif event.event() == 'mousedown': 59 | self.hue = (self.hue + random.uniform(30, 330)) % 360 60 | elif event.event() == 'wheel': 61 | if event.ctrlKey: 62 | self.hue = (self.hue + event.deltaY * -0.1) % 360 63 | else: 64 | self.radius += event.deltaY * -0.2 65 | self.radius = min(max(self.radius, 12), 1000) 66 | 67 | # The page's event loop 68 | def run(self, path): 69 | myxine.update(path, self.draw()) # Draw the page in the browser. 70 | try: 71 | for event in myxine.events(path): # For each browser event, 72 | self.react(event) # update our model of the page, 73 | myxine.update(path, self.draw()) # then re-draw it in the browser. 74 | except KeyboardInterrupt: 75 | pass # Press Ctrl-C to quit. 76 | 77 | if __name__ == '__main__': 78 | Page().run('/') # Run the page on the root path. 79 | -------------------------------------------------------------------------------- /examples/rust/angles.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use reqwest; 3 | use serde_json::{json, Value}; 4 | use futures::{Stream, StreamExt}; 5 | use bytes::{Bytes, Buf}; 6 | use std::marker::Unpin; 7 | 8 | /// A stream of `Bytes` chunks paired with a buffer, to allow us to read from it 9 | /// in a line-oriented way rather than in chunks. 10 | #[derive(Debug)] 11 | pub struct BufByteStream>> { 12 | stream: S, 13 | buffer: Bytes, 14 | } 15 | 16 | impl>> BufByteStream { 17 | /// Wrap a stream of chunks or errors (as, for example, would be returned 18 | /// from `reqwest::Response::bytes_stream`) in a buffered async reader. 19 | pub fn from_result_stream(stream: S) -> BufByteStream { 20 | BufByteStream{stream, buffer: Bytes::new()} 21 | } 22 | 23 | /// Return a `Vec` of bytes representing all bytes in this reader up to *and 24 | /// including* the specified "stop" byte. 25 | async fn read_until(&mut self, stop: u8) -> Vec { 26 | let mut result = Vec::new(); 27 | loop { 28 | let mut index: usize = 0; 29 | for byte in self.buffer.iter() { 30 | index += 1; 31 | result.push(*byte); 32 | if *byte == stop { 33 | self.buffer.advance(index); 34 | return result; 35 | } 36 | } 37 | if let Some(Ok(chunk)) = self.stream.next().await { 38 | self.buffer = chunk; 39 | } else { 40 | return result; 41 | } 42 | } 43 | } 44 | 45 | /// Read a line of the wrapped stream, up to *and including* the newline 46 | /// character that terminated the line (or none if it reached the end of the 47 | /// stream without a newline). 48 | async fn read_line(&mut self) -> Vec { 49 | self.read_until(b'\n').await 50 | } 51 | } 52 | 53 | /// A UI event, as sent from the server, contains an optional `id`, `event` 54 | /// type, and some `data` which in our case always comes in the form of a map 55 | /// from string keys to unknown JSON `Value`s. 56 | #[derive(Debug, Clone)] 57 | pub struct Event { 58 | pub id: Option, 59 | pub event: Option, 60 | pub data: HashMap, 61 | } 62 | 63 | impl Event { 64 | /// Make a new, empty event. 65 | pub fn new() -> Event { 66 | Event { 67 | id: None, 68 | event: None, 69 | data: HashMap::new(), 70 | } 71 | } 72 | } 73 | 74 | /// A stream of `Event`s read from the network. 75 | #[derive(Debug)] 76 | pub struct EventStream>>( 77 | BufByteStream 78 | ); 79 | 80 | /// The possible errors that could occur when decoding an event from a stream. 81 | #[derive(Debug)] 82 | pub enum EventStreamError { 83 | EndOfStream, 84 | BadStreamFormat, 85 | BadEncoding(std::str::Utf8Error), 86 | BadData(serde_json::error::Error, Vec), 87 | } 88 | 89 | impl>> EventStream { 90 | 91 | /// Return the next event in the stream, or an error if the stream 92 | /// terminates or is malformed. 93 | pub async fn next(&mut self) -> Result { 94 | let mut event_id: Option> = None; 95 | let mut event_type: Option> = None; 96 | let mut raw_event_data = Vec::new(); 97 | let mut started = false; 98 | loop { 99 | let line = self.0.read_line().await; 100 | match line.as_slice() { 101 | b"" => return Err(EventStreamError::EndOfStream), 102 | b":\n" => { /* heartbeat message */ }, 103 | b"\n" => 104 | if started { 105 | let event = Event { 106 | id: event_id.map(|id| { 107 | String::from_utf8_lossy(&id).trim().to_string() 108 | }), 109 | event: event_type.map(|event| { 110 | String::from_utf8_lossy(&event).trim().to_string() 111 | }), 112 | data: match serde_json::from_slice(&raw_event_data) { 113 | Ok(data) => data, 114 | Err(err) => return Err(EventStreamError::BadData(err, raw_event_data)), 115 | }, 116 | }; 117 | return Ok(event) 118 | }, 119 | line => { 120 | started = true; 121 | match line.splitn(2, |c| *c == b':').collect::>().as_slice() { 122 | [b"id", id] => { 123 | if event_id.is_some() { 124 | return Err(EventStreamError::BadStreamFormat); 125 | } else { 126 | event_id = Some(id.to_vec()); 127 | } 128 | }, 129 | [b"event", event] => { 130 | if event_type.is_some() { 131 | return Err(EventStreamError::BadStreamFormat); 132 | } else { 133 | event_type = Some(event.to_vec()); 134 | } 135 | }, 136 | [b"data", data] => { 137 | raw_event_data.extend_from_slice(data); 138 | }, 139 | _ => return Err(EventStreamError::BadStreamFormat), 140 | } 141 | }, 142 | } 143 | } 144 | } 145 | } 146 | 147 | #[tokio::main] 148 | async fn main() { 149 | let client = reqwest::Client::new(); 150 | let subscription = 151 | json!({ 152 | "#container": { 153 | "mousemove": [ 154 | ".clientX", 155 | ".clientY", 156 | "window.innerHeight", 157 | "window.innerWidth", 158 | ], 159 | }, 160 | "window": { 161 | "resize": [ 162 | "window.innerHeight", 163 | "window.innerWidth", 164 | ], 165 | }, 166 | }); 167 | 168 | let draw_body = |mouse: &Option<(u64, u64)>, window: &Option<(u64, u64)>| -> String { 169 | let mut angle; 170 | let saturation; 171 | let lightness; 172 | if let (Some((x, y)), Some((w, h))) = (mouse, window) { 173 | let x = *x as f64; 174 | let y = *y as f64; 175 | let w = *w as f64; 176 | let h = *h as f64; 177 | angle = (y - h / 2_f64).atan2(x - w / 2_f64).to_degrees() + 90.0; 178 | if angle < 0.0 { angle = angle + 360.0 } 179 | let ratio_from_edge = 1.0 - 180 | ((y - h / 2_f64).abs() + (x - w / 2_f64).abs()) 181 | / (h / 2_f64 + w / 2_f64); 182 | saturation = 100.0 * ratio_from_edge; 183 | lightness = 100.0 - 50.0 * ratio_from_edge; 184 | } else { 185 | angle = 0_f64; 186 | saturation = 100_f64; 187 | lightness = 50.0; 188 | } 189 | format!("
\ 193 | {angle}°
", 199 | angle = angle as i64, 200 | saturation = saturation, 201 | lightness = lightness) 202 | }; 203 | 204 | if let Ok(response) = 205 | client.post("http://localhost:1123/?subscribe") 206 | .body(subscription.to_string()).send().await { 207 | let mut events = 208 | EventStream(BufByteStream::from_result_stream(response.bytes_stream())); 209 | 210 | // The main event loop 211 | let mut mouse_location: Option<(u64, u64)> = None; 212 | let mut window_size: Option<(u64, u64)> = None; 213 | loop { 214 | if let Err(err) = 215 | client.post("http://localhost:1123/") 216 | .body(draw_body(&mouse_location, &window_size)) 217 | .send().await { 218 | println!("{:?}", err); 219 | break; 220 | } 221 | let event = match events.next().await { 222 | Ok(event) => event, 223 | Err(err) => { 224 | println!("{:?}", err); 225 | break; 226 | } 227 | }; 228 | // Dispatch on events to update the model 229 | if let Some(id) = event.id { 230 | match id.as_str() { 231 | "#container" => { 232 | mouse_location = 233 | Some((event.data.get(".clientX").unwrap().as_u64().unwrap(), 234 | event.data.get(".clientY").unwrap().as_u64().unwrap())); 235 | window_size = 236 | Some((event.data.get("window.innerWidth").unwrap().as_u64().unwrap(), 237 | event.data.get("window.innerHeight").unwrap().as_u64().unwrap())); 238 | }, 239 | "window" => { 240 | window_size = 241 | Some((event.data.get("window.innerWidth").unwrap().as_u64().unwrap(), 242 | event.data.get("window.innerHeight").unwrap().as_u64().unwrap())); 243 | }, 244 | _ => { }, 245 | } 246 | } 247 | } 248 | 249 | } else { 250 | println!("Couldn't connect to myxine server."); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /examples/rust/spin.rs: -------------------------------------------------------------------------------- 1 | use reqwest; 2 | use std::time::{Instant, Duration}; 3 | use tokio::time; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | let client = reqwest::Client::new(); 8 | let mut interval = time::interval(Duration::from_millis(7)); 9 | let mut ok = true; 10 | while ok { 11 | let start = Instant::now(); 12 | for angle in 0_usize .. 360 { 13 | interval.tick().await; 14 | let body = format!( 15 | r#"
18 | {angle}°
"#, 24 | angle = angle); 25 | let result = 26 | client.post("http://localhost:1123").body(body).send().await; 27 | if let Err(e) = result { 28 | eprintln!("{}", e); 29 | ok = false; 30 | break; 31 | } 32 | } 33 | let elapsed = start.elapsed(); 34 | println!("{:.1} fps", 360.0 / elapsed.as_secs_f32()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /images/eptatretus_polytrema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaidfinch/myxine/3f57767ee3059400eea5fd890f5d1b0b388a0632/images/eptatretus_polytrema.jpg -------------------------------------------------------------------------------- /images/myxine_glutinosa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaidfinch/myxine/3f57767ee3059400eea5fd890f5d1b0b388a0632/images/myxine_glutinosa.png -------------------------------------------------------------------------------- /scripts/generate-event-enum.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | from typing import * 6 | 7 | def generate_rust(events): 8 | # Generate names of variants 9 | variants = [] 10 | for name_words, properties in events: 11 | name = ''.join(word.title() for word in name_words) 12 | variants.append((name, properties)) 13 | # Actually output the text 14 | lines = [] 15 | lines.append('#[non_exhaustive]') 16 | lines.append('#[derive(Clone, Debug, Serialize, Deserialize)]') 17 | lines.append('#[serde(rename_all = "lowercase", tag = "event", content = "properties")]') 18 | lines.append('enum Event {') 19 | for name, properties in variants: 20 | lines.append(' #[non_exhaustive]') 21 | lines.append(' ' + name + ' { ') 22 | items = list(properties.items()) 23 | items.sort() 24 | for field, type in items: 25 | lines.append(' ' + field + ': ' + type + ',') 26 | lines.append(' },') 27 | lines.append('}') 28 | return '\n'.join(lines) 29 | 30 | languages = { 31 | 'rust': generate_rust 32 | } 33 | 34 | def main(): 35 | try: _, language, filename = sys.argv 36 | except: print("Wrong number of arguments: please specify output language and interface definition JSON file.", file=sys.stderr) 37 | 38 | try: generate = languages[language] 39 | except: print("Invalid language: " + language, file=sys.stderr) 40 | 41 | try: 42 | with open(filename) as x: spec = json.loads(x.read()) 43 | except: print("Couldn't open file: " + filename, file=sys.stderr) 44 | spec_events = spec['events'] 45 | spec_interfaces = spec['interfaces'] 46 | events = [] 47 | for event, event_info in spec_events.items(): 48 | interface = event_info['interface'] 49 | name_words = event_info['nameWords'] 50 | fields = accum_fields(interface, spec_interfaces) 51 | events.append((name_words, fields)) 52 | print(generate(events)) 53 | 54 | # Accumulate all the fields in all super-interfaces of the given interface 55 | def accum_fields(interface, interfaces): 56 | properties = {} 57 | while True: 58 | for property, type in interfaces[interface]['properties'].items(): 59 | if properties.get(property) is None: 60 | properties[property] = type 61 | if interfaces[interface]['inherits'] is None: break 62 | else: interface = interfaces[interface]['inherits'] 63 | return properties 64 | 65 | if __name__ == '__main__': main() 66 | -------------------------------------------------------------------------------- /scripts/generate-event-map.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import html 6 | from typing import * 7 | 8 | graph_parameters = [ 9 | 'rankdir="LR";', 10 | 'splines=false;', 11 | 'node [shape=none, fontname="Courier", fontsize="18pt", overlap=false, penwidth=2];', 12 | 'edge [arrowtail=none, dir=both, overlap=false, concentrate=true, penwidth=2];', 13 | 'outputorder="edgesfirst";', 14 | 'target="_blank"', 15 | ] 16 | 17 | interface_parameters = [ 18 | 'node [fontcolor="blue", fontsize="20pt", margin="-1"];' 19 | ] 20 | 21 | event_parameters = [ 22 | 'node [shape=ellipse, fontcolor="blue", style="filled"];' 23 | ] 24 | 25 | def generate_node(name, attributes, *, quotes=True): 26 | attr_list = [] 27 | if quotes: quote = '"' 28 | else: quote = '' 29 | for attr, value in attributes.items(): 30 | attr_list.append(attr + '=' + quote + value + quote) 31 | return '"' + name + '" [' + ', '.join(attr_list) + '];' 32 | 33 | def generate_edge(start, end, *, start_port=None, end_port=None): 34 | if start_port is not None: start_port = ':' + start_port 35 | else: start_port = '' 36 | if end_port is not None: end_port = ':' + end_port 37 | else: end_port = '' 38 | return '"' + start + '"' + start_port + ' -> "' + end + '"' + end_port + ';' 39 | 40 | def get_interface_levels(events, interfaces): 41 | heights = {} 42 | for interface in interfaces.keys(): 43 | heights[interface] = 1 44 | for event, event_info in events.items(): 45 | interface = event_info['interface'] 46 | height = 1 47 | while True: 48 | heights[interface] = max(heights[interface], height) 49 | height += 1 50 | interface = interfaces[interface].get('inherits') 51 | if interface is None: break 52 | levels = {} 53 | for interface, height in heights.items(): 54 | if levels.get(height) is None: 55 | levels[height] = [] 56 | levels[height].append(interface) 57 | return levels 58 | 59 | def generate(events, interfaces): 60 | # Figure out the groupings of interfaces by height 61 | interface_levels = get_interface_levels(events, interfaces) 62 | 63 | # Sort the events by interface and then by name 64 | events = sorted(events.items(), key=lambda e: (e[1]['interface'], e[0])) 65 | 66 | lines = ['digraph {'] 67 | lines.extend(' ' + s for s in graph_parameters) 68 | 69 | # The interface nodes 70 | for _, same_rank_interfaces in sorted(interface_levels.items(), reverse=True): 71 | lines.append(' {') 72 | lines.append(' rank=same;') 73 | lines.extend(' ' + s for s in interface_parameters) 74 | for interface in same_rank_interfaces: 75 | interface_info = interfaces[interface] 76 | docs = "https://developer.mozilla.org/docs/Web/API/" + interface 77 | properties = ''.join('' + \ 78 | f'' + \ 79 | property + ':' + \ 80 | '' + \ 81 | html.escape(type) + '' + \ 82 | '' \ 83 | for property, type in interface_info['properties'].items()) 84 | label = f'<' \ 85 | + f'' \ 86 | + properties \ 87 | + '
{interface}
>' 88 | attributes = {'label': label} 89 | lines.append(' ' + generate_node(interface, attributes, quotes=False)) 90 | lines.append(' }') 91 | 92 | # The event nodes 93 | lines.append(' {') 94 | lines.extend(' ' + s for s in event_parameters) 95 | lines.append(' rank=same;') 96 | for event, event_info in events: 97 | if event_info['bubbles']: 98 | attrs = {'fillcolor': 'lightgreen'} 99 | else: 100 | attrs = {'fillcolor': 'yellow'} 101 | attrs['href'] = "https://developer.mozilla.org/docs/Web/Events/" + event 102 | lines.append(' ' + generate_node(event, attrs)) 103 | lines.append(' }') 104 | 105 | # The interface inheritance edges 106 | for interface, interface_info in interfaces.items(): 107 | inherits = interface_info['inherits'] 108 | if inherits is not None: 109 | lines.append(' ' + generate_edge(inherits, interface, 110 | start_port='interface:e', 111 | end_port='interface:w')) 112 | 113 | # The event inheritance edges 114 | for event, event_info in events: 115 | interface = event_info['interface'] 116 | if inherits is not None: 117 | lines.append(' ' + generate_edge(interface, event, start_port='interface:e')) 118 | 119 | lines.append('}') 120 | return '\n'.join(lines) 121 | 122 | def main(): 123 | try: _, filename = sys.argv 124 | except: print("Wrong number of arguments: please specify interface definition file.", 125 | file=sys.stderr) 126 | 127 | try: 128 | with open(filename) as x: spec = json.loads(x.read()) 129 | except: print("Couldn't open file: " + filename, file=sys.stderr) 130 | spec_events = spec['events'] 131 | spec_interfaces = spec['interfaces'] 132 | print(generate(spec_events, spec_interfaces)) 133 | 134 | if __name__ == '__main__': main() 135 | -------------------------------------------------------------------------------- /scripts/scrape-event-info.js: -------------------------------------------------------------------------------- 1 | // Run this script on https://developer.mozilla.org/en-US/docs/Web/Events and it 2 | // will generate a listing of which web standard events are documented to 3 | // bubble, and which are not. This listing is saved in bubbles.json. Note that 4 | // because this script uses async XMLHttpRequests, you need to manually wait for 5 | // the terminal reports of XHRs to quiesce before reading out the value of the 6 | // results and errors, since they're filled in asynchronously after the body of 7 | // the function is done running. 8 | 9 | function scrapeBubbling() { 10 | let results = {}; 11 | let errors = {}; 12 | Array.from(document.querySelectorAll('#Standard_events ~ p + div tr td:first-child a')) 13 | .map(a => { 14 | const request = new XMLHttpRequest(); 15 | request.addEventListener("load", () => { 16 | try { 17 | let bubbles = Array.from(request.responseXML.querySelectorAll('table.properties tr')) 18 | .filter(e => e.querySelector('th') !== null ? e.querySelector('th').innerText === 'Bubbles' : false)[0] 19 | .querySelector('td').innerText === 'Yes'; 20 | let interfaceLink = Array.from(request.responseXML.querySelectorAll('table.properties tr')) 21 | .filter(e => e.querySelector('th') !== null ? e.querySelector('th').innerText === 'Interface' : false)[0] 22 | .querySelector('td a'); 23 | console.log(a.innerText, bubbles); 24 | let item = results[a.innerText]; 25 | if (typeof item === 'undefined') { 26 | item = []; 27 | } 28 | item.push({ 29 | bubbles: bubbles, 30 | docs: a.href, 31 | interface: interfaceLink.innerText, 32 | interfaceDocs: interfaceLink.href, 33 | }); 34 | results[a.innerText] = item; 35 | } catch(err) { 36 | errors[a.href] = a.innerText; 37 | } 38 | }); 39 | request.open("GET", a.href); 40 | request.responseType = "document"; 41 | request.send(); 42 | }); 43 | return [results, errors]; 44 | } 45 | 46 | [r, e] = scrapeBubbling(); 47 | -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "myxine" 3 | version = "0.2.2" 4 | description = "Get a GUI fast in any language under the sea!" 5 | keywords = ["GUI", "web", "interface", "scripting", "tool"] 6 | categories = ["command-line-utilities", "graphics", "gui", "visualization", "web-programming"] 7 | authors = ["Kenny Foner "] 8 | edition = "2018" 9 | homepage = "https://github.com/kwf/myxine" 10 | readme = "README.md" 11 | license = "MIT" 12 | 13 | [badges] 14 | maintenance = { status = "actively-developed" } 15 | 16 | [dependencies] 17 | myxine-core = { path = "../core", version = "0.1.1" } 18 | warp = { version = "0.2" } 19 | hyper = "0.13" 20 | http = "0.2" 21 | futures = "0.3" 22 | tokio = { version = "0.2.22", features = ["full"] } 23 | structopt = "0.3" 24 | serde_json = "1.0" 25 | serde_urlencoded = "0.6" 26 | bytes = "0.5" 27 | lazy_static = "1.4" 28 | 29 | [[bin]] 30 | name = "myxine" 31 | -------------------------------------------------------------------------------- /server/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /server/src/connect.js: -------------------------------------------------------------------------------- 1 | let socket = new WebSocket("ws://" + location.host + location.pathname); 2 | socket.onmessage = message => postMessage(JSON.parse(message.data)); 3 | socket.onopen = () => postMessage(null); // Signal initialization success. 4 | onmessage = message => { 5 | try { 6 | socket.send(JSON.stringify(message.data)); 7 | } catch { 8 | // If the connection is dead, drop the event. 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/dynamic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /server/src/dynamic.js: -------------------------------------------------------------------------------- 1 | function myxine(enabledEvents) { 2 | // Print debug info if the user sets window.myxine = true 3 | window.myxine = { "debug": false }; 4 | function debug(...args) { 5 | if (window.myxine.debug === true) { 6 | console.log(...args); 7 | } 8 | } 9 | 10 | // Current animation frame callback ID, if any 11 | let animationId = null; 12 | 13 | // Set the body 14 | function setBodyTo(body) { 15 | // Cancel the previous animation frame, if any 16 | if (animationId !== null) { 17 | window.cancelAnimationFrame(animationId); 18 | } 19 | // Redraw the body before the next repaint (but not right now yet) 20 | animationId = window.requestAnimationFrame(timestamp => { 21 | window.diff.innerHTML(document.body, body); 22 | }); 23 | } 24 | 25 | // Evaluate a JavaScript expression in the global environment 26 | function evaluate(expression, statementMode) { 27 | if (!statementMode) { 28 | return Function("return (" + expression + ");")(); 29 | } else { 30 | return Function(expression)(); 31 | } 32 | } 33 | 34 | // Evaluate a JavaScript expression and return the result 35 | function evaluateAndRespond(statementMode, expression, id, worker) { 36 | debug("Evaluating expression '" + expression + "' as a" 37 | + (statementMode ? " statement" : "n expression")); 38 | try { 39 | let result = evaluate(expression, statementMode); 40 | if (typeof result === "undefined") { 41 | result = null; 42 | } 43 | debug("Sending back result response:", result); 44 | worker.postMessage({ 45 | type: "evalResult", 46 | id: id, 47 | result: { Ok: result } 48 | }) 49 | } catch(err) { 50 | debug("Sending back error response:", err); 51 | worker.postMessage({ 52 | type: "evalResult", 53 | id: id, 54 | result: { Err: err.toString() } 55 | }) 56 | } 57 | } 58 | 59 | // Functions from JavaScript objects to serializable objects, keyed by the 60 | // types of those objects as represented in the interface description 61 | const customJsonFormatters = { 62 | // Add here if there's a need to support more complex object types 63 | }; 64 | 65 | // Parse a description of events and interfaces, and return a mapping from 66 | // event name to mappings from property name -> formatter for that property 67 | function parseEventDescriptions(enabledEvents) { 68 | let events = {}; 69 | const allEvents = enabledEvents.events; 70 | Object.entries(allEvents).forEach(([eventName, eventInfo]) => { 71 | // Accumulate the desired fields for the event into a map from 72 | // field name to formatter for the objects in that field 73 | let interfaceName = eventInfo["interface"]; // most specific 74 | let theInterface = enabledEvents.interfaces[interfaceName]; 75 | events[eventName] = {}; 76 | while (true) { 77 | const properties = Object.keys(theInterface.properties); 78 | properties.forEach(property => { 79 | let formatter = customJsonFormatters[property]; 80 | if (typeof formatter === "undefined") { 81 | formatter = (x => x); // Default formatter is id 82 | } 83 | if (typeof events[eventName][property] === "undefined") { 84 | events[eventName][property] = formatter; 85 | } else { 86 | debug("Duplicate property in " 87 | + eventName 88 | + ": " 89 | + property); 90 | } 91 | }); 92 | if (theInterface.inherits !== null) { 93 | // Check ancestors for more fields to add 94 | theInterface = 95 | enabledEvents.interfaces[theInterface.inherits]; 96 | } else { 97 | break; // Top of interface hierarchy 98 | } 99 | } 100 | }); 101 | return events; 102 | } 103 | 104 | // We only should set up page event listeners once 105 | let pageEventListenersSet = false; 106 | 107 | // Set up listeners for all those events which send back the appropriately 108 | // formatted results when they fire 109 | function setupPageEventListeners(worker) { 110 | if (!pageEventListenersSet) { 111 | pageEventListenersSet = true; 112 | const descriptions = parseEventDescriptions(enabledEvents); 113 | const subscription = Object.keys(descriptions); 114 | // Set up event handlers 115 | subscription.forEach(eventName => { 116 | if (typeof descriptions[eventName] !== "undefined") { 117 | const listener = event => { 118 | // Calculate the id path 119 | const path = 120 | event.composedPath() 121 | .filter(t => t instanceof Element) 122 | .map(target => { 123 | const pathElement = { 124 | tagName: target.tagName.toLowerCase(), 125 | attributes: {}, 126 | }; 127 | const numAttrs = target.attributes.length; 128 | for (let i = numAttrs - 1; i >= 0; i--) { 129 | const attribute = target.attributes[i]; 130 | const name = attribute.name; 131 | const value = attribute.value; 132 | pathElement.attributes[name] = value; 133 | } 134 | return pathElement; 135 | }); 136 | 137 | // Extract the relevant properties 138 | const data = {}; 139 | Object.entries(descriptions[eventName]) 140 | .forEach(([property, formatter]) => { 141 | data[property] = formatter(event[property]); 142 | }); 143 | 144 | // Send the event to the server 145 | worker.postMessage({ 146 | type: "event", 147 | event: eventName, 148 | targets: path, 149 | properties: data, 150 | }); 151 | }; 152 | debug("Adding listener:", eventName); 153 | window.addEventListener(eventName, listener); 154 | } else { 155 | debug("Invalid event name:", eventName); 156 | } 157 | }); 158 | } 159 | } 160 | 161 | // The handlers for events coming from the server: 162 | function setupServerEventListeners(worker) { 163 | worker.onmessage = workerMessage => { 164 | setupPageEventListeners(worker); 165 | let message = workerMessage.data; 166 | if (message !== null) { // Worker will send null message to signal initialization. 167 | debug("Received message:", message); 168 | if (message.type === "reload") { 169 | window.location.reload(); 170 | } else if (message.type === "evaluate") { 171 | evaluateAndRespond(message.statementMode, message.script, message.id, worker); 172 | } else if (message.type === "update") { 173 | document.title = message.title; 174 | if (message.diff) { 175 | setBodyTo(message.body); 176 | } else { 177 | document.body.innerHTML = message.body; 178 | } 179 | } 180 | } 181 | } 182 | } 183 | 184 | // When things are loaded, activate the page. 185 | window.addEventListener("load", () => { 186 | let worker = new Worker(location.href + "?connect") 187 | setupServerEventListeners(worker); 188 | }); 189 | } 190 | -------------------------------------------------------------------------------- /server/src/enabled-events.json: -------------------------------------------------------------------------------- 1 | ../../enabled-events.json -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | use structopt::StructOpt; 2 | 3 | mod params; 4 | mod server; 5 | 6 | #[derive(Debug, StructOpt)] 7 | #[structopt( 8 | about = 9 | "A local web server to help you make interactive applications\n\ 10 | in your browser using any language under the sea!\n\ 11 | \n\ 12 | DOCUMENTATION:\ 13 | \n https://github.com/kwf/myxine")] 14 | struct Options { 15 | /// Run on this port 16 | #[structopt(short, long, default_value = "1123")] 17 | port: u16, 18 | } 19 | 20 | #[tokio::main] 21 | async fn main() { 22 | let options = Options::from_args(); 23 | if let Err(err) = server::run(([0, 0, 0, 0], options.port)).await { 24 | eprintln!("{}", err); 25 | std::process::exit(1); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/params.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::fmt::{Display, Formatter}; 3 | 4 | use myxine_core::RefreshMode; 5 | use myxine_core::Subscription; 6 | 7 | #[derive(Debug, Clone)] 8 | pub enum ParseError { 9 | ExpectedFlag(&'static str), 10 | ExpectedOne(&'static str), 11 | Unexpected(HashMap>, Vec), 12 | Custom(&'static str, String, &'static str), 13 | } 14 | 15 | impl warp::reject::Reject for ParseError {} 16 | 17 | impl Display for ParseError { 18 | fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { 19 | match self { 20 | ParseError::ExpectedFlag(flag) => write!( 21 | f, 22 | "Expected query parameter '{}' as a flag with no value", 23 | flag 24 | ), 25 | ParseError::ExpectedOne(param) => write!( 26 | f, 27 | "Expected query parameter '{}' with exactly one argument", 28 | param 29 | ), 30 | ParseError::Unexpected(map, valid) => { 31 | write!( 32 | f, 33 | "Unexpected query parameter{}: ", 34 | if map.len() > 1 { "s" } else { "" } 35 | )?; 36 | let mut i = 1; 37 | // write!(f, "?")?; 38 | for key in map.keys() { 39 | // let mut j = 1; 40 | // for val in vals { 41 | // if val != "" { 42 | // write!(f, "{}={}", key, val)?; 43 | // } else { 44 | write!(f, "'{}'", key)?; 45 | // } 46 | // if j < vals.len() { 47 | // write!(f, "&")?; 48 | // } 49 | // j += 1; 50 | // } 51 | // if i < map.len() { 52 | // write!(f, "&")?; 53 | // } 54 | if i < map.len() { 55 | write!(f, ", ")?; 56 | } 57 | i += 1; 58 | } 59 | write!(f, "\nExpecting only: ")?; 60 | i = 1; 61 | for param in valid { 62 | write!(f, "'{}'", param)?; 63 | if i < valid.len() { 64 | write!(f, ", ")?; 65 | } 66 | i += 1; 67 | } 68 | Ok(()) 69 | } 70 | ParseError::Custom(param, value, expected) => write!( 71 | f, 72 | "Parse error in the value of query parameter '{}={}'\nExpecting: {}", 73 | param, value, expected 74 | ), 75 | } 76 | } 77 | } 78 | 79 | /// Parsed parameters from a query string for a GET/HEAD request. 80 | #[derive(Debug, Clone)] 81 | pub enum GetParams { 82 | /// Get the full page. 83 | FullPage, 84 | /// Get the tiny WebWorker script for connecting back to the server. 85 | Connect, 86 | /// Subscribe to events in the page. 87 | Subscribe { 88 | subscription: Subscription, 89 | stream_or_after: SubscribeParams, 90 | }, 91 | } 92 | 93 | /// The manner in which a subscription should be handled. 94 | #[derive(Debug, Clone)] 95 | pub enum SubscribeParams { 96 | /// Stream all events to the client. 97 | Stream, 98 | /// Return the earliest event after the given moment to the client, as soon 99 | /// as it is available. 100 | After(u64), 101 | /// Return the next event matching the subscription, but don't return any 102 | /// events that have already been registered. 103 | Next, 104 | } 105 | 106 | impl GetParams { 107 | /// Parse a query string from a GET request. 108 | pub fn parse(query: &str) -> Result { 109 | let params = query_params(&query); 110 | if params.is_empty() { 111 | Ok(GetParams::FullPage) 112 | } else if param_as_flag("connect", ¶ms)? { 113 | constrain_to_keys(params, &["connect"])?; 114 | Ok(GetParams::Connect) 115 | } else if param_as_flag("stream", ¶ms)? { 116 | let result = Ok(GetParams::Subscribe { 117 | subscription: parse_subscription(¶ms), 118 | stream_or_after: SubscribeParams::Stream, 119 | }); 120 | constrain_to_keys(params, &["events", "event", "stream"])?; 121 | result 122 | } else if let Ok(after_str) = param_as_str("after", ¶ms) { 123 | let after = u64::from_str_radix(after_str, 10) 124 | .map_err(|_| { 125 | ParseError::Custom("after", after_str.to_string(), "non-negative whole number") 126 | }) 127 | .and_then(|n| { 128 | Ok(n + if param_as_flag("next", ¶ms)? { 129 | 1 130 | } else { 131 | 0 132 | }) 133 | })?; 134 | let result = Ok(GetParams::Subscribe { 135 | subscription: parse_subscription(¶ms), 136 | stream_or_after: SubscribeParams::After(after), 137 | }); 138 | constrain_to_keys(params, &["events", "event", "next", "after"])?; 139 | result 140 | } else { 141 | let result = Ok(GetParams::Subscribe { 142 | subscription: parse_subscription(¶ms), 143 | stream_or_after: SubscribeParams::Next, 144 | }); 145 | constrain_to_keys(params, &["events", "event", "next"])?; 146 | result 147 | } 148 | } 149 | } 150 | 151 | /// Get the canonical path to the moment for an event 152 | // NOTE: This must be updated if the ?after=... API changes! 153 | pub fn canonical_moment(path: &warp::path::FullPath, moment: u64) -> String { 154 | format!("{}?after={}", path.as_str(), moment) 155 | } 156 | 157 | fn parse_subscription<'a>(params: &'a HashMap>) -> Subscription { 158 | let mut events = match (params.get("events"), params.get("event")) { 159 | (Some(e1), Some(e2)) => e1.iter().chain(e2).map(String::from).collect(), 160 | (Some(e1), None) => e1.iter().map(String::from).collect(), 161 | (None, Some(e2)) => e2.iter().map(String::from).collect(), 162 | (None, None) => HashSet::new(), 163 | }; 164 | // The 'empty event' is a red herring: if someone types '?events' with no 165 | // event, we want to treat this as a universal subscription. 166 | events.remove(""); 167 | if events.is_empty() { 168 | Subscription::universal() 169 | } else { 170 | Subscription::from_events(events) 171 | } 172 | } 173 | 174 | /// Parsed parameters from a query string for a POST request. 175 | #[derive(Debug, Clone)] 176 | pub enum PostParams { 177 | DynamicPage { title: String, refresh: RefreshMode }, 178 | StaticPage, 179 | Evaluate { expression: Option }, 180 | } 181 | 182 | impl PostParams { 183 | /// Parse a query string from a POST request. 184 | pub fn parse(query: &str) -> Result { 185 | let params = query_params(query); 186 | if params.contains_key("evaluate") { 187 | let expression = param_as_str("evaluate", ¶ms).map_or_else( 188 | |err| { 189 | if param_as_flag("evaluate", ¶ms)? { 190 | Ok(None) 191 | } else { 192 | Err(err) 193 | } 194 | }, 195 | |expression| { 196 | Ok(if expression != "" { 197 | Some(expression.to_string()) 198 | } else { 199 | None 200 | }) 201 | }, 202 | )?; 203 | constrain_to_keys(params, &["evaluate"])?; 204 | Ok(PostParams::Evaluate { expression }) 205 | } else if param_as_flag("static", ¶ms)? { 206 | constrain_to_keys(params, &["static"])?; 207 | Ok(PostParams::StaticPage) 208 | } else { 209 | let title = param_as_str("title", ¶ms).unwrap_or("").to_string(); 210 | let refresh = match param_as_flag("refresh", ¶ms) { 211 | Ok(true) => RefreshMode::FullReload, 212 | Ok(false) => RefreshMode::Diff, 213 | Err(_) => match param_as_str("refresh", ¶ms)? { 214 | "full" => RefreshMode::FullReload, 215 | "set" => RefreshMode::SetBody, 216 | "diff" => RefreshMode::Diff, 217 | s => { 218 | return Err(ParseError::Custom( 219 | "refresh", 220 | s.to_string(), 221 | "one of 'full', 'set', or 'diff'", 222 | )) 223 | } 224 | }, 225 | }; 226 | constrain_to_keys(params, &["title", "refresh"])?; 227 | Ok(PostParams::DynamicPage { title, refresh }) 228 | } 229 | } 230 | } 231 | 232 | /// Parse a given parameter as a boolean, where its presence without a mapping 233 | /// is interpreted as true. If it is mapped to multiple values, or mapped to 234 | /// something other than "true" or "false", return `None`. 235 | fn param_as_flag<'a>( 236 | param: &'static str, 237 | params: &'a HashMap>, 238 | ) -> Result { 239 | match params.get(param).map(Vec::as_slice) { 240 | Some([]) => Ok(true), 241 | Some([s]) if s == "" => Ok(true), 242 | None => Ok(false), 243 | _ => Err(ParseError::ExpectedFlag(param)), 244 | } 245 | } 246 | 247 | /// Parse a given parameter as a string, where its presence without a mapping 248 | /// (or its absence entirely) is interpreted as the empty string. If it is 249 | /// mapped to multiple values, retrun `None`. 250 | fn param_as_str<'a>( 251 | param: &'static str, 252 | params: &'a HashMap>, 253 | ) -> Result<&'a str, ParseError> { 254 | match params.get(param).map(Vec::as_slice) { 255 | Some([string]) => Ok(string.as_ref()), 256 | _ => Err(ParseError::ExpectedOne(param)), 257 | } 258 | } 259 | 260 | /// Parse a query string into a mapping from key to list of values. The syntax 261 | /// expected for an individual key-value mapping is one of `k`, `k=`, `k=v`, and 262 | /// mappings are concatenated by `&`, as in: `k1=v1&k2=v2`. 263 | pub(crate) fn query_params(query: &str) -> HashMap> { 264 | let mut map: HashMap> = HashMap::new(); 265 | let raw: Vec<(String, String)> = serde_urlencoded::from_str(query).unwrap(); 266 | for (key, value) in raw { 267 | let key = key.trim(); 268 | let existing = map.entry(key.to_string()).or_insert_with(Vec::new); 269 | existing.push(value.to_string()); 270 | } 271 | map 272 | } 273 | 274 | /// If the keys of the hashmap are exclusively within the set enumerated by the 275 | /// slice, return `true`, otherwise return `false`. 276 | pub(crate) fn constrain_to_keys( 277 | mut map: HashMap>, 278 | valid: &[&str], 279 | ) -> Result<(), ParseError> { 280 | for key in valid { 281 | map.remove(*key); 282 | } 283 | if map.is_empty() { 284 | Ok(()) 285 | } else { 286 | Err(ParseError::Unexpected( 287 | map, 288 | valid.iter().map(|s| s.to_string()).collect(), 289 | )) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /server/src/server.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use futures::{FutureExt, SinkExt, StreamExt}; 3 | use http::{Response, StatusCode, Uri}; 4 | use hyper::body::Body; 5 | use lazy_static::lazy_static; 6 | use std::net::SocketAddr; 7 | use std::sync::Arc; 8 | use tokio::time::Duration; 9 | use warp::{self, path::FullPath, reject::Reject, Filter, Rejection}; 10 | 11 | use myxine_core::{Page, Session}; 12 | 13 | use crate::params; 14 | 15 | /// The interval between heartbeats. 16 | const HEARTBEAT_INTERVAL: Duration = Duration::from_millis(100); 17 | 18 | /// The duration we should wait before marking a page as stale. 19 | const KEEP_ALIVE_DURATION: Duration = Duration::from_secs(1); 20 | 21 | /// The maximum size of the event buffer for each page: a consumer of events via 22 | /// the polling interface can lag by this many events before dropping events. 23 | const DEFAULT_BUFFER_LEN: usize = 512; 24 | 25 | /// A custom rejection for when the path ended with a slash 26 | #[derive(Debug, Clone)] 27 | struct Redirect(Uri); 28 | impl Reject for Redirect {} 29 | 30 | /// Get the `Page` in the given `Session` that corresponds to the path, or send 31 | /// a `Rejection` that tells us to emit a redirect, if the path ends with a 32 | /// slash. 33 | fn page( 34 | session: Arc, 35 | ) -> impl Filter,), Error = warp::Rejection> + Clone { 36 | warp::path::full().and_then(move |path: warp::path::FullPath| { 37 | let session = session.clone(); 38 | async move { 39 | let path = path.as_str(); 40 | let path_ends_with_slash = path != "/" && path.ends_with('/'); 41 | let path = if path != "/" { 42 | path.trim_end_matches('/') 43 | } else { 44 | "/" 45 | }; 46 | if path_ends_with_slash { 47 | Err(warp::reject::custom(Redirect(path.parse().unwrap()))) 48 | } else { 49 | Ok(session.page(&path).await) 50 | } 51 | } 52 | }) 53 | } 54 | 55 | /// Get the raw query string, if one is present, or the empty string if one is 56 | /// not. This filter will never fail, unlike warp::query::raw, which fails if 57 | /// there is not any query string. 58 | fn query() -> impl Filter + Clone { 59 | warp::query::raw().or(warp::any().map(String::new)).unify() 60 | } 61 | 62 | /// If the request was a GET request, parse its query parameters as such, or 63 | /// else fail. 64 | fn get_params() -> impl Filter + Clone { 65 | warp::get().and(query().and_then(|params: String| async move { 66 | params::GetParams::parse(¶ms).map_err(warp::reject::custom) 67 | })) 68 | } 69 | 70 | /// If the request was a POST request, parse its query parameters as such, or 71 | /// else fail. 72 | fn post_params() -> impl Filter + Clone { 73 | warp::post().and(query().and_then(|params: String| async move { 74 | params::PostParams::parse(¶ms).map_err(warp::reject::custom) 75 | })) 76 | } 77 | 78 | /// Enforce that there are no query parameters, throwing a nice error if there 79 | /// are any. 80 | fn no_params() -> impl Filter + Clone { 81 | query() 82 | .and_then(|params: String| async move { 83 | params::constrain_to_keys(params::query_params(¶ms), &[]) 84 | .map_err(warp::reject::custom) 85 | }) 86 | .untuple_one() 87 | } 88 | 89 | lazy_static! { 90 | /// The content of the dynamic page returned by default, including (inlined) 91 | /// all the JavaScript necessary to make it run. 92 | static ref DYNAMIC_PAGE: String = 93 | format!( 94 | include_str!("dynamic.html"), 95 | diff = include_str!("../deps/diffhtml.min.js"), 96 | dynamic = include_str!("dynamic.js"), 97 | enabled_events = include_str!("enabled-events.json"), 98 | ); 99 | } 100 | 101 | /// Handle a GET request, in the whole. 102 | fn get( 103 | session: Arc, 104 | ) -> impl Filter + Clone { 105 | warp::path::full() 106 | .and(get_params()) 107 | .and(page(session)) 108 | .and_then( 109 | |path: FullPath, params: params::GetParams, page: Arc| async move { 110 | use params::GetParams::*; 111 | use params::SubscribeParams::*; 112 | let mut response = Response::builder(); 113 | Ok::<_, warp::Rejection>( 114 | match params { 115 | FullPage => { 116 | if let Some((content_type, raw_contents)) = page.static_content().await 117 | { 118 | if let Some(content_type) = content_type { 119 | response = response.header("Content-Type", content_type); 120 | } 121 | response.body(raw_contents.into()) 122 | } else { 123 | response.body(DYNAMIC_PAGE.as_str().into()) 124 | } 125 | } 126 | Connect => response 127 | .header("Content-Type", "application/javascript; charset=utf8") 128 | .body(include_str!("connect.js").into()), 129 | Subscribe { 130 | subscription, 131 | stream_or_after: Stream, 132 | } => { 133 | let events = page.events(subscription).await; 134 | let stream = hyper::body::Body::wrap_stream(events.map(|event| { 135 | let mut line = serde_json::to_vec(&*event).unwrap(); 136 | line.push(b'\n'); 137 | Ok::, std::convert::Infallible>(line) 138 | })); 139 | response.body(stream) 140 | } 141 | Subscribe { 142 | subscription, 143 | stream_or_after: After(after), 144 | } => match page.event_after(subscription, after).await { 145 | Ok((moment, event)) => response 146 | .header("Content-Type", "application/json; charset=utf8") 147 | .header("Content-Location", params::canonical_moment(&path, moment)) 148 | .body(serde_json::to_vec(&*event).unwrap().into()), 149 | Err(moment) => response 150 | .header("Location", params::canonical_moment(&path, moment)) 151 | .status(StatusCode::TEMPORARY_REDIRECT) 152 | .body(format!("{}", moment - after).into()), 153 | }, 154 | Subscribe { 155 | subscription, 156 | stream_or_after: Next, 157 | } => { 158 | let (moment, event) = page.next_event(subscription).await; 159 | response 160 | .header("Content-Type", "application/json; charset=utf8") 161 | .header("Content-Location", params::canonical_moment(&path, moment)) 162 | .body(serde_json::to_vec(&*event).unwrap().into()) 163 | } 164 | } 165 | .unwrap(), 166 | ) 167 | }, 168 | ) 169 | } 170 | 171 | /// Handle a POST request, in the whole. 172 | fn post( 173 | session: Arc, 174 | ) -> impl Filter + Clone { 175 | post_params() 176 | .and(page(session)) 177 | .and(warp::header::optional::("Content-Type")) 178 | .and(warp::body::bytes()) 179 | .and_then(|params: params::PostParams, page: Arc, mut content_type: Option, bytes: Bytes| async move { 180 | use params::PostParams::*; 181 | // One of these content types means it's just data, we know nothing 182 | // about it, and we should just serve it up as a unicode string. The 183 | // user should specify some particular content type if they desire 184 | // one. 185 | if let Some(ref t) = content_type { 186 | if t.starts_with("application/x-www-form-urlencoded") 187 | || t.starts_with("multipart/form-data") 188 | { 189 | content_type = None; 190 | } 191 | } 192 | Ok::<_, warp::Rejection>({ 193 | match params { 194 | StaticPage => page.set_static(content_type, bytes).await, 195 | DynamicPage { title, refresh } => { 196 | match std::str::from_utf8(bytes.as_ref()) { 197 | Ok(body) => page.set_content(title, body, refresh).await, 198 | Err(err) => { 199 | return Ok(Response::builder() 200 | .status(StatusCode::BAD_REQUEST) 201 | .body(format!("Invalid UTF-8 in POST data: {}", err).into()) 202 | .unwrap()); 203 | }, 204 | } 205 | }, 206 | Evaluate { expression } => { 207 | // Make an "abort" future that will complete if this 208 | // enclosing future is dropped: that is, if the client 209 | // kills the connection, regardless of whether we've 210 | // gotten an evaluation result, we're done. 211 | let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); 212 | let abort = rx.map(|_| ()); 213 | return tokio::spawn(async move { 214 | match if let Some(expression) = expression { 215 | if bytes.is_empty() { 216 | page.evaluate(&expression, false, abort).await 217 | } else { 218 | return Ok(Response::builder() 219 | .status(StatusCode::BAD_REQUEST) 220 | .body("Expecting empty body with non-empty ?evaluate=... query parameter".into()) 221 | .unwrap()) 222 | } 223 | } else { 224 | match std::str::from_utf8(bytes.as_ref()) { 225 | Ok(statements) => page.evaluate(&statements, true, abort).await, 226 | Err(err) => { 227 | return Ok(Response::builder() 228 | .status(StatusCode::BAD_REQUEST) 229 | .body(format!("Invalid UTF-8 in POST data: {}", err).into()) 230 | .unwrap()); 231 | } 232 | } 233 | } { 234 | Some(Ok(value)) => { 235 | let json = serde_json::to_string(&value).unwrap(); 236 | Ok(Response::builder() 237 | .header("Content-Type", "application/json") 238 | .body(Body::from(json)) 239 | .unwrap()) 240 | }, 241 | Some(Err(err)) => { 242 | Ok(Response::builder() 243 | .status(StatusCode::BAD_REQUEST) 244 | .body(err.into()) 245 | .unwrap()) 246 | }, 247 | None => { 248 | Ok(Response::builder() 249 | .status(StatusCode::INTERNAL_SERVER_ERROR) 250 | .body("JavaScript evaluation aborted before connection closed".into()) 251 | .unwrap()) 252 | }, 253 | } 254 | }).await.unwrap() 255 | }, 256 | }; 257 | Response::new(Body::empty()) 258 | }) 259 | }) 260 | } 261 | 262 | /// Handle a DELETE request, in the whole. 263 | fn delete( 264 | session: Arc, 265 | ) -> impl Filter + Clone { 266 | no_params() 267 | .and(page(session)) 268 | .and_then(|page: Arc| async move { 269 | page.clear().await; 270 | Ok::<_, warp::Rejection>("") 271 | }) 272 | } 273 | 274 | /// Handle a websocket upgrade from the page. 275 | fn websocket( 276 | session: Arc, 277 | ) -> impl Filter + Clone { 278 | warp::ws() 279 | .and(page(session)) 280 | .map(|ws: warp::ws::Ws, page: Arc| { 281 | ws.on_upgrade(|websocket| async { 282 | // Forward page updates to the page 283 | let (mut tx, mut rx) = websocket.split(); 284 | if let Some(mut commands) = page.commands().await { 285 | tokio::spawn(async move { 286 | while let Some(command) = commands.next().await { 287 | let message = serde_json::to_string(&command).unwrap(); 288 | if tx.send(warp::ws::Message::text(message)).await.is_err() { 289 | break; 290 | } 291 | } 292 | }); 293 | } 294 | // Forward responses from the browser to their handlers 295 | tokio::spawn(async move { 296 | while let Some(Ok(message)) = rx.next().await { 297 | if let Ok(text) = message.to_str() { 298 | if let Ok(response) = serde_json::from_str(text) { 299 | match response { 300 | myxine_core::Response::Event(event) => { 301 | page.send_event(event).await 302 | } 303 | myxine_core::Response::EvalResult { id, result } => { 304 | page.send_eval_result(id, result).await 305 | } 306 | } 307 | } else { 308 | // Couldn't parse response. 309 | break; 310 | } 311 | } 312 | } 313 | }); 314 | }) 315 | }) 316 | } 317 | 318 | /// The static string identifying the server and its major.minor version (we 319 | /// don't reveal the patch because patch versions should not affect public API). 320 | /// This allows clients to check whether they are compatible with this version 321 | /// of the server. 322 | const SERVER: &str = concat!( 323 | env!("CARGO_PKG_NAME"), 324 | "/", 325 | env!("CARGO_PKG_VERSION_MAJOR"), 326 | ".", 327 | env!("CARGO_PKG_VERSION_MINOR") 328 | ); 329 | 330 | pub(crate) async fn run(addr: impl Into + 'static) -> Result<(), warp::Error> { 331 | // The session holding all the pages for this instantiation of the server 332 | let session = Arc::new( 333 | Session::start(myxine_core::Config { 334 | heartbeat_interval: HEARTBEAT_INTERVAL, 335 | keep_alive_duration: KEEP_ALIVE_DURATION, 336 | default_buffer_len: DEFAULT_BUFFER_LEN, 337 | }) 338 | .await, 339 | ); 340 | 341 | // Collect the routes 342 | let routes = websocket(session.clone()) 343 | .or(get(session.clone())) 344 | .or(post(session.clone())) 345 | .or(delete(session)) 346 | .map(|reply| warp::reply::with_header(reply, "Cache-Control", "no-cache")) 347 | .map(|reply| warp::reply::with_header(reply, "Server", SERVER)) 348 | .recover(|err: Rejection| async { 349 | if let Some(Redirect(uri)) = err.find() { 350 | Ok(warp::redirect(uri.clone())) 351 | } else { 352 | Err(err) 353 | } 354 | }) 355 | .recover(|err: Rejection| async { 356 | if let Some(param_error) = err.find::() { 357 | Ok(warp::reply::with_status( 358 | format!("{}", param_error), 359 | StatusCode::BAD_REQUEST, 360 | )) 361 | } else { 362 | Err(err) 363 | } 364 | }); 365 | 366 | // Run the server 367 | match warp::serve(routes).try_bind_ephemeral(addr) { 368 | Ok((actual_addr, server)) => { 369 | println!("http://{}", actual_addr); 370 | server.await; 371 | Ok(()) 372 | } 373 | Err(err) => Err(err), 374 | } 375 | } 376 | --------------------------------------------------------------------------------