Compare commits
292 Commits
v0.4.0-pre
...
v0.5.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1395a382a | ||
|
|
a54364642a | ||
|
|
14ab85adbd | ||
|
|
c284b7de40 | ||
|
|
e23056ac2f | ||
|
|
8ce2e605a2 | ||
|
|
9408247708 | ||
|
|
3e1c467b65 | ||
|
|
811db2e668 | ||
|
|
f833642733 | ||
|
|
30ce927621 | ||
|
|
f21f01be1a | ||
|
|
d81eaa6c13 | ||
|
|
5ad830a6d7 | ||
|
|
14e1829164 | ||
|
|
0a2810a98b | ||
|
|
7b84fce737 | ||
|
|
1e1b409f8b | ||
|
|
5f91a8fdfe | ||
|
|
5bb70c2675 | ||
|
|
ed6d8415bd | ||
|
|
d53ffd8da2 | ||
|
|
d52ecaa050 | ||
|
|
9ac001bd3e | ||
|
|
4b81166782 | ||
|
|
95866d0f2e | ||
|
|
54b8a1aea5 | ||
|
|
0822aa9240 | ||
|
|
c883850142 | ||
|
|
54da826447 | ||
|
|
ce5ea92076 | ||
|
|
98f8c5c0f2 | ||
|
|
6ced8f32b1 | ||
|
|
f870107c66 | ||
|
|
4e7aa5d0a9 | ||
|
|
779bcaeccb | ||
|
|
f2849357f8 | ||
|
|
998fca721a | ||
|
|
a83c68f2fc | ||
|
|
665809e11a | ||
|
|
a306fa26e0 | ||
|
|
9574f8ebd7 | ||
|
|
b62d946f83 | ||
|
|
b26b36da5d | ||
|
|
8d640ab467 | ||
|
|
eff4301027 | ||
|
|
0be4e6921d | ||
|
|
049875e8fc | ||
|
|
b9f7d3d889 | ||
|
|
70ba101fe1 | ||
|
|
b2753cb268 | ||
|
|
11f398b553 | ||
|
|
24a4099d82 | ||
|
|
99ea374fc5 | ||
|
|
1992ce1cfb | ||
|
|
2724534156 | ||
|
|
c57989a790 | ||
|
|
1888c83b6e | ||
|
|
837fd22254 | ||
|
|
02a3da111a | ||
|
|
5c2bf65eaa | ||
|
|
b5ae6a5785 | ||
|
|
699e07a0f7 | ||
|
|
b8025452bf | ||
|
|
1138c05dff | ||
|
|
ae36688bf2 | ||
|
|
64e2ef3d3b | ||
|
|
9cfeee0577 | ||
|
|
86e0f3fabe | ||
|
|
edcb3d8638 | ||
|
|
1582d8f504 | ||
|
|
5816bb64dc | ||
|
|
b7a28aa511 | ||
|
|
37ed80055b | ||
|
|
e6c2f1c15d | ||
|
|
a74c11aef5 | ||
|
|
ad3999066d | ||
|
|
77c10d14c9 | ||
|
|
8c2e430a56 | ||
|
|
0aaefe9a66 | ||
|
|
14db86e4b7 | ||
|
|
9949a6c9ee | ||
|
|
9bf5bd11e2 | ||
|
|
a3cc39cd92 | ||
|
|
45af35cccd | ||
|
|
20e9688268 | ||
|
|
3be5988083 | ||
|
|
474d877290 | ||
|
|
b6a2b7dded | ||
|
|
2e42c28485 | ||
|
|
4453211c0d | ||
|
|
01dd603bd5 | ||
|
|
fff71e1de0 | ||
|
|
c0ffbd360e | ||
|
|
2f1aadd497 | ||
|
|
645ab0ae98 | ||
|
|
9ac7ebc335 | ||
|
|
d807d22350 | ||
|
|
05594ecca0 | ||
|
|
a511a5b259 | ||
|
|
9125f96302 | ||
|
|
1b9ab43b6d | ||
|
|
1176c9bbf1 | ||
|
|
65e551c5cf | ||
|
|
8fadafcd24 | ||
|
|
57442a4848 | ||
|
|
7154f2c328 | ||
|
|
e3e4809446 | ||
|
|
5707b8c7e8 | ||
|
|
f125814847 | ||
|
|
893587040d | ||
|
|
308369b14f | ||
|
|
9516a1aeea | ||
|
|
f43dc99f7a | ||
|
|
3feb8c3344 | ||
|
|
4d0a2b806c | ||
|
|
a89fff1a22 | ||
|
|
52f01da400 | ||
|
|
b732c43274 | ||
|
|
ee0a5cada3 | ||
|
|
dbd499701f | ||
|
|
fc3f750efb | ||
|
|
457f3c8f54 | ||
|
|
e4d3c3b045 | ||
|
|
e4379e29af | ||
|
|
4542febaaf | ||
|
|
f691d8a6a5 | ||
|
|
503d7400f3 | ||
|
|
061ea0e7a3 | ||
|
|
dd4d542d7e | ||
|
|
75359e2b83 | ||
|
|
db7f8ffb1b | ||
|
|
f59a9040fc | ||
|
|
5114d12daf | ||
|
|
13a7c1ba81 | ||
|
|
26a7bb9746 | ||
|
|
d427f01224 | ||
|
|
25c73ed917 | ||
|
|
ce6a9dc448 | ||
|
|
c50922e90c | ||
|
|
bcd5fab33c | ||
|
|
49a2bc8ace | ||
|
|
f1c5268670 | ||
|
|
29fe7492cc | ||
|
|
2340a07408 | ||
|
|
797c39347f | ||
|
|
5a9d3959e2 | ||
|
|
1e0a7dea73 | ||
|
|
c61d6a5804 | ||
|
|
8aee5c769f | ||
|
|
7c585fcbce | ||
|
|
f7689f3154 | ||
|
|
6617b8b6c4 | ||
|
|
9db31c9191 | ||
|
|
767a59a481 | ||
|
|
f632444a0e | ||
|
|
16c3c1f498 | ||
|
|
c8bb9bf2e9 | ||
|
|
729ab25581 | ||
|
|
38e0f82812 | ||
|
|
b4fd2e31b3 | ||
|
|
e09d23d6c2 | ||
|
|
9ad0eabb85 | ||
|
|
fb950cb007 | ||
|
|
60c5c2d344 | ||
|
|
a29c4f2b65 | ||
|
|
5a99281e23 | ||
|
|
31e1f61548 | ||
|
|
dbad0a16c4 | ||
|
|
a69cbf45df | ||
|
|
284f423220 | ||
|
|
81a18e88ad | ||
|
|
72bc77f1d5 | ||
|
|
80b9b7594b | ||
|
|
7e671ee76a | ||
|
|
5d608cb498 | ||
|
|
c6982f70b4 | ||
|
|
ef0d1e7cec | ||
|
|
1db06194c7 | ||
|
|
f3e7e54675 | ||
|
|
2bd64db8d9 | ||
|
|
ae8098b80a | ||
|
|
bfe8dcd224 | ||
|
|
8a26994084 | ||
|
|
77d0865d58 | ||
|
|
bece337d79 | ||
|
|
5a5da3240f | ||
|
|
4138bb7ee1 | ||
|
|
4088bb47f0 | ||
|
|
d10b6d324e | ||
|
|
43b27831eb | ||
|
|
20c9c89b27 | ||
|
|
e1c420d37d | ||
|
|
be58598a3e | ||
|
|
5e08093609 | ||
|
|
f5599b95b3 | ||
|
|
ba930ea584 | ||
|
|
ba3fa24f9a | ||
|
|
ff0f5cd49c | ||
|
|
284f5cfb71 | ||
|
|
871796f172 | ||
|
|
9733f059c2 | ||
|
|
db71bdfde7 | ||
|
|
9aa27f4c11 | ||
|
|
8893d0ddde | ||
|
|
0b46860cdd | ||
|
|
ec1f9bd706 | ||
|
|
e30545c132 | ||
|
|
7d7f671920 | ||
|
|
fb7bfa928a | ||
|
|
100d69262c | ||
|
|
5e01658846 | ||
|
|
ccec93aee8 | ||
|
|
a089d82023 | ||
|
|
82ba583fa0 | ||
|
|
1b82044d7d | ||
|
|
0d49a2e0af | ||
|
|
1343d3a2a9 | ||
|
|
a86001b85c | ||
|
|
d6dd46c467 | ||
|
|
320974074c | ||
|
|
7b824abe52 | ||
|
|
bfd33f4b8d | ||
|
|
d5a21a0513 | ||
|
|
c894b38f06 | ||
|
|
a86347ea32 | ||
|
|
b60bfc7495 | ||
|
|
4b2f27b26d | ||
|
|
f4d7dda8e3 | ||
|
|
0d6e3e66ce | ||
|
|
7e4d451765 | ||
|
|
804bbc93b7 | ||
|
|
e7fe4ac3ec | ||
|
|
40c41b4400 | ||
|
|
0936c7c97d | ||
|
|
9ac537d38f | ||
|
|
fcfd55ff76 | ||
|
|
c2495ed57f | ||
|
|
6ad763fc01 | ||
|
|
c856a3e361 | ||
|
|
aa5f0cc335 | ||
|
|
b067335bbf | ||
|
|
7d24a14004 | ||
|
|
910be640e9 | ||
|
|
3137753afa | ||
|
|
000ff351a5 | ||
|
|
533c8ddaf7 | ||
|
|
f777d1b6c6 | ||
|
|
8b17d3b7d9 | ||
|
|
6fbe1daf8e | ||
|
|
3bd191414b | ||
|
|
fd2cb3495b | ||
|
|
e9d33bdc02 | ||
|
|
c0f4b31ab3 | ||
|
|
78de30dcf2 | ||
|
|
23c59dcae7 | ||
|
|
274ba5810b | ||
|
|
3661d0daec | ||
|
|
f215df891c | ||
|
|
ce5fe00a66 | ||
|
|
2d71e3ebea | ||
|
|
187194a615 | ||
|
|
9e956e593d | ||
|
|
c2cfcc7a2c | ||
|
|
8c482f75dd | ||
|
|
29a83cb626 | ||
|
|
a563e4c381 | ||
|
|
9cee587f22 | ||
|
|
b5cc243466 | ||
|
|
73c6b5a08c | ||
|
|
1f5a686570 | ||
|
|
6fc497f95e | ||
|
|
52eea667a7 | ||
|
|
c2f7e268ff | ||
|
|
31e5c558ab | ||
|
|
7a7ac9550d | ||
|
|
4d0fdf0dfd | ||
|
|
b448e8007e | ||
|
|
bad0e67266 | ||
|
|
3dee3dd627 | ||
|
|
4772350968 | ||
|
|
eabcc0bd1d | ||
|
|
3a3af6ab10 | ||
|
|
9723622b66 | ||
|
|
3b1d647acb | ||
|
|
6fa925a402 | ||
|
|
c8f837d726 | ||
|
|
4557396564 | ||
|
|
d3d67d47e1 | ||
|
|
42107e0715 | ||
|
|
ed183e0805 | ||
|
|
116be16392 |
@@ -3,14 +3,16 @@ root = true
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.json]
|
||||
[*.{json,js,css}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{md,rs}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.lua]
|
||||
indent_style = tab
|
||||
5
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
/target/
|
||||
/site
|
||||
/target
|
||||
/server/scratch
|
||||
**/*.rs.bk
|
||||
/generate-docs.run
|
||||
18
.gitmodules
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
[submodule "plugin/modules/roact"]
|
||||
path = plugin/modules/roact
|
||||
url = https://github.com/Roblox/roact.git
|
||||
[submodule "plugin/modules/rodux"]
|
||||
path = plugin/modules/rodux
|
||||
url = https://github.com/Roblox/rodux.git
|
||||
[submodule "plugin/modules/roact-rodux"]
|
||||
path = plugin/modules/roact-rodux
|
||||
url = https://github.com/Roblox/roact-rodux.git
|
||||
[submodule "plugin/modules/testez"]
|
||||
path = plugin/modules/testez
|
||||
url = https://github.com/Roblox/testez.git
|
||||
[submodule "plugin/modules/lemur"]
|
||||
path = plugin/modules/lemur
|
||||
url = https://github.com/LPGhatguy/lemur.git
|
||||
[submodule "plugin/modules/promise"]
|
||||
path = plugin/modules/promise
|
||||
url = https://github.com/LPGhatguy/roblox-lua-promise.git
|
||||
41
.travis.yml
@@ -1,5 +1,38 @@
|
||||
language: rust
|
||||
matrix:
|
||||
include:
|
||||
- language: python
|
||||
env:
|
||||
- LUA="lua=5.1"
|
||||
|
||||
rust:
|
||||
- stable
|
||||
- beta
|
||||
before_install:
|
||||
- pip install hererocks
|
||||
- hererocks lua_install -r^ --$LUA
|
||||
- export PATH=$PATH:$PWD/lua_install/bin
|
||||
|
||||
install:
|
||||
- luarocks install luafilesystem
|
||||
- luarocks install busted
|
||||
- luarocks install luacov
|
||||
- luarocks install luacov-coveralls
|
||||
- luarocks install luacheck
|
||||
|
||||
script:
|
||||
- cd plugin
|
||||
- luacheck src
|
||||
- lua -lluacov spec.lua
|
||||
|
||||
after_success:
|
||||
- cd plugin
|
||||
- luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
|
||||
|
||||
- language: rust
|
||||
rust: stable
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
|
||||
- language: rust
|
||||
rust: beta
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
120
CHANGES.md
@@ -1,41 +1,129 @@
|
||||
# Rojo Change Log
|
||||
|
||||
## Current Master (0.4.0)
|
||||
* Protocol version 1, which shifts more responsibility onto the server
|
||||
* This is a **major breaking** change!
|
||||
* The server now has a content of 'filter plugins', which transform data at various stages in the pipeline
|
||||
* The server now exposes Roblox instance objects instead of file contents, which lines up with how `rojo pack` will work, and paves the way for more robust syncing.
|
||||
## Current master
|
||||
* "Epiphany" rewrite, in progress since the beginning of time
|
||||
* New live sync protocol
|
||||
* Uses HTTP long polling to reduce request count and improve responsiveness
|
||||
* New project format
|
||||
* Hierarchical, preventing overlapping partitions
|
||||
* Added `rojo build` command
|
||||
* Generates `rbxm`, `rbxmx`, `rbxl`, or `rbxlx` files out of your project
|
||||
* Usage: `rojo build <PROJECT> --output <OUTPUT>.rbxm`
|
||||
* Added `rojo upload` command
|
||||
* Generates and uploads a place or model to roblox.com out of your project
|
||||
* Usage: `rojo upload <PROJECT> --cookie "<ROBLOSECURITY>" --asset_id <PLACE_ID>`
|
||||
* New plugin
|
||||
* Only one button now, "Connect"
|
||||
* New UI to pick server address and port
|
||||
* Better error reporting
|
||||
* Added support for `.csv` files turning into `LocalizationTable` instances
|
||||
* Added support for `.txt` files turning into `StringValue` instances
|
||||
* Added debug visualization code to diagnose problems
|
||||
* `/visualize/rbx` and `/visualize/imfs` show instance and file state respectively; they require GraphViz to be installed on your machine.
|
||||
* Added optional place ID restrictions to project files
|
||||
* This helps prevent syncing in content to the wrong place
|
||||
* Multiple places can be specified, like when building a multi-place game
|
||||
* Added support for specifying properties on services in project files
|
||||
|
||||
## 0.3.2
|
||||
## 0.4.13 (November 12, 2018)
|
||||
* When `rojo.json` points to a file or directory that does not exist, Rojo now issues a warning instead of throwing an error and exiting
|
||||
|
||||
## 0.4.12 (June 21, 2018)
|
||||
* Fixed obscure assertion failure when renaming or deleting files ([#78](https://github.com/LPGhatguy/rojo/issues/78))
|
||||
* Added a `PluginAction` for the sync in command, which should help with some automation scripts ([#80](https://github.com/LPGhatguy/rojo/pull/80))
|
||||
|
||||
## 0.4.11 (June 10, 2018)
|
||||
* Defensively insert existing instances into RouteMap; should fix most duplication cases when syncing into existing trees.
|
||||
* Fixed incorrect synchronization from `Plugin:_pull` that would cause polling to create issues
|
||||
* Fixed incorrect file routes being assigned to `init.lua` and `init.model.json` files
|
||||
* Untangled route handling-internals slightly
|
||||
|
||||
## 0.4.10 (June 2, 2018)
|
||||
* Added support for `init.model.json` files, which enable versioning `Tool` instances (among other things) with Rojo. ([#66](https://github.com/LPGhatguy/rojo/issues/66))
|
||||
* Fixed obscure error when syncing into an invalid service.
|
||||
* Fixed multiple sync processes occurring when a server ID mismatch is detected.
|
||||
|
||||
## 0.4.9 (May 26, 2018)
|
||||
* Fixed warning when renaming or removing files that would sometimes corrupt the instance cache ([#72](https://github.com/LPGhatguy/rojo/pull/72))
|
||||
* JSON models are no longer as strict -- `Children` and `Properties` are now optional.
|
||||
|
||||
## 0.4.8 (May 26, 2018)
|
||||
* Hotfix to prevent errors from being thrown when objects managed by Rojo are deleted
|
||||
|
||||
## 0.4.7 (May 25, 2018)
|
||||
* Added icons to the Rojo plugin, made by [@Vorlias](https://github.com/Vorlias)! ([#70](https://github.com/LPGhatguy/rojo/pull/70))
|
||||
* Server will now issue a warning if no partitions are specified in `rojo serve` ([#40](https://github.com/LPGhatguy/rojo/issues/40))
|
||||
|
||||
## 0.4.6 (May 21, 2018)
|
||||
* Rojo handles being restarted by Roblox Studio more gracefully ([#67](https://github.com/LPGhatguy/rojo/issues/67))
|
||||
* Folders should no longer get collapsed when syncing occurs.
|
||||
* **Significant** robustness improvements with regards to caching.
|
||||
* **This should catch all existing script duplication bugs.**
|
||||
* If there are any bugs with script duplication or caching in the future, restarting the Rojo server process will fix them for that session.
|
||||
* Fixed message in plugin not being prefixed with `Rojo: `.
|
||||
|
||||
## 0.4.5 (May 1, 2018)
|
||||
* Rojo messages are now prefixed with `Rojo: ` to make them stand out in the output more.
|
||||
* Fixed server to notice file changes *much* more quickly. (200ms vs 1000ms)
|
||||
* Server now lists name of project when starting up.
|
||||
* Rojo now throws an error if no project file is found. ([#63](https://github.com/LPGhatguy/rojo/issues/63))
|
||||
* Fixed multiple sync operations occuring at the same time. ([#61](https://github.com/LPGhatguy/rojo/issues/61))
|
||||
* Partitions targeting files directly now work as expected. ([#57](https://github.com/LPGhatguy/rojo/issues/57))
|
||||
|
||||
## 0.4.4 (April 7, 2018)
|
||||
* Fix small regression introduced in 0.4.3
|
||||
|
||||
## 0.4.3 (April 7, 2018)
|
||||
* Plugin now automatically selects `HttpService` if it determines that HTTP isn't enabled ([#58](https://github.com/LPGhatguy/rojo/pull/58))
|
||||
* Plugin now has much more robust handling and will wipe all state when the server changes.
|
||||
* This should fix issues that would otherwise be solved by restarting Roblox Studio.
|
||||
|
||||
## 0.4.2 (April 4, 2018)
|
||||
* Fixed final case of duplicated instance insertion, caused by reconciled instances not being inserted into `RouteMap`.
|
||||
* The reconciler is still not a perfect solution, especially if script instances get moved around without being destroyed. I don't think this can be fixed before a big refactor.
|
||||
|
||||
## 0.4.1 (April 1, 2018)
|
||||
* Merged plugin repository into main Rojo repository for easier tracking.
|
||||
* Improved `RouteMap` object tracking; this should fix some cases of duplicated instances being synced into the tree.
|
||||
|
||||
## 0.4.0 (March 27, 2018)
|
||||
* Protocol version 1, which shifts more responsibility onto the server
|
||||
* This is a **major breaking** change!
|
||||
* The server now has a content of 'filter plugins', which transform data at various stages in the pipeline
|
||||
* The server now exposes Roblox instance objects instead of file contents, which lines up with how `rojo pack` will work, and paves the way for more robust syncing.
|
||||
* Added `*.model.json` files, which let you embed small Roblox objects into your Rojo tree.
|
||||
* Improved error messages in some cases ([#46](https://github.com/LPGhatguy/rojo/issues/46))
|
||||
|
||||
## 0.3.2 (December 20, 2017)
|
||||
* Fixed `rojo serve` failing to correctly construct an absolute root path when passed as an argument
|
||||
* Fixed intense CPU usage when running `rojo serve`
|
||||
|
||||
## 0.3.1
|
||||
## 0.3.1 (December 14, 2017)
|
||||
* Improved error reporting when invalid JSON is found in a `rojo.json` project
|
||||
* These messages are passed on from Serde
|
||||
* These messages are passed on from Serde
|
||||
|
||||
## 0.3.0
|
||||
## 0.3.0 (December 12, 2017)
|
||||
* Factored out the plugin into a separate repository
|
||||
* Fixed server when using a file as a partition
|
||||
* Previously, trailing slashes were put on the end of a partition even if the read request was an empty string. This broke file reading on Windows when a partition pointed to a file instead of a directory!
|
||||
* Previously, trailing slashes were put on the end of a partition even if the read request was an empty string. This broke file reading on Windows when a partition pointed to a file instead of a directory!
|
||||
* Started running automatic tests on Travis CI (#9)
|
||||
|
||||
## 0.2.3
|
||||
## 0.2.3 (December 4, 2017)
|
||||
* Plugin only release
|
||||
* Tightened `init` file rules to only match script files
|
||||
* Previously, Rojo would sometimes pick up the wrong file when syncing
|
||||
* Previously, Rojo would sometimes pick up the wrong file when syncing
|
||||
|
||||
## 0.2.2
|
||||
## 0.2.2 (December 1, 2017)
|
||||
* Plugin only release
|
||||
* Fixed broken reconciliation behavior with `init` files
|
||||
|
||||
## 0.2.1
|
||||
## 0.2.1 (December 1, 2017)
|
||||
* Plugin only release
|
||||
* Changes default port to 8000
|
||||
|
||||
## 0.2.0
|
||||
## 0.2.0 (December 1, 2017)
|
||||
* Support for `init.lua` like rbxfs and rbxpacker
|
||||
* More robust syncing with a new reconciler
|
||||
|
||||
## 0.1.0
|
||||
## 0.1.0 (November 29, 2017)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
2003
Cargo.lock
generated
27
Cargo.toml
@@ -1,22 +1,5 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "0.4.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "A tool to create robust Roblox projects"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/LPGhatguy/rojo"
|
||||
|
||||
[[bin]]
|
||||
name = "rojo"
|
||||
path = "src/bin.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = "2.27.1"
|
||||
rouille = "1.0"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
notify = "4.0.0"
|
||||
rand = "0.3"
|
||||
regex = "0.2"
|
||||
lazy_static = "1.0"
|
||||
[workspace]
|
||||
members = [
|
||||
"server",
|
||||
"rojo-e2e",
|
||||
]
|
||||
49
DESIGN.md
@@ -1,49 +0,0 @@
|
||||
# Rojo Design - Protocol Version 1
|
||||
This is a super rough draft that I'm trying to use to lay out some of my thoughts.
|
||||
|
||||
## API
|
||||
|
||||
### POST `/read`
|
||||
Accepts a `Vec<Route>` of items to read.
|
||||
|
||||
Returns `Vec<Option<RbxInstance>>`, in the same order as the request.
|
||||
|
||||
### POST `/write`
|
||||
Accepts a `Vec<{ Route, RbxInstance }>` of items to write.
|
||||
|
||||
I imagine that the `Name` attribute of the top-level `RbxInstance` would be ignored in favor of the route name?
|
||||
|
||||
## CLI
|
||||
The `rojo serve` command uses three major components:
|
||||
* A Virtual Filesystem (VFS), which exposes the filesystem as `VfsItem` objects
|
||||
* A VFS watcher, which tracks changes to the filesystem and logs them
|
||||
* An HTTP API, which exposes an interface to the Roblox Studio plugin
|
||||
|
||||
### Transform Plugins
|
||||
Transform plugins (or filter plugins?) can interject in three places:
|
||||
* Transform a `VfsItem` that's being read into an `RbxInstance` in the VFS
|
||||
* Transform an `RbxInstance` that's being written into a `VfsItem` in the VFS
|
||||
* Transform a file change into paths that need to be updated in the VFS watcher
|
||||
|
||||
The plan is to have several built-in plugins that can be rearranged/configured in project settings:
|
||||
|
||||
* Base plugin
|
||||
* Transforms all unhandled files to/from StringValue objects
|
||||
* Script plugin
|
||||
* Transforms `*.lua` files to their appropriate file types
|
||||
* JSON/rbxmx/rbxlx model plugin
|
||||
* External binary plugin
|
||||
* User passes a binary name (like `moonc`) that modifies file contents
|
||||
|
||||
## Roblox Studio Plugin
|
||||
With the protocol version 1 change, the Roblox Studio plugin got a lot simpler. Notably, the plugin doesn't need to be aware of anything about the filesystem's semantics, which is super handy.
|
||||
|
||||
## Bi-directional syncing
|
||||
Quenty laid out a good way to handle bi-directional syncing.
|
||||
|
||||
When receiving a change from the plugin:
|
||||
1. Hash the new contents of the file, store it in a map from routes to hashes
|
||||
2. Write the new file contents to the filesystem
|
||||
3. Later down the line, receive a change event from the filesystem watcher
|
||||
4. When receiving a change, if the item is in the hash map, read it and hash those contents
|
||||
5. If the hash matches the last noted hash, discard the change, else continue as normal
|
||||
373
LICENSE
Normal file
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -1,9 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Lucien Greathouse
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
165
README.md
@@ -1,159 +1,68 @@
|
||||
<div align="center">
|
||||
<img src="assets/rojo-logo.png" alt="Rojo" height="150" />
|
||||
<img src="assets/rojo-logo.png" alt="Rojo" height="217" />
|
||||
</div>
|
||||
|
||||
<div> </div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://travis-ci.org/LPGhatguy/rojo">
|
||||
<img src="https://api.travis-ci.org/LPGhatguy/rojo.svg?branch=master" alt="Travis-CI Build Status" />
|
||||
</a>
|
||||
<a href="https://travis-ci.org/LPGhatguy/rojo">
|
||||
<img src="https://api.travis-ci.org/LPGhatguy/rojo.svg?branch=master" alt="Travis-CI Build Status" />
|
||||
</a>
|
||||
<a href="https://crates.io/crates/rojo">
|
||||
<img src="https://img.shields.io/crates/v/rojo.svg?label=version" alt="Latest server version" />
|
||||
</a>
|
||||
<a href="https://lpghatguy.github.io/rojo/0.4.x">
|
||||
<img src="https://img.shields.io/badge/documentation-0.4.x-brightgreen.svg" alt="Rojo Documentation" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects. It's in early development, but is still useful for many projects.
|
||||
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
|
||||
|
||||
It's designed for power users who want to use the **best tools available** for building games, libraries, and plugins.
|
||||
It lets Roblox developers use industry-leading tools like Git and VS Code, and crucial utilities like Luacheck.
|
||||
|
||||
This is the main Rojo repository, containing the binary and project server component. For the source for the Roblox plugin, [see the rojo-plugin repository](https://github.com/LPGhatguy/rojo-plugin).
|
||||
|
||||
The master branches of both respositories should always pass all tests and be functional, but are not suitable for production use!
|
||||
Rojo is designed for **power users** who want to use the **best tools available** for building games, libraries, and plugins.
|
||||
|
||||
## Features
|
||||
|
||||
Rojo has a number of desirable features *right now*:
|
||||
Rojo lets you:
|
||||
|
||||
* Work on scripts from the filesystem, in your favorite editor
|
||||
* Version your place, library, or plugin using Git or another VCS
|
||||
* Sync JSON-format models from the filesystem into your game
|
||||
|
||||
Later this year, Rojo will be able to:
|
||||
Soon, Rojo will be able to:
|
||||
|
||||
* Sync rbxmx-format Roblox models bi-directionally between the filesystem and Roblox Studio
|
||||
* Create installation scripts for libraries to be used in standalone places
|
||||
* Similar to [rbxpacker](https://github.com/LPGhatguy/rbxpacker), another one of my projects
|
||||
* Add strongly-versioned dependencies to your project
|
||||
* Sync scripts from Roblox Studio to the filesystem
|
||||
* Compile MoonScript and sync it into Roblox Studio
|
||||
* Sync `rbxmx` models between the filesystem and Roblox Studio
|
||||
* Package projects into `rbxmx` files from the command line
|
||||
|
||||
## Installation
|
||||
Rojo has two components:
|
||||
* The command line tool, written in Rust
|
||||
* The [Roblox Studio plugin](https://www.roblox.com/library/1211549683/Rojo), written in Lua
|
||||
## [Documentation](https://lpghatguy.github.io/rojo/0.4.x)
|
||||
You can also view the documentation by browsing the [docs](https://github.com/LPGhatguy/rojo/tree/master/docs) folder of the repository, but because it uses a number of Markdown extensions, it may not be very readable.
|
||||
|
||||
To install the command line tool, there are two options:
|
||||
* Cargo, if you have Rust installed
|
||||
* Use `cargo install rojo` -- Rojo will be available with the `rojo` command
|
||||
* Download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
||||
|
||||
## Usage
|
||||
For more help, use `rojo help`.
|
||||
|
||||
### New Project
|
||||
Just create a new folder and tell Rojo to initialize it!
|
||||
|
||||
```sh
|
||||
mkdir my-new-project
|
||||
cd my-new-project
|
||||
|
||||
rojo init
|
||||
```
|
||||
|
||||
Rojo will create an empty project in the directory.
|
||||
|
||||
The default project looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-new-project",
|
||||
"servePort": 8000,
|
||||
"partitions": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Start Dev Server
|
||||
To create a server that allows the Rojo Dev Plugin to access your project, use:
|
||||
|
||||
```sh
|
||||
rojo serve
|
||||
```
|
||||
|
||||
The tool will tell you whether it found an existing project. You should then be able to connect and use the project from within Roblox Studio!
|
||||
|
||||
### Migrating an Existing Roblox Project
|
||||
**Coming soon!**
|
||||
|
||||
### Syncing into Roblox
|
||||
In order to sync code into Roblox, you'll need to add one or more "partitions" to your configuration. A partition tells Rojo how to map directories to Roblox objects.
|
||||
|
||||
Each entry in the partitions table has a unique name, a filesystem path, and the full name of the Roblox object to sync into.
|
||||
|
||||
For example, if you want to map your `src` directory to an object named `My Cool Game` in `ReplicatedStorage`, you could use this configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rojo",
|
||||
"servePort": 8000,
|
||||
"partitions": {
|
||||
"game": {
|
||||
"path": "src",
|
||||
"target": "ReplicatedStorage.My Cool Game"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `path` parameter is relative to the project file.
|
||||
|
||||
The `target` parameter is a path to a Roblox object to link the partition to. It starts at `game` and crawls down the tree. If any objects don't exist along the way, they'll be created as `Folder` instances.
|
||||
|
||||
Run `rojo serve` in the directory containing this project, then press the "Sync In" or "Toggle Polling" buttons in the Roblox Studio plugin to move code into your game.
|
||||
|
||||
### Sync Details
|
||||
The structure of files and folders on the filesystem are preserved when syncing into game.
|
||||
|
||||
Creation of Roblox instances follows a simple set of rules. The first rule that matches the file name is chosen:
|
||||
|
||||
| File Name | Instance Type | Notes |
|
||||
| -------------- | -------------- | ----------------------------------------- |
|
||||
| `*.server.lua` | `Script` | `Source` will contain the file's contents |
|
||||
| `*.client.lua` | `LocalScript` | `Source` will contain the file's contents |
|
||||
| `*.lua` | `ModuleScript` | `Source` will contain the file's contents |
|
||||
| `*.model.json` | *Varies* | See [this file](test-project/src/hello.model.json) for an example model |
|
||||
| `*` | `StringValue` | `Value` will contain the file's contents |
|
||||
|
||||
Any folders on the filesystem will turn into `Folder` objects unless they contain a file named `init.lua`, `init.server.lua`, or `init.client.lua`. Following the convention of Lua, those objects will instead be whatever the `init` file would turn into.
|
||||
|
||||
For example, this file tree:
|
||||
|
||||
* my-game
|
||||
* init.client.lua
|
||||
* foo.lua
|
||||
|
||||
Will turn into this tree in Roblox:
|
||||
|
||||
* `my-game` (`LocalScript` with source from `my-game/init.client.lua`)
|
||||
* `foo` (`ModuleScript` with source from `my-game/foo.lua`)
|
||||
|
||||
## Inspiration
|
||||
There are lots of other tools that sync scripts into Roblox, or otherwise work to improve the development flow outside of Roblox Studio.
|
||||
## Inspiration and Alternatives
|
||||
There are lots of other tools that sync scripts into Roblox or provide other tools for working with Roblox places.
|
||||
|
||||
Here are a few, if you're looking for alternatives or supplements to Rojo:
|
||||
* [Studio Bridge by Vocksel](https://github.com/vocksel/studio-bridge)
|
||||
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
|
||||
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
|
||||
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
|
||||
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
|
||||
|
||||
I also have a couple tools that Rojo intends to replace:
|
||||
* [rbxfs](https://github.com/LPGhatguy/rbxfs), which has been deprecated by Rojo
|
||||
* [rbxpacker](https://github.com/LPGhatguy/rbxpacker), which is still useful
|
||||
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
|
||||
* [Rofresh by Osyris](https://github.com/osyrisrblx/rofresh)
|
||||
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
|
||||
* [Studio Bridge by Vocksel](https://github.com/vocksel/studio-bridge)
|
||||
* [Elixir by Vocksel](https://github.com/vocksel/elixir)
|
||||
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
|
||||
* [CodeSync by MemoryPenguin](https://github.com/MemoryPenguin/CodeSync)
|
||||
* [rbx-exteditor by MemoryPenguin](https://github.com/MemoryPenguin/rbx-exteditor)
|
||||
|
||||
If you use a plugin that _isn't_ Rojo for syncing code, open an issue and let me know why! I'd like Rojo to be the end-all tool so that people stop reinventing solutions to this problem.
|
||||
|
||||
## Contributing
|
||||
The `master` branch is a rewrite known as **Epiphany**. It includes a breaking change to the project configuration format and an infrastructure overhaul.
|
||||
|
||||
Pull requests are welcome!
|
||||
|
||||
The `master` branch of both repositories have tests running on Travis for every commit and pull request. The test suite on `master` should always pass!
|
||||
|
||||
The Rojo and Rojo Plugin repositories should stay in sync with eachother, so that the current `master` of each repository can be used together.
|
||||
All pull requests are run against a test suite on Travis CI. That test suite should always pass!
|
||||
|
||||
## License
|
||||
Rojo is available under the terms of the MIT license. See [LICENSE.md](LICENSE.md) for details.
|
||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE](LICENSE) for details.
|
||||
BIN
assets/kenney-ui-gray-sheet.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
41
assets/kenney-ui-gray-sheet.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<TextureAtlas imagePath="sheet.png">
|
||||
<SubTexture name="grey_arrowDownGrey.png" x="78" y="498" width="15" height="10"/>
|
||||
<SubTexture name="grey_arrowDownWhite.png" x="123" y="496" width="15" height="10"/>
|
||||
<SubTexture name="grey_arrowUpGrey.png" x="108" y="498" width="15" height="10"/>
|
||||
<SubTexture name="grey_arrowUpWhite.png" x="93" y="498" width="15" height="10"/>
|
||||
<SubTexture name="grey_box.png" x="147" y="433" width="38" height="36"/>
|
||||
<SubTexture name="grey_boxCheckmark.png" x="147" y="469" width="38" height="36"/>
|
||||
<SubTexture name="grey_boxCross.png" x="185" y="433" width="38" height="36"/>
|
||||
<SubTexture name="grey_boxTick.png" x="190" y="198" width="36" height="36"/>
|
||||
<SubTexture name="grey_button00.png" x="0" y="143" width="190" height="45"/>
|
||||
<SubTexture name="grey_button01.png" x="0" y="188" width="190" height="49"/>
|
||||
<SubTexture name="grey_button02.png" x="0" y="98" width="190" height="45"/>
|
||||
<SubTexture name="grey_button03.png" x="0" y="331" width="190" height="49"/>
|
||||
<SubTexture name="grey_button04.png" x="0" y="286" width="190" height="45"/>
|
||||
<SubTexture name="grey_button05.png" x="0" y="0" width="195" height="49"/>
|
||||
<SubTexture name="grey_button06.png" x="0" y="49" width="191" height="49"/>
|
||||
<SubTexture name="grey_button07.png" x="195" y="0" width="49" height="49"/>
|
||||
<SubTexture name="grey_button08.png" x="240" y="49" width="49" height="49"/>
|
||||
<SubTexture name="grey_button09.png" x="98" y="433" width="49" height="45"/>
|
||||
<SubTexture name="grey_button10.png" x="191" y="49" width="49" height="49"/>
|
||||
<SubTexture name="grey_button11.png" x="0" y="433" width="49" height="45"/>
|
||||
<SubTexture name="grey_button12.png" x="244" y="0" width="49" height="49"/>
|
||||
<SubTexture name="grey_button13.png" x="49" y="433" width="49" height="45"/>
|
||||
<SubTexture name="grey_button14.png" x="0" y="384" width="190" height="49"/>
|
||||
<SubTexture name="grey_button15.png" x="0" y="237" width="190" height="49"/>
|
||||
<SubTexture name="grey_checkmarkGrey.png" x="99" y="478" width="21" height="20"/>
|
||||
<SubTexture name="grey_checkmarkWhite.png" x="78" y="478" width="21" height="20"/>
|
||||
<SubTexture name="grey_circle.png" x="185" y="469" width="36" height="36"/>
|
||||
<SubTexture name="grey_crossGrey.png" x="120" y="478" width="18" height="18"/>
|
||||
<SubTexture name="grey_crossWhite.png" x="190" y="318" width="18" height="18"/>
|
||||
<SubTexture name="grey_panel.png" x="190" y="98" width="100" height="100"/>
|
||||
<SubTexture name="grey_sliderDown.png" x="190" y="234" width="28" height="42"/>
|
||||
<SubTexture name="grey_sliderEnd.png" x="138" y="478" width="8" height="10"/>
|
||||
<SubTexture name="grey_sliderHorizontal.png" x="0" y="380" width="190" height="4"/>
|
||||
<SubTexture name="grey_sliderLeft.png" x="0" y="478" width="39" height="31"/>
|
||||
<SubTexture name="grey_sliderRight.png" x="39" y="478" width="39" height="31"/>
|
||||
<SubTexture name="grey_sliderUp.png" x="190" y="276" width="28" height="42"/>
|
||||
<SubTexture name="grey_sliderVertical.png" x="208" y="318" width="4" height="100"/>
|
||||
<SubTexture name="grey_tickGrey.png" x="190" y="336" width="17" height="17"/>
|
||||
<SubTexture name="grey_tickWhite.png" x="190" y="353" width="17" height="17"/>
|
||||
</TextureAtlas>
|
||||
BIN
assets/rojo-plugin-logo.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
assets/rojo-polling-icon.png
Normal file
|
After Width: | Height: | Size: 375 B |
BIN
assets/rojo-sync-in.png
Normal file
|
After Width: | Height: | Size: 382 B |
BIN
assets/rojo-test-icon.png
Normal file
|
After Width: | Height: | Size: 430 B |
39
design.gv
Normal file
@@ -0,0 +1,39 @@
|
||||
digraph G {
|
||||
graph [
|
||||
ranksep = "0.7",
|
||||
nodesep = "1.0",
|
||||
];
|
||||
node [
|
||||
fontname = "Hack",
|
||||
shape = "record",
|
||||
];
|
||||
|
||||
roblox_studio -> plugin [dir = "both"];
|
||||
plugin -> web_server [style = "dashed", dir = "both"];
|
||||
|
||||
web_server -> session;
|
||||
|
||||
session -> rbx_session;
|
||||
session -> fs_watcher;
|
||||
session -> message_queue;
|
||||
|
||||
fs_watcher -> imfs [weight = "10"];
|
||||
fs_watcher -> rbx_session [constraint = "false"];
|
||||
|
||||
imfs -> fs;
|
||||
|
||||
rbx_session -> imfs;
|
||||
rbx_session -> middlewares [weight = "10"];
|
||||
rbx_session -> message_queue [constraint = "false"];
|
||||
|
||||
plugin [label = "Studio Plugin"];
|
||||
roblox_studio [label = "Roblox Studio"];
|
||||
fs [label = "Filesystem"];
|
||||
fs_watcher [label = "Filesystem Watcher"];
|
||||
session [label = "Session"];
|
||||
web_server [label = "Web API"];
|
||||
imfs [label = "In-Memory Filesystem"];
|
||||
rbx_session [label = "RbxSession"];
|
||||
message_queue [label = "MessageQueue"];
|
||||
middlewares [label = "Middlewares"];
|
||||
}
|
||||
64
docs/getting-started/creating-a-place.md
Normal file
@@ -0,0 +1,64 @@
|
||||
To use Rojo to build a place, you'll need to create a new project file, which tells Rojo how your project is structured on-disk and in Roblox.
|
||||
|
||||
Create a new folder, then run `rojo init` inside that folder to initialize an empty project.
|
||||
|
||||
```sh
|
||||
mkdir my-new-project
|
||||
cd my-new-project
|
||||
|
||||
rojo init
|
||||
```
|
||||
|
||||
Rojo will make a small project file in your directory, named `roblox-project.json`. It'll make sure that any code in the directory `src` will get put into `ReplicatedStorage.Source`.
|
||||
|
||||
Speaking of, let's make sure we create a directory named `src`, and maybe a Lua file inside of it:
|
||||
|
||||
```sh
|
||||
mkdir src
|
||||
echo 'print("Hello, world!")' > src/hello.lua
|
||||
```
|
||||
|
||||
## Building Your Place
|
||||
Now that we have a project, one thing we can do is build a Roblox place file for our project. This is a great way to get started with a project quickly with no fuss.
|
||||
|
||||
All we have to do is call `rojo build`:
|
||||
|
||||
```sh
|
||||
rojo build -o MyNewProject.rbxl
|
||||
```
|
||||
|
||||
If you open `MyNewProject.rbxl` in Roblox Studio now, you should see a `Folder` containing a `ModuleScript` under `ReplicatedStorage`!
|
||||
|
||||
!!! info
|
||||
To generate an XML place file instead, like if you're checking the place file into version control, just use `rbxlx` as the extension on the output file instead.
|
||||
|
||||
## Live-Syncing into Studio
|
||||
Building a place file is great for the initial build, but for actively working on your place, you'll want something quicker.
|
||||
|
||||
In Roblox Studio, make sure the Rojo plugin is installed. If you need it, check out [the installation guide](installation) to learn how to install it.
|
||||
|
||||
To expose your project to the plugin, you'll need to _serve_ it from the command line:
|
||||
|
||||
```sh
|
||||
rojo serve
|
||||
```
|
||||
|
||||
This will start up a web server that tells Roblox Studio what instances are in your project and sends notifications if any of them change.
|
||||
|
||||
Note the port number, then switch into Roblox Studio and press the Rojo **Connect** button in the plugins tab. Type in the port number, if necessary, and press **Start**.
|
||||
|
||||
If everything went well, you should now be able to change files in the `src` directory and watch them sync into Roblox Studio in real time!
|
||||
|
||||
## Uploading Your Place
|
||||
Aimed at teams that want serious levels of automation, Rojo can upload places to Roblox.com automatically.
|
||||
|
||||
You'll need an existing place on Roblox.com as well as the `.ROBLOSECURITY` cookie of an account that has write access to that place.
|
||||
|
||||
!!! warning
|
||||
It's recommended that you set up a Roblox account dedicated to deploying your place instead of your personal account in case your security cookie is compromised.
|
||||
|
||||
Generating and uploading your place file is as simple as:
|
||||
|
||||
```sh
|
||||
rojo upload --asset_id [PLACE ID] --cookie "[SECURITY COOKIE]"
|
||||
```
|
||||
25
docs/getting-started/installation.md
Normal file
@@ -0,0 +1,25 @@
|
||||
Rojo has two components:
|
||||
|
||||
* The server, a binary written in Rust
|
||||
* The plugin, a Roblox Studio plugin written in Lua
|
||||
|
||||
It's important that the plugin and server are compatible. The plugin will show errors in the Roblox Studio Output window if there is a version mismatch.
|
||||
|
||||
## Installing the Server
|
||||
To install the server, either:
|
||||
|
||||
* If you have Rust installed, use `cargo install rojo`
|
||||
* Or, download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
||||
|
||||
**The Rojo binary must be run from the command line, like Terminal on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo binary on your `PATH` to make this easier.**
|
||||
|
||||
## Installing the Plugin
|
||||
To install the plugin, either:
|
||||
|
||||
* Install the plugin from the [Roblox plugin page](https://www.roblox.com/library/1211549683/Rojo).
|
||||
* This gives you less control over what version you install -- you will always have the latest version.
|
||||
* Or, download the latest release from [the GitHub releases section](https://github.com/LPGhatguy/rojo/releases) and install it into your Roblox plugins folder
|
||||
* You can open this folder by clicking the "Plugins Folder" button from the Plugins toolbar in Roblox Studio
|
||||
|
||||
## Visual Studio Code Extension
|
||||
If you use Visual Studio Code on Windows, you can install [Evaera's unofficial Rojo extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo), which will install both halves of Rojo for you. It even has a nifty UI to add partitions and start/stop the Rojo server!
|
||||
BIN
docs/images/connection-error.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
docs/images/plugin-buttons.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/images/sync-example.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
11
docs/index.md
Normal file
@@ -0,0 +1,11 @@
|
||||
This is the documentation home for Rojo.
|
||||
|
||||
Available versions of these docs:
|
||||
|
||||
* [0.5.x](https://lpghatguy.github.io/rojo/0.5.x)
|
||||
* [0.4.x](https://lpghatguy.github.io/rojo/0.4.x)
|
||||
* [`master` branch](https://lpghatguy.github.io/rojo/master)
|
||||
|
||||
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
|
||||
|
||||
This documentation is a continual work in progress. If you find any issues, please file an issue on [Rojo's issue tracker](https://github.com/LPGhatguy/rojo/issues)!
|
||||
59
docs/migrating-to-epiphany.md
Normal file
@@ -0,0 +1,59 @@
|
||||
Rojo underwent a large refactor during most of 2018 to enable a bunch of new features and lay groundwork for lots more in 2019. As such, Rojo **0.5.x** projects are not compatible with Rojo **0.4.x** projects.
|
||||
|
||||
## Supporting Both 0.4.x and 0.5.x
|
||||
Rojo 0.5.x uses a different name for its project format. While 0.4.x used `rojo.json`, 0.5.x uses `roblox-project.json`, which allows them to coexist.
|
||||
|
||||
If you aren't sure about upgrading or want to upgrade gradually, it's possible to keep both files in the same project without causing problems.
|
||||
|
||||
## Upgrading Your Project File
|
||||
Project files in 0.5.x are more explicit and flexible than they were in 0.4.x. Project files can now describe models and plugins in addition to places.
|
||||
|
||||
This new project file format also guards against two of the biggest pitfalls when writing a config file:
|
||||
|
||||
* Using a service as a partition target directly, which often wiped away extra instances
|
||||
* Defining two partitions that overlapped, which made Rojo act unpredictably
|
||||
|
||||
The biggest change is that the `partitions` field has been replaced with a new field, `tree`, that describes the entire hierarchy of your project from the top-down.
|
||||
|
||||
A project for 0.4.x that syncs from the `src` directory into `ReplicatedStorage.Source` would look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Rojo 0.4.x Example",
|
||||
"partitions": {
|
||||
"path": "src",
|
||||
"target": "ReplicatedStorage.Source"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In 0.5.x, the project format is more explicit:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Rojo 0.5.x Example",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"Source": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For each object in the tree, we define *metadata* and *children*.
|
||||
|
||||
Metadata begins with a dollar sign (`$`), like `$className`. This is so that children and metadata can coexist without creating too many nested layers.
|
||||
|
||||
All other values are considered children, where the key is the instance's name, and the value is an object, repeating the process.
|
||||
|
||||
## Migrating `.model.json` Files
|
||||
No upgrade path yet, stay tuned.
|
||||
|
||||
## Migrating Unknown Files
|
||||
If you used Rojo to sync in files as `StringValue` objects, you'll need to make sure those files end with the `txt` extension to preserve this in Rojo 0.5.x.
|
||||
|
||||
Unknown files are now ignored in Rojo instead of being converted to `StringValue` objects.
|
||||
3
docs/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
mkdocs
|
||||
mkdocs-material
|
||||
pymdown-extensions
|
||||
35
docs/sync-details.md
Normal file
@@ -0,0 +1,35 @@
|
||||
This page aims to describe how Rojo turns files on the filesystem into Roblox objects.
|
||||
|
||||
## Overview
|
||||
| File Name | Instance Type |
|
||||
| -------------- | ------------------- |
|
||||
| any directory | `Folder` |
|
||||
| `*.server.lua` | `Script` |
|
||||
| `*.client.lua` | `LocalScript` |
|
||||
| `*.lua` | `ModuleScript` |
|
||||
| `*.csv` | `LocalizationTable` |
|
||||
| `*.txt` | `StringValue` |
|
||||
|
||||
## Folders
|
||||
Any directory on the filesystem will turn into a `Folder` instance unless it contains an 'init' script, described below.
|
||||
|
||||
## Scripts
|
||||
The default script type in Rojo projects is `ModuleScript`, since most scripts in well-structued Roblox projects will be modules.
|
||||
|
||||
If a directory contains a file named `init.server.lua`, `init.client.lua`, or `init.lua`, that folder will be transformed into a `*Script` instance with the contents of the 'init' file. This can be used to create scripts inside of scripts.
|
||||
|
||||
For example, these files:
|
||||
|
||||
* my-game
|
||||
* init.client.lua
|
||||
* foo.lua
|
||||
|
||||
Will turn into these instances in Roblox:
|
||||
|
||||

|
||||
|
||||
## Localization Tables
|
||||
Any CSV files are transformed into `LocalizationTable` instances. Rojo expects these files to follow the same format that Roblox does when importing and exporting localization information.
|
||||
|
||||
## Plain Text Files
|
||||
Plain text files (`.txt`) files are transformed into `StringValue` instances. This is useful for bringing in text data that can be read by scripts at runtime.
|
||||
20
docs/why-rojo.md
Normal file
@@ -0,0 +1,20 @@
|
||||
There are a number of existing plugins for Roblox that move code from the filesystem into Roblox.
|
||||
|
||||
Besides Rojo, there is:
|
||||
|
||||
* [Studio Bridge](https://github.com/vocksel/studio-bridge) by [Vocksel](https://github.com/vocksel)
|
||||
* [RbxRefresh](https://github.com/osyrisrblx/RbxRefresh) by [Osyris](https://github.com/osyrisrblx)
|
||||
* [RbxSync](https://github.com/evaera/RbxSync) by [evaera](https://github.com/evaera)
|
||||
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
|
||||
* [rbxmk](https://github.com/anaminus/rbxmk) by [Anaminus](https://github.com/anaminus)
|
||||
|
||||
So why did I build Rojo?
|
||||
|
||||
Each of these tools solves what is essentially the same problem from a few different angles. The goal of Rojo is to take all of the lessons and ideas learned from these projects and build a tool that can solve the problem for good.
|
||||
|
||||
Additionally:
|
||||
|
||||
* I think that this tool needs to be built in a compiled language without a runtime, for easy distribution and good performance.
|
||||
* I think that the conventions promoted by other sync plugins (`.module.lua` for modules, as well a single sync point) are sub-optimal.
|
||||
* I think that I have a good enough understanding of the problem to build something robust.
|
||||
* I think that Rojo should be able to do more than just sync code.
|
||||
25
generate-docs
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Kludged documentation generator to support multiple versions.
|
||||
# Make sure the `site` folder is a checkout of this repository's `gh-pages`
|
||||
# branch.
|
||||
# To use, copy this file to `generate-docs.run` so that Git will leave it alone,
|
||||
# then run `generate-docs.run` in the root of the repository.
|
||||
|
||||
set -e
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
echo "Building 0.4.x"
|
||||
git checkout v0.4.x
|
||||
git pull
|
||||
mkdocs build --site-dir site/0.4.x
|
||||
|
||||
echo "Building master"
|
||||
git checkout master
|
||||
mkdocs build --site-dir site/master
|
||||
|
||||
echo "Building 0.5.x"
|
||||
mkdocs build --site-dir site/0.5.x
|
||||
|
||||
git checkout "$CURRENT_BRANCH"
|
||||
26
mkdocs.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
site_name: Rojo Documentation
|
||||
repo_name: LPGhatguy/rojo
|
||||
repo_url: https://github.com/LPGhatguy/rojo
|
||||
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
primary: 'Red'
|
||||
accent: 'Red'
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Why Rojo?: why-rojo.md
|
||||
- Getting Started:
|
||||
- Installation: getting-started/installation.md
|
||||
- Creating a Place with Rojo: getting-started/creating-a-place.md
|
||||
- Sync Details: sync-details.md
|
||||
- Migrating from 0.4.x to 0.5.x: migrating-to-epiphany.md
|
||||
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
- admonition
|
||||
- codehilite:
|
||||
guess_lang: false
|
||||
- toc:
|
||||
permalink: true
|
||||
1
plugin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/luacov.*
|
||||
56
plugin/.luacheckrc
Normal file
@@ -0,0 +1,56 @@
|
||||
stds.roblox = {
|
||||
read_globals = {
|
||||
game = {
|
||||
other_fields = true,
|
||||
},
|
||||
|
||||
-- Roblox globals
|
||||
"script",
|
||||
|
||||
-- Extra functions
|
||||
"tick", "warn", "spawn",
|
||||
"wait", "settings", "typeof",
|
||||
|
||||
-- Types
|
||||
"Vector2", "Vector3",
|
||||
"Color3",
|
||||
"UDim", "UDim2",
|
||||
"Rect",
|
||||
"CFrame",
|
||||
"Enum",
|
||||
"Instance",
|
||||
}
|
||||
}
|
||||
|
||||
stds.plugin = {
|
||||
read_globals = {
|
||||
"plugin",
|
||||
}
|
||||
}
|
||||
|
||||
stds.testez = {
|
||||
read_globals = {
|
||||
"describe",
|
||||
"it", "itFOCUS", "itSKIP",
|
||||
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||
"expect",
|
||||
}
|
||||
}
|
||||
|
||||
ignore = {
|
||||
"212", -- unused arguments
|
||||
"421", -- shadowing local variable
|
||||
"422", -- shadowing argument
|
||||
"431", -- shadowing upvalue
|
||||
"432", -- shadowing upvalue argument
|
||||
}
|
||||
|
||||
std = "lua51+roblox"
|
||||
|
||||
files["**/*.server.lua"] = {
|
||||
std = "+plugin",
|
||||
}
|
||||
|
||||
files["**/*.spec.lua"] = {
|
||||
std = "+testez",
|
||||
}
|
||||
8
plugin/.luacov
Normal file
@@ -0,0 +1,8 @@
|
||||
return {
|
||||
include = {
|
||||
"^src",
|
||||
},
|
||||
exclude = {
|
||||
"%.spec$",
|
||||
},
|
||||
}
|
||||
5
plugin/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Rojo Plugin
|
||||
|
||||
This is the source to the Rojo Roblox Studio plugin.
|
||||
|
||||
Documentation is WIP.
|
||||
37
plugin/loadEnvironment.lua
Normal file
@@ -0,0 +1,37 @@
|
||||
--[[
|
||||
Loads the Rojo plugin and all of its dependencies.
|
||||
]]
|
||||
|
||||
local function loadEnvironment()
|
||||
-- If you add any dependencies, add them to this table so they'll be loaded!
|
||||
local LOAD_MODULES = {
|
||||
{"src", "Rojo"},
|
||||
{"modules/promise/lib", "Promise"},
|
||||
{"modules/testez/lib", "TestEZ"},
|
||||
}
|
||||
|
||||
-- This makes sure we can load Lemur and other libraries that depend on init.lua
|
||||
package.path = package.path .. ";?/init.lua"
|
||||
|
||||
-- If this fails, make sure you've run `lua bin/install-dependencies.lua` first!
|
||||
local lemur = require("modules.lemur")
|
||||
|
||||
-- Create a virtual Roblox tree
|
||||
local habitat = lemur.Habitat.new()
|
||||
|
||||
-- We'll put all of our library code and dependencies here
|
||||
local modules = lemur.Instance.new("Folder")
|
||||
modules.Name = "Modules"
|
||||
modules.Parent = habitat.game:GetService("ReplicatedStorage")
|
||||
|
||||
-- Load all of the modules specified above
|
||||
for _, module in ipairs(LOAD_MODULES) do
|
||||
local container = habitat:loadFromFs(module[1])
|
||||
container.Name = module[2]
|
||||
container.Parent = modules
|
||||
end
|
||||
|
||||
return habitat, modules
|
||||
end
|
||||
|
||||
return loadEnvironment
|
||||
1
plugin/modules/lemur
Submodule
1
plugin/modules/promise
Submodule
1
plugin/modules/roact
Submodule
1
plugin/modules/roact-rodux
Submodule
1
plugin/modules/rodux
Submodule
1
plugin/modules/testez
Submodule
21
plugin/roblox-project.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Rojo",
|
||||
"tree": {
|
||||
"$className": "Folder",
|
||||
"Plugin": {
|
||||
"$path": "src"
|
||||
},
|
||||
"Roact": {
|
||||
"$path": "modules/roact/lib"
|
||||
},
|
||||
"Rodux": {
|
||||
"$path": "modules/rodux/lib"
|
||||
},
|
||||
"RoactRodux": {
|
||||
"$path": "modules/roact-rodux/lib"
|
||||
},
|
||||
"Promise": {
|
||||
"$path": "modules/promise/lib"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
plugin/rojo.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "rojo",
|
||||
"servePort": 8000,
|
||||
"partitions": {
|
||||
"plugin": {
|
||||
"path": "src",
|
||||
"target": "ReplicatedStorage.Rojo.Plugin"
|
||||
},
|
||||
"modules/roact": {
|
||||
"path": "modules/roact/lib",
|
||||
"target": "ReplicatedStorage.Rojo.Roact"
|
||||
},
|
||||
"modules/rodux": {
|
||||
"path": "modules/rodux/lib",
|
||||
"target": "ReplicatedStorage.Rojo.Rodux"
|
||||
},
|
||||
"modules/roact-rodux": {
|
||||
"path": "modules/roact-rodux/lib",
|
||||
"target": "ReplicatedStorage.Rojo.RoactRodux"
|
||||
},
|
||||
"modules/promise": {
|
||||
"path": "modules/promise/lib",
|
||||
"target": "ReplicatedStorage.Rojo.Promise"
|
||||
},
|
||||
"modules/testez": {
|
||||
"path": "modules/testez/lib",
|
||||
"target": "ReplicatedStorage.TestEZ"
|
||||
},
|
||||
"tests": {
|
||||
"path": "testBootstrap.server.lua",
|
||||
"target": "TestService.testBootstrap"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
plugin/runTest.lua
Normal file
@@ -0,0 +1,15 @@
|
||||
local loadEnvironment = require("loadEnvironment")
|
||||
|
||||
local testPath = assert((...), "Please specify a path to a test file.")
|
||||
|
||||
local habitat = loadEnvironment()
|
||||
|
||||
local testModule = habitat:loadFromFs(testPath)
|
||||
|
||||
if testModule == nil then
|
||||
error("Couldn't find test file at " .. testPath)
|
||||
end
|
||||
|
||||
print("Starting test module.")
|
||||
|
||||
habitat:require(testModule)
|
||||
17
plugin/spec.lua
Normal file
@@ -0,0 +1,17 @@
|
||||
--[[
|
||||
Loads our library and all of its dependencies, then runs tests using TestEZ.
|
||||
]]
|
||||
|
||||
local loadEnvironment = require("loadEnvironment")
|
||||
|
||||
local habitat, modules = loadEnvironment()
|
||||
|
||||
-- Load TestEZ and run our tests
|
||||
local TestEZ = habitat:require(modules.TestEZ)
|
||||
|
||||
local results = TestEZ.TestBootstrap:run({modules.Rojo}, TestEZ.Reporters.TextReporter)
|
||||
|
||||
-- Did something go wrong?
|
||||
if results.failureCount > 0 then
|
||||
os.exit(1)
|
||||
end
|
||||
145
plugin/src/ApiContext.lua
Normal file
@@ -0,0 +1,145 @@
|
||||
local Promise = require(script.Parent.Parent.Promise)
|
||||
|
||||
local Config = require(script.Parent.Config)
|
||||
local Version = require(script.Parent.Version)
|
||||
local Http = require(script.Parent.Http)
|
||||
local HttpError = require(script.Parent.HttpError)
|
||||
|
||||
local ApiContext = {}
|
||||
ApiContext.__index = ApiContext
|
||||
|
||||
-- TODO: Audit cases of errors and create enum values for each of them.
|
||||
ApiContext.Error = {
|
||||
ServerIdMismatch = "ServerIdMismatch",
|
||||
}
|
||||
|
||||
setmetatable(ApiContext.Error, {
|
||||
__index = function(_, key)
|
||||
error("Invalid ApiContext.Error name " .. key, 2)
|
||||
end
|
||||
})
|
||||
|
||||
function ApiContext.new(baseUrl)
|
||||
assert(type(baseUrl) == "string")
|
||||
|
||||
local self = {
|
||||
baseUrl = baseUrl,
|
||||
serverId = nil,
|
||||
rootInstanceId = nil,
|
||||
messageCursor = -1,
|
||||
partitionRoutes = nil,
|
||||
}
|
||||
|
||||
setmetatable(self, ApiContext)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function ApiContext:onMessage(callback)
|
||||
self.onMessageCallback = callback
|
||||
end
|
||||
|
||||
function ApiContext:connect()
|
||||
local url = ("%s/api/rojo"):format(self.baseUrl)
|
||||
|
||||
return Http.get(url)
|
||||
:andThen(function(response)
|
||||
local body = response:json()
|
||||
|
||||
if body.protocolVersion ~= Config.protocolVersion then
|
||||
local message = (
|
||||
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
|
||||
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
|
||||
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
|
||||
"\nYour server is version %s, with protocol version %s." ..
|
||||
"\n\nGo to https://github.com/LPGhatguy/rojo for more details."
|
||||
):format(
|
||||
Version.display(Config.version), Config.protocolVersion,
|
||||
Config.expectedApiContextVersionString,
|
||||
body.serverVersion, body.protocolVersion
|
||||
)
|
||||
|
||||
return Promise.reject(message)
|
||||
end
|
||||
|
||||
if body.expectedPlaceIds ~= nil then
|
||||
local foundId = false
|
||||
|
||||
for _, id in ipairs(body.expectedPlaceIds) do
|
||||
if id == game.PlaceId then
|
||||
foundId = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not foundId then
|
||||
local idList = {}
|
||||
for _, id in ipairs(body.expectedPlaceIds) do
|
||||
table.insert(idList, "- " .. tostring(id))
|
||||
end
|
||||
|
||||
local message = (
|
||||
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
|
||||
"\nYour place ID is %s, but needs to be one of these:" ..
|
||||
"\n%s" ..
|
||||
"\n\nTo change this list, edit 'servePlaceIds' in roblox-project.json"
|
||||
):format(
|
||||
tostring(game.PlaceId),
|
||||
table.concat(idList, "\n")
|
||||
)
|
||||
|
||||
return Promise.reject(message)
|
||||
end
|
||||
end
|
||||
|
||||
self.serverId = body.serverId
|
||||
self.partitionRoutes = body.partitions
|
||||
self.rootInstanceId = body.rootInstanceId
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:read(ids)
|
||||
local url = ("%s/api/read/%s"):format(self.baseUrl, table.concat(ids, ","))
|
||||
|
||||
return Http.get(url)
|
||||
:catch(function(err)
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
:andThen(function(response)
|
||||
local body = response:json()
|
||||
|
||||
if body.serverId ~= self.serverId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
self.messageCursor = body.messageCursor
|
||||
|
||||
return body
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:retrieveMessages()
|
||||
local url = ("%s/api/subscribe/%s"):format(self.baseUrl, self.messageCursor)
|
||||
|
||||
return Http.get(url)
|
||||
:catch(function(err)
|
||||
if err.type == HttpError.Error.Timeout then
|
||||
return self:retrieveMessages()
|
||||
end
|
||||
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
:andThen(function(response)
|
||||
local body = response:json()
|
||||
|
||||
if body.serverId ~= self.serverId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
self.messageCursor = body.messageCursor
|
||||
|
||||
return body.messages
|
||||
end)
|
||||
end
|
||||
|
||||
return ApiContext
|
||||
52
plugin/src/Assets.lua
Normal file
@@ -0,0 +1,52 @@
|
||||
local sheetAsset = "rbxassetid://2738712459"
|
||||
|
||||
local Assets = {
|
||||
Sprites = {
|
||||
WhiteCross = {
|
||||
asset = sheetAsset,
|
||||
offset = Vector2.new(190, 318),
|
||||
size = Vector2.new(18, 18),
|
||||
},
|
||||
},
|
||||
Slices = {
|
||||
GrayBox = {
|
||||
asset = sheetAsset,
|
||||
offset = Vector2.new(147, 433),
|
||||
size = Vector2.new(38, 36),
|
||||
center = Rect.new(8, 8, 9, 9),
|
||||
},
|
||||
GrayButton02 = {
|
||||
asset = sheetAsset,
|
||||
offset = Vector2.new(0, 98),
|
||||
size = Vector2.new(190, 45),
|
||||
center = Rect.new(16, 16, 17, 17),
|
||||
},
|
||||
GrayButton07 = {
|
||||
asset = sheetAsset,
|
||||
offset = Vector2.new(195, 0),
|
||||
size = Vector2.new(49, 49),
|
||||
center = Rect.new(16, 16, 17, 17),
|
||||
},
|
||||
},
|
||||
StartSession = "",
|
||||
SessionActive = "",
|
||||
Configure = "",
|
||||
}
|
||||
|
||||
local function guardForTypos(name, map)
|
||||
setmetatable(map, {
|
||||
__index = function(_, key)
|
||||
error(("%q is not a valid member of %s"):format(tostring(key), name), 2)
|
||||
end
|
||||
})
|
||||
|
||||
for key, child in pairs(map) do
|
||||
if type(child) == "table" then
|
||||
guardForTypos(("%s.%s"):format(name, key), child)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
guardForTypos("Assets", Assets)
|
||||
|
||||
return Assets
|
||||
194
plugin/src/Components/App.lua
Normal file
@@ -0,0 +1,194 @@
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Session = require(Plugin.Session)
|
||||
local Config = require(Plugin.Config)
|
||||
local Version = require(Plugin.Version)
|
||||
local Logging = require(Plugin.Logging)
|
||||
local DevSettings = require(Plugin.DevSettings)
|
||||
|
||||
local ConnectPanel = require(Plugin.Components.ConnectPanel)
|
||||
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local function showUpgradeMessage(lastVersion)
|
||||
local message = (
|
||||
"Rojo detected an upgrade from version %s to version %s." ..
|
||||
"\nMake sure you have also upgraded your server!" ..
|
||||
"\n\nRojo plugin version %s is intended for use with server version %s."
|
||||
):format(
|
||||
Version.display(lastVersion), Version.display(Config.version),
|
||||
Version.display(Config.version), Config.expectedServerVersionString
|
||||
)
|
||||
|
||||
Logging.info(message)
|
||||
end
|
||||
|
||||
--[[
|
||||
Check if the user is using a newer version of Rojo than last time. If they
|
||||
are, show them a reminder to make sure they check their server version.
|
||||
]]
|
||||
local function checkUpgrade(plugin)
|
||||
-- When developing Rojo, there's no use in doing version checks
|
||||
if DevSettings:isEnabled() then
|
||||
return
|
||||
end
|
||||
|
||||
local lastVersion = plugin:GetSetting("LastRojoVersion")
|
||||
|
||||
if lastVersion then
|
||||
local wasUpgraded = Version.compare(Config.version, lastVersion) == 1
|
||||
|
||||
if wasUpgraded then
|
||||
showUpgradeMessage(lastVersion)
|
||||
end
|
||||
end
|
||||
|
||||
plugin:SetSetting("LastRojoVersion", Config.version)
|
||||
end
|
||||
|
||||
local SessionStatus = {
|
||||
Disconnected = "Disconnected",
|
||||
Connected = "Connected",
|
||||
ConfiguringSession = "ConfiguringSession",
|
||||
-- TODO: Error?
|
||||
}
|
||||
|
||||
setmetatable(SessionStatus, {
|
||||
__index = function(_, key)
|
||||
error(("%q is not a valid member of SessionStatus"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:init()
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
|
||||
self.connectButton = nil
|
||||
self.configButton = nil
|
||||
self.currentSession = nil
|
||||
|
||||
self.displayedVersion = DevSettings:isEnabled()
|
||||
and Config.codename
|
||||
or Version.display(Config.version)
|
||||
end
|
||||
|
||||
function App:render()
|
||||
local children
|
||||
|
||||
if self.state.sessionStatus == SessionStatus.Connected then
|
||||
children = {
|
||||
ConnectionActivePanel = e(ConnectionActivePanel),
|
||||
}
|
||||
elseif self.state.sessionStatus == SessionStatus.ConfiguringSession then
|
||||
children = {
|
||||
ConnectPanel = e(ConnectPanel, {
|
||||
startSession = function(address, port)
|
||||
Logging.trace("Starting new session")
|
||||
|
||||
local success, session = Session.new({
|
||||
address = address,
|
||||
port = port,
|
||||
onError = function(message)
|
||||
Logging.warn("%s", tostring(message))
|
||||
Logging.trace("Session terminated due to error")
|
||||
self.currentSession = nil
|
||||
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
end
|
||||
})
|
||||
|
||||
if success then
|
||||
self.currentSession = session
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Connected,
|
||||
})
|
||||
end
|
||||
end,
|
||||
cancel = function()
|
||||
Logging.trace("Canceling session configuration")
|
||||
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}
|
||||
end
|
||||
|
||||
return e("ScreenGui", {
|
||||
AutoLocalize = false,
|
||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
}, children)
|
||||
end
|
||||
|
||||
function App:didMount()
|
||||
Logging.trace("Rojo %s initializing", self.displayedVersion)
|
||||
|
||||
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
|
||||
|
||||
self.connectButton = toolbar:CreateButton(
|
||||
"Connect",
|
||||
"Connect to a running Rojo session",
|
||||
Assets.StartSession)
|
||||
self.connectButton.ClickableWhenViewportHidden = false
|
||||
self.connectButton.Click:Connect(function()
|
||||
checkUpgrade(self.props.plugin)
|
||||
|
||||
if self.state.sessionStatus == SessionStatus.Connected then
|
||||
Logging.trace("Disconnecting session")
|
||||
|
||||
self.currentSession:disconnect()
|
||||
self.currentSession = nil
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
|
||||
Logging.trace("Session terminated by user")
|
||||
elseif self.state.sessionStatus == SessionStatus.Disconnected then
|
||||
Logging.trace("Starting session configuration")
|
||||
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.ConfiguringSession,
|
||||
})
|
||||
elseif self.state.sessionStatus == SessionStatus.ConfiguringSession then
|
||||
Logging.trace("Canceling session configuration")
|
||||
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
end
|
||||
end)
|
||||
|
||||
self.configButton = toolbar:CreateButton(
|
||||
"Configure",
|
||||
"Configure the Rojo plugin",
|
||||
Assets.Configure)
|
||||
self.configButton.ClickableWhenViewportHidden = false
|
||||
self.configButton.Click:Connect(function()
|
||||
self.configButton:SetActive(false)
|
||||
end)
|
||||
end
|
||||
|
||||
function App:didUpdate()
|
||||
local connectActive = self.state.sessionStatus == SessionStatus.ConfiguringSession
|
||||
or self.state.sessionStatus == SessionStatus.Connected
|
||||
|
||||
self.connectButton:SetActive(connectActive)
|
||||
|
||||
if self.state.sessionStatus == SessionStatus.Connected then
|
||||
self.connectButton.Icon = Assets.SessionActive
|
||||
else
|
||||
self.connectButton.Icon = Assets.StartSession
|
||||
end
|
||||
end
|
||||
|
||||
return App
|
||||
243
plugin/src/Components/ConnectPanel.lua
Normal file
@@ -0,0 +1,243 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Config = require(Plugin.Config)
|
||||
local Assets = require(Plugin.Assets)
|
||||
|
||||
local FitList = require(Plugin.Components.FitList)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
local FormButton = require(Plugin.Components.FormButton)
|
||||
local FormTextInput = require(Plugin.Components.FormTextInput)
|
||||
|
||||
local WhiteCross = Assets.Sprites.WhiteCross
|
||||
local GrayBox = Assets.Slices.GrayBox
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local TEXT_COLOR = Color3.new(0.05, 0.05, 0.05)
|
||||
local FORM_TEXT_SIZE = 20
|
||||
|
||||
local ConnectPanel = Roact.Component:extend("ConnectPanel")
|
||||
|
||||
function ConnectPanel:init()
|
||||
self.labelSizes = {}
|
||||
self.labelSize, self.setLabelSize = Roact.createBinding(Vector2.new())
|
||||
|
||||
self:setState({
|
||||
address = Config.defaultHost,
|
||||
port = Config.defaultPort,
|
||||
})
|
||||
end
|
||||
|
||||
function ConnectPanel:updateLabelSize(name, size)
|
||||
self.labelSizes[name] = size
|
||||
|
||||
local x = 0
|
||||
local y = 0
|
||||
|
||||
for _, size in pairs(self.labelSizes) do
|
||||
x = math.max(x, size.X)
|
||||
y = math.max(y, size.Y)
|
||||
end
|
||||
|
||||
self.setLabelSize(Vector2.new(x, y))
|
||||
end
|
||||
|
||||
function ConnectPanel:render()
|
||||
local startSession = self.props.startSession
|
||||
local cancel = self.props.cancel
|
||||
|
||||
return e(FitList, {
|
||||
containerKind = "ImageLabel",
|
||||
containerProps = {
|
||||
Image = GrayBox.asset,
|
||||
ImageRectOffset = GrayBox.offset,
|
||||
ImageRectSize = GrayBox.size,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
SliceCenter = GrayBox.center,
|
||||
BackgroundTransparency = 1,
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
},
|
||||
layoutProps = {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
},
|
||||
}, {
|
||||
Head = e("Frame", {
|
||||
LayoutOrder = 1,
|
||||
Size = UDim2.new(1, 0, 0, 36),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Padding = e("UIPadding", {
|
||||
PaddingTop = UDim.new(0, 8),
|
||||
PaddingBottom = UDim.new(0, 8),
|
||||
PaddingLeft = UDim.new(0, 8),
|
||||
PaddingRight = UDim.new(0, 8),
|
||||
}),
|
||||
|
||||
Title = e("TextLabel", {
|
||||
Font = Enum.Font.SourceSansBold,
|
||||
TextSize = 22,
|
||||
Text = "Start New Rojo Session",
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
BackgroundTransparency = 1,
|
||||
TextColor3 = TEXT_COLOR,
|
||||
}),
|
||||
|
||||
Close = e("ImageButton", {
|
||||
Image = WhiteCross.asset,
|
||||
ImageRectOffset = WhiteCross.offset,
|
||||
ImageRectSize = WhiteCross.size,
|
||||
Size = UDim2.new(0, 18, 0, 18),
|
||||
Position = UDim2.new(1, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(1, 0.5),
|
||||
ImageColor3 = TEXT_COLOR,
|
||||
BackgroundTransparency = 1,
|
||||
[Roact.Event.Activated] = function()
|
||||
cancel()
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
Border = e("Frame", {
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = Color3.new(0.7, 0.7, 0.7),
|
||||
Size = UDim2.new(1, -4, 0, 2),
|
||||
LayoutOrder = 2,
|
||||
}),
|
||||
|
||||
Body = e(FitList, {
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 3,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
paddingProps = {
|
||||
PaddingTop = UDim.new(0, 8),
|
||||
PaddingBottom = UDim.new(0, 8),
|
||||
PaddingLeft = UDim.new(0, 8),
|
||||
PaddingRight = UDim.new(0, 8),
|
||||
},
|
||||
}, {
|
||||
Address = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
MinSize = Vector2.new(0, 24),
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = Enum.Font.SourceSansBold,
|
||||
TextSize = FORM_TEXT_SIZE,
|
||||
Text = "Address",
|
||||
TextColor3 = TEXT_COLOR,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self:updateLabelSize("address", rbx.AbsoluteSize)
|
||||
end,
|
||||
}, {
|
||||
Sizing = e("UISizeConstraint", {
|
||||
MinSize = self.labelSize,
|
||||
}),
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
layoutOrder = 2,
|
||||
size = UDim2.new(0, 300, 0, 24),
|
||||
value = self.state.address,
|
||||
onValueChange = function(newValue)
|
||||
self:setState({
|
||||
address = newValue,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
Port = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
MinSize = Vector2.new(0, 24),
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = Enum.Font.SourceSansBold,
|
||||
TextSize = FORM_TEXT_SIZE,
|
||||
Text = "Port",
|
||||
TextColor3 = TEXT_COLOR,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self:updateLabelSize("port", rbx.AbsoluteSize)
|
||||
end,
|
||||
}, {
|
||||
Sizing = e("UISizeConstraint", {
|
||||
MinSize = self.labelSize,
|
||||
}),
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
layoutOrder = 2,
|
||||
size = UDim2.new(0, 300, 0, 24),
|
||||
value = self.state.port,
|
||||
onValueChange = function(newValue)
|
||||
self:setState({
|
||||
port = newValue,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
Buttons = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 3,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
}, {
|
||||
e(FormButton, {
|
||||
text = "Start",
|
||||
onClick = function()
|
||||
if startSession ~= nil then
|
||||
startSession(self.state.address, self.state.port)
|
||||
end
|
||||
end,
|
||||
}),
|
||||
|
||||
e(FormButton, {
|
||||
text = "Cancel",
|
||||
onClick = function()
|
||||
if cancel ~= nil then
|
||||
cancel()
|
||||
end
|
||||
end,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
end
|
||||
|
||||
return ConnectPanel
|
||||
39
plugin/src/Components/ConnectionActivePanel.lua
Normal file
@@ -0,0 +1,39 @@
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Assets = require(script.Parent.Parent.Assets)
|
||||
|
||||
local FitList = require(script.Parent.FitList)
|
||||
local FitText = require(script.Parent.FitText)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local GrayBox = Assets.Slices.GrayBox
|
||||
|
||||
local ConnectionActivePanel = Roact.Component:extend("ConnectionActivePanel")
|
||||
|
||||
function ConnectionActivePanel:render()
|
||||
return e(FitList, {
|
||||
containerKind = "ImageButton",
|
||||
containerProps = {
|
||||
Image = GrayBox.asset,
|
||||
ImageRectOffset = GrayBox.offset,
|
||||
ImageRectSize = GrayBox.size,
|
||||
SliceCenter = GrayBox.center,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
BackgroundTransparency = 1,
|
||||
Position = UDim2.new(0.5, 0, 0, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0),
|
||||
},
|
||||
}, {
|
||||
Text = e(FitText, {
|
||||
Padding = Vector2.new(12, 6),
|
||||
Font = Enum.Font.SourceSans,
|
||||
TextSize = 18,
|
||||
Text = "Rojo Connected",
|
||||
TextColor3 = Color3.new(0.05, 0.05, 0.05),
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return ConnectionActivePanel
|
||||
50
plugin/src/Components/FitList.lua
Normal file
@@ -0,0 +1,50 @@
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Dictionary = require(script.Parent.Parent.Dictionary)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local FitList = Roact.Component:extend("FitList")
|
||||
|
||||
function FitList:init()
|
||||
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
|
||||
end
|
||||
|
||||
function FitList:render()
|
||||
local containerKind = self.props.containerKind or "Frame"
|
||||
local containerProps = self.props.containerProps
|
||||
local layoutProps = self.props.layoutProps
|
||||
local paddingProps = self.props.paddingProps
|
||||
|
||||
local padding
|
||||
if paddingProps ~= nil then
|
||||
padding = e("UIPadding", paddingProps)
|
||||
end
|
||||
|
||||
local children = Dictionary.merge(self.props[Roact.Children], {
|
||||
["$Layout"] = e("UIListLayout", Dictionary.merge({
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
[Roact.Change.AbsoluteContentSize] = function(instance)
|
||||
local size = instance.AbsoluteContentSize
|
||||
|
||||
if paddingProps ~= nil then
|
||||
size = size + Vector2.new(
|
||||
paddingProps.PaddingLeft.Offset + paddingProps.PaddingRight.Offset,
|
||||
paddingProps.PaddingTop.Offset + paddingProps.PaddingBottom.Offset)
|
||||
end
|
||||
|
||||
self.setSize(UDim2.new(0, size.X, 0, size.Y))
|
||||
end,
|
||||
}, layoutProps)),
|
||||
|
||||
["$Padding"] = padding,
|
||||
})
|
||||
|
||||
local fullContainerProps = Dictionary.merge(containerProps, {
|
||||
Size = self.sizeBinding,
|
||||
})
|
||||
|
||||
return e(containerKind, fullContainerProps, children)
|
||||
end
|
||||
|
||||
return FitList
|
||||
52
plugin/src/Components/FitText.lua
Normal file
@@ -0,0 +1,52 @@
|
||||
local TextService = game:GetService("TextService")
|
||||
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Dictionary = require(script.Parent.Parent.Dictionary)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local FitText = Roact.Component:extend("FitText")
|
||||
|
||||
function FitText:init()
|
||||
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
|
||||
end
|
||||
|
||||
function FitText:render()
|
||||
local kind = self.props.Kind or "TextLabel"
|
||||
|
||||
local containerProps = Dictionary.merge(self.props, {
|
||||
Kind = Dictionary.None,
|
||||
Padding = Dictionary.None,
|
||||
MinSize = Dictionary.None,
|
||||
Size = self.sizeBinding
|
||||
})
|
||||
|
||||
return e(kind, containerProps)
|
||||
end
|
||||
|
||||
function FitText:didMount()
|
||||
self:updateTextMeasurements()
|
||||
end
|
||||
|
||||
function FitText:didUpdate()
|
||||
self:updateTextMeasurements()
|
||||
end
|
||||
|
||||
function FitText:updateTextMeasurements()
|
||||
local minSize = self.props.MinSize or Vector2.new(0, 0)
|
||||
local padding = self.props.Padding or Vector2.new(0, 0)
|
||||
|
||||
local text = self.props.Text or ""
|
||||
local font = self.props.Font or Enum.Font.Legacy
|
||||
local textSize = self.props.TextSize or 12
|
||||
|
||||
local measuredText = TextService:GetTextSize(text, textSize, font, Vector2.new(9e6, 9e6))
|
||||
local totalSize = UDim2.new(
|
||||
0, math.max(minSize.X, padding.X * 2 + measuredText.X),
|
||||
0, math.max(minSize.Y, padding.Y * 2 + measuredText.Y))
|
||||
|
||||
self.setSize(totalSize)
|
||||
end
|
||||
|
||||
return FitText
|
||||
49
plugin/src/Components/FormButton.lua
Normal file
@@ -0,0 +1,49 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
local FitList = require(Plugin.Components.FitList)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local GrayButton07 = Assets.Slices.GrayButton07
|
||||
|
||||
local function FormButton(props)
|
||||
local text = props.text
|
||||
local layoutOrder = props.layoutOrder
|
||||
local onClick = props.onClick
|
||||
|
||||
return e(FitList, {
|
||||
containerKind = "ImageButton",
|
||||
containerProps = {
|
||||
LayoutOrder = layoutOrder,
|
||||
BackgroundTransparency = 1,
|
||||
Image = GrayButton07.asset,
|
||||
ImageRectOffset = GrayButton07.offset,
|
||||
ImageRectSize = GrayButton07.size,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
SliceCenter = GrayButton07.center,
|
||||
|
||||
[Roact.Event.Activated] = function()
|
||||
if onClick ~= nil then
|
||||
onClick()
|
||||
end
|
||||
end,
|
||||
},
|
||||
}, {
|
||||
Text = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
Text = text,
|
||||
TextSize = 22,
|
||||
Font = Enum.Font.SourceSansBold,
|
||||
Padding = Vector2.new(14, 6),
|
||||
TextColor3 = Color3.new(0.05, 0.05, 0.05),
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return FormButton
|
||||
47
plugin/src/Components/FormTextInput.lua
Normal file
@@ -0,0 +1,47 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local GrayBox = Assets.Slices.GrayBox
|
||||
|
||||
local function FormTextInput(props)
|
||||
local value = props.value
|
||||
local onValueChange = props.onValueChange
|
||||
local layoutOrder = props.layoutOrder
|
||||
local size = props.size
|
||||
|
||||
return e("ImageLabel", {
|
||||
LayoutOrder = layoutOrder,
|
||||
Image = GrayBox.asset,
|
||||
ImageRectOffset = GrayBox.offset,
|
||||
ImageRectSize = GrayBox.size,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
SliceCenter = GrayBox.center,
|
||||
Size = size,
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
InputInner = e("TextBox", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(1, -8, 1, -8),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
Font = Enum.Font.SourceSans,
|
||||
ClearTextOnFocus = false,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 20,
|
||||
Text = value,
|
||||
TextColor3 = Color3.new(0.05, 0.05, 0.05),
|
||||
|
||||
[Roact.Change.Text] = function(rbx)
|
||||
onValueChange(rbx.Text)
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return FormTextInput
|
||||
8
plugin/src/Config.lua
Normal file
@@ -0,0 +1,8 @@
|
||||
return {
|
||||
codename = "Epiphany",
|
||||
version = {0, 5, 0, "-alpha.0"},
|
||||
expectedServerVersionString = "0.5.0 or newer",
|
||||
protocolVersion = 2,
|
||||
defaultHost = "localhost",
|
||||
defaultPort = 34872,
|
||||
}
|
||||
59
plugin/src/DevSettings.lua
Normal file
@@ -0,0 +1,59 @@
|
||||
local Config = require(script.Parent.Config)
|
||||
|
||||
local function getValueContainer()
|
||||
return game:FindFirstChild("RojoDev-" .. Config.codename)
|
||||
end
|
||||
|
||||
local valueContainer = getValueContainer()
|
||||
|
||||
local function getValue(name)
|
||||
if valueContainer == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
local valueObject = valueContainer:FindFirstChild(name)
|
||||
|
||||
if valueObject == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
return valueObject.Value
|
||||
end
|
||||
|
||||
local function setValue(name, kind, value)
|
||||
local object = valueContainer:FindFirstChild(name)
|
||||
|
||||
if object == nil then
|
||||
object = Instance.new(kind)
|
||||
object.Name = name
|
||||
object.Parent = valueContainer
|
||||
end
|
||||
|
||||
object.Value = value
|
||||
end
|
||||
|
||||
local function createAllValues()
|
||||
valueContainer = getValueContainer()
|
||||
|
||||
if valueContainer == nil then
|
||||
valueContainer = Instance.new("Folder")
|
||||
valueContainer.Name = "RojoDev-" .. Config.codename
|
||||
valueContainer.Parent = game
|
||||
end
|
||||
|
||||
setValue("LogLevel", "IntValue", getValue("LogLevel") or 2)
|
||||
end
|
||||
|
||||
_G[("ROJO_%s_DEV_CREATE"):format(Config.codename:upper())] = createAllValues
|
||||
|
||||
local DevSettings = {}
|
||||
|
||||
function DevSettings:isEnabled()
|
||||
return valueContainer ~= nil
|
||||
end
|
||||
|
||||
function DevSettings:getLogLevel()
|
||||
return getValue("LogLevel")
|
||||
end
|
||||
|
||||
return DevSettings
|
||||
33
plugin/src/Dictionary.lua
Normal file
@@ -0,0 +1,33 @@
|
||||
--[[
|
||||
This is a placeholder module waiting for Cryo to become available.
|
||||
]]
|
||||
|
||||
local None = newproxy(true)
|
||||
getmetatable(None).__tostring = function()
|
||||
return "None"
|
||||
end
|
||||
|
||||
local function merge(...)
|
||||
local output = {}
|
||||
|
||||
for i = 1, select("#", ...) do
|
||||
local source = select(i, ...)
|
||||
|
||||
if source ~= nil then
|
||||
for key, value in pairs(source) do
|
||||
if value == None then
|
||||
output[key] = nil
|
||||
else
|
||||
output[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return output
|
||||
end
|
||||
|
||||
return {
|
||||
None = None,
|
||||
merge = merge,
|
||||
}
|
||||
75
plugin/src/Http.lua
Normal file
@@ -0,0 +1,75 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Promise = require(script.Parent.Parent.Promise)
|
||||
|
||||
local Logging = require(script.Parent.Logging)
|
||||
local HttpError = require(script.Parent.HttpError)
|
||||
local HttpResponse = require(script.Parent.HttpResponse)
|
||||
|
||||
local lastRequestId = 0
|
||||
|
||||
-- TODO: Factor out into separate library, especially error handling
|
||||
local Http = {}
|
||||
|
||||
function Http.get(url)
|
||||
local requestId = lastRequestId + 1
|
||||
lastRequestId = requestId
|
||||
|
||||
Logging.trace("GET(%d) %s", requestId, url)
|
||||
|
||||
return Promise.new(function(resolve, reject)
|
||||
coroutine.wrap(function()
|
||||
local success, response = pcall(function()
|
||||
return HttpService:RequestAsync({
|
||||
Url = url,
|
||||
Method = "GET",
|
||||
})
|
||||
end)
|
||||
|
||||
if success then
|
||||
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
|
||||
resolve(HttpResponse.fromRobloxResponse(response))
|
||||
else
|
||||
Logging.trace("Request %d failure: %s", requestId, response)
|
||||
reject(HttpError.fromErrorString(response))
|
||||
end
|
||||
end)()
|
||||
end)
|
||||
end
|
||||
|
||||
function Http.post(url, body)
|
||||
local requestId = lastRequestId + 1
|
||||
lastRequestId = requestId
|
||||
|
||||
Logging.trace("POST(%d) %s\n%s", requestId, url, body)
|
||||
|
||||
return Promise.new(function(resolve, reject)
|
||||
coroutine.wrap(function()
|
||||
local success, response = pcall(function()
|
||||
return HttpService:RequestAsync({
|
||||
Url = url,
|
||||
Method = "POST",
|
||||
Body = body,
|
||||
})
|
||||
end)
|
||||
|
||||
if success then
|
||||
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
|
||||
resolve(HttpResponse.fromRobloxResponse(response))
|
||||
else
|
||||
Logging.trace("Request %d failure: %s", requestId, response)
|
||||
reject(HttpError.fromErrorString(response))
|
||||
end
|
||||
end)()
|
||||
end)
|
||||
end
|
||||
|
||||
function Http.jsonEncode(object)
|
||||
return HttpService:JSONEncode(object)
|
||||
end
|
||||
|
||||
function Http.jsonDecode(source)
|
||||
return HttpService:JSONDecode(source)
|
||||
end
|
||||
|
||||
return Http
|
||||
72
plugin/src/HttpError.lua
Normal file
@@ -0,0 +1,72 @@
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
local HttpError = {}
|
||||
HttpError.__index = HttpError
|
||||
|
||||
HttpError.Error = {
|
||||
HttpNotEnabled = {
|
||||
message = "Rojo requires HTTP access, which is not enabled.\n" ..
|
||||
"Check your game settings, located in the 'Home' tab of Studio.",
|
||||
},
|
||||
ConnectFailed = {
|
||||
message = "Couldn't connect to the Rojo server.\n" ..
|
||||
"Make sure the server is running -- use 'rojo serve' to run it!",
|
||||
},
|
||||
Timeout = {
|
||||
message = "Request timed out.",
|
||||
},
|
||||
Unknown = {
|
||||
message = "Unknown error: {{message}}",
|
||||
},
|
||||
}
|
||||
|
||||
setmetatable(HttpError.Error, {
|
||||
__index = function(_, key)
|
||||
error(("%q is not a valid member of HttpError.Error"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
|
||||
function HttpError.new(type, extraMessage)
|
||||
extraMessage = extraMessage or ""
|
||||
local message = type.message:gsub("{{message}}", extraMessage)
|
||||
|
||||
local err = {
|
||||
type = type,
|
||||
message = message,
|
||||
}
|
||||
|
||||
setmetatable(err, HttpError)
|
||||
|
||||
return err
|
||||
end
|
||||
|
||||
function HttpError:__tostring()
|
||||
return self.message
|
||||
end
|
||||
|
||||
--[[
|
||||
This method shouldn't have to exist. Ugh.
|
||||
]]
|
||||
function HttpError.fromErrorString(message)
|
||||
local lower = message:lower()
|
||||
|
||||
if lower:find("^http requests are not enabled") then
|
||||
return HttpError.new(HttpError.Error.HttpNotEnabled)
|
||||
end
|
||||
|
||||
if lower:find("^httperror: timedout") then
|
||||
return HttpError.new(HttpError.Error.Timeout)
|
||||
end
|
||||
|
||||
if lower:find("^httperror: connectfail") then
|
||||
return HttpError.new(HttpError.Error.ConnectFailed)
|
||||
end
|
||||
|
||||
return HttpError.new(HttpError.Error.Unknown, message)
|
||||
end
|
||||
|
||||
function HttpError:report()
|
||||
Logging.warn(self.message)
|
||||
end
|
||||
|
||||
return HttpError
|
||||
34
plugin/src/HttpResponse.lua
Normal file
@@ -0,0 +1,34 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local stringTemplate = [[
|
||||
HttpResponse {
|
||||
code: %d
|
||||
body: %s
|
||||
}]]
|
||||
|
||||
local HttpResponse = {}
|
||||
HttpResponse.__index = HttpResponse
|
||||
|
||||
function HttpResponse:__tostring()
|
||||
return stringTemplate:format(self.code, self.body)
|
||||
end
|
||||
|
||||
function HttpResponse.fromRobloxResponse(response)
|
||||
local self = {
|
||||
body = response.Body,
|
||||
code = response.StatusCode,
|
||||
headers = response.Headers,
|
||||
}
|
||||
|
||||
return setmetatable(self, HttpResponse)
|
||||
end
|
||||
|
||||
function HttpResponse:isSuccess()
|
||||
return self.code >= 200 and self.code < 300
|
||||
end
|
||||
|
||||
function HttpResponse:json()
|
||||
return HttpService:JSONDecode(self.body)
|
||||
end
|
||||
|
||||
return HttpResponse
|
||||
55
plugin/src/Logging.lua
Normal file
@@ -0,0 +1,55 @@
|
||||
local DevSettings = require(script.Parent.DevSettings)
|
||||
|
||||
local testLogLevel = nil
|
||||
|
||||
local Level = {
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Info = 2,
|
||||
Trace = 3,
|
||||
}
|
||||
|
||||
local function getLogLevel()
|
||||
if testLogLevel ~= nil then
|
||||
return testLogLevel
|
||||
end
|
||||
|
||||
local devValue = DevSettings:getLogLevel()
|
||||
if devValue ~= nil then
|
||||
return devValue
|
||||
end
|
||||
|
||||
return Level.Info
|
||||
end
|
||||
|
||||
local function addTags(tag, message)
|
||||
return tag .. message:gsub("\n", "\n" .. tag)
|
||||
end
|
||||
|
||||
local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] "
|
||||
local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] "
|
||||
local WARN_TAG = "[Rojo-Warn] "
|
||||
|
||||
local Log = {}
|
||||
|
||||
Log.Level = Level
|
||||
|
||||
function Log.trace(template, ...)
|
||||
if getLogLevel() >= Level.Trace then
|
||||
print(addTags(TRACE_TAG, string.format(template, ...)))
|
||||
end
|
||||
end
|
||||
|
||||
function Log.info(template, ...)
|
||||
if getLogLevel() >= Level.Info then
|
||||
print(addTags(INFO_TAG, string.format(template, ...)))
|
||||
end
|
||||
end
|
||||
|
||||
function Log.warn(template, ...)
|
||||
if getLogLevel() >= Level.Warning then
|
||||
warn(addTags(WARN_TAG, string.format(template, ...)))
|
||||
end
|
||||
end
|
||||
|
||||
return Log
|
||||
281
plugin/src/Reconciler.lua
Normal file
@@ -0,0 +1,281 @@
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
local function makeInstanceMap()
|
||||
local self = {
|
||||
fromIds = {},
|
||||
fromInstances = {},
|
||||
}
|
||||
|
||||
function self:insert(id, instance)
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
end
|
||||
|
||||
function self:removeId(id)
|
||||
local instance = self.fromIds[id]
|
||||
|
||||
if instance ~= nil then
|
||||
self.fromIds[id] = nil
|
||||
self.fromInstances[instance] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
function self:removeInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self.fromInstances[instance] = nil
|
||||
self.fromIds[id] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function self:destroyId(id)
|
||||
local instance = self.fromIds[id]
|
||||
self:removeId(id)
|
||||
|
||||
if instance ~= nil then
|
||||
local descendantsToDestroy = {}
|
||||
|
||||
for otherInstance in pairs(self.fromInstances) do
|
||||
if otherInstance:IsDescendantOf(instance) then
|
||||
table.insert(descendantsToDestroy, otherInstance)
|
||||
end
|
||||
end
|
||||
|
||||
for _, otherInstance in ipairs(descendantsToDestroy) do
|
||||
self:removeInstance(otherInstance)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
else
|
||||
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
local function setProperty(instance, key, value)
|
||||
-- The 'Contents' property of LocalizationTable isn't directly exposed, but
|
||||
-- has corresponding (deprecated) getters and setters.
|
||||
if key == "Contents" and instance.ClassName == "LocalizationTable" then
|
||||
instance:SetContents(value)
|
||||
return
|
||||
end
|
||||
|
||||
-- If we don't have permissions to access this value at all, we can skip it.
|
||||
local readSuccess, existingValue = pcall(function()
|
||||
return instance[key]
|
||||
end)
|
||||
|
||||
if not readSuccess then
|
||||
-- An error will be thrown if there was a permission issue or if the
|
||||
-- property doesn't exist. In the latter case, we should tell the user
|
||||
-- because it's probably their fault.
|
||||
if existingValue:find("lacking permission") then
|
||||
Logging.trace("Permission error reading property %s on class %s", tostring(key), instance.ClassName)
|
||||
return
|
||||
else
|
||||
error(("Invalid property %s on class %s: %s"):format(tostring(key), instance.ClassName, existingValue), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local writeSuccess, err = pcall(function()
|
||||
if existingValue ~= value then
|
||||
instance[key] = value
|
||||
end
|
||||
end)
|
||||
|
||||
if not writeSuccess then
|
||||
error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local Reconciler = {}
|
||||
Reconciler.__index = Reconciler
|
||||
|
||||
function Reconciler.new()
|
||||
local self = {
|
||||
instanceMap = makeInstanceMap(),
|
||||
}
|
||||
|
||||
return setmetatable(self, Reconciler)
|
||||
end
|
||||
|
||||
function Reconciler:applyUpdate(requestedIds, virtualInstancesById)
|
||||
-- This function may eventually be asynchronous; it will require calls to
|
||||
-- the server to resolve instances that don't exist yet.
|
||||
local visitedIds = {}
|
||||
|
||||
for _, id in ipairs(requestedIds) do
|
||||
self:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Update an existing instance, including its properties and children, to match
|
||||
the given information.
|
||||
]]
|
||||
function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
-- If an instance changes ClassName, we assume it's very different. That's
|
||||
-- not always the case!
|
||||
if virtualInstance.ClassName ~= instance.ClassName then
|
||||
-- TODO: Preserve existing children instead?
|
||||
local parent = instance.Parent
|
||||
self.instanceMap:destroyId(id)
|
||||
return self:__reify(virtualInstancesById, id, parent)
|
||||
end
|
||||
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
-- Some instances don't like being named, even if their name already matches
|
||||
setProperty(instance, "Name", virtualInstance.Name)
|
||||
|
||||
for key, value in pairs(virtualInstance.Properties) do
|
||||
setProperty(instance, key, value.Value)
|
||||
end
|
||||
|
||||
local existingChildren = instance:GetChildren()
|
||||
|
||||
local unvisitedExistingChildren = {}
|
||||
for _, child in ipairs(existingChildren) do
|
||||
unvisitedExistingChildren[child] = true
|
||||
end
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
local childData = virtualInstancesById[childId]
|
||||
|
||||
local existingChildInstance
|
||||
for instance in pairs(unvisitedExistingChildren) do
|
||||
local ok, name, className = pcall(function()
|
||||
return instance.Name, instance.ClassName
|
||||
end)
|
||||
|
||||
if ok then
|
||||
if name == childData.Name and className == childData.ClassName then
|
||||
existingChildInstance = instance
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if existingChildInstance ~= nil then
|
||||
unvisitedExistingChildren[existingChildInstance] = nil
|
||||
self:reconcile(virtualInstancesById, childId, existingChildInstance)
|
||||
else
|
||||
self:__reify(virtualInstancesById, childId, instance)
|
||||
end
|
||||
end
|
||||
|
||||
if self:__shouldClearUnknownInstances(virtualInstance) then
|
||||
for existingChildInstance in pairs(unvisitedExistingChildren) do
|
||||
self.instanceMap:removeInstance(existingChildInstance)
|
||||
existingChildInstance:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- The root instance of a project won't have a parent, like the DataModel,
|
||||
-- so we need to be careful here.
|
||||
if virtualInstance.Parent ~= nil then
|
||||
local parent = self.instanceMap.fromIds[virtualInstance.Parent]
|
||||
|
||||
if parent == nil then
|
||||
Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent))
|
||||
error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.")
|
||||
end
|
||||
|
||||
-- Some instances, like services, don't like having their Parent
|
||||
-- property poked, even if we're setting it to the same value.
|
||||
setProperty(instance, "Parent", parent)
|
||||
if instance.Parent ~= parent then
|
||||
instance.Parent = parent
|
||||
end
|
||||
end
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
function Reconciler:__shouldClearUnknownInstances(virtualInstance)
|
||||
if virtualInstance.Metadata ~= nil then
|
||||
return not virtualInstance.Metadata.ignoreUnknownInstances
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
function Reconciler:__reify(virtualInstancesById, id, parent)
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
local instance = Instance.new(virtualInstance.ClassName)
|
||||
|
||||
for key, value in pairs(virtualInstance.Properties) do
|
||||
-- TODO: Branch on value.Type
|
||||
setProperty(instance, key, value.Value)
|
||||
end
|
||||
|
||||
instance.Name = virtualInstance.Name
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
self:__reify(virtualInstancesById, childId, instance)
|
||||
end
|
||||
|
||||
setProperty(instance, "Parent", parent)
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
|
||||
if visitedIds[id] then
|
||||
return
|
||||
end
|
||||
|
||||
visitedIds[id] = true
|
||||
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
local instance = self.instanceMap.fromIds[id]
|
||||
|
||||
-- The instance was deleted in this update
|
||||
if virtualInstance == nil then
|
||||
self.instanceMap:destroyId(id)
|
||||
return
|
||||
end
|
||||
|
||||
-- An instance we know about was updated
|
||||
if instance ~= nil then
|
||||
self:reconcile(virtualInstancesById, id, instance)
|
||||
return instance
|
||||
end
|
||||
|
||||
-- If the instance's parent already exists, we can stick it there
|
||||
local parentInstance = self.instanceMap.fromIds[virtualInstance.Parent]
|
||||
if parentInstance ~= nil then
|
||||
self:__reify(virtualInstancesById, id, parentInstance)
|
||||
return
|
||||
end
|
||||
|
||||
-- Otherwise, we can check if this response payload contained the parent and
|
||||
-- work from there instead.
|
||||
local parentData = virtualInstancesById[virtualInstance.Parent]
|
||||
if parentData ~= nil then
|
||||
if visitedIds[virtualInstance.Parent] then
|
||||
error("Rojo bug: An instance was present and marked as visited but its instance was missing")
|
||||
end
|
||||
|
||||
self:__applyUpdatePiece(virtualInstance.Parent, visitedIds, virtualInstancesById)
|
||||
return
|
||||
end
|
||||
|
||||
Logging.trace("Instance ID %s, parent ID %s", tostring(id), tostring(virtualInstance.Parent))
|
||||
error("Rojo NYI: Instances with parents that weren't mentioned in an update payload")
|
||||
end
|
||||
|
||||
return Reconciler
|
||||
97
plugin/src/Session.lua
Normal file
@@ -0,0 +1,97 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
|
||||
local Promise = require(Rojo.Promise)
|
||||
|
||||
local ApiContext = require(script.Parent.ApiContext)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
local Session = {}
|
||||
Session.__index = Session
|
||||
|
||||
function Session.new(config)
|
||||
local remoteUrl = ("http://%s:%s"):format(config.address, config.port)
|
||||
local api = ApiContext.new(remoteUrl)
|
||||
|
||||
local self = {
|
||||
onError = config.onError,
|
||||
disconnected = false,
|
||||
reconciler = Reconciler.new(),
|
||||
api = api,
|
||||
}
|
||||
|
||||
api:connect()
|
||||
:andThen(function()
|
||||
if self.disconnected then
|
||||
return Promise.resolve()
|
||||
end
|
||||
|
||||
return api:read({api.rootInstanceId})
|
||||
:andThen(function(response)
|
||||
if self.disconnected then
|
||||
return Promise.resolve()
|
||||
end
|
||||
|
||||
self.reconciler:reconcile(response.instances, api.rootInstanceId, game)
|
||||
return self:__processMessages()
|
||||
end)
|
||||
end)
|
||||
:catch(function(message)
|
||||
self.disconnected = true
|
||||
self.onError(message)
|
||||
end)
|
||||
|
||||
return not self.disconnected, setmetatable(self, Session)
|
||||
end
|
||||
|
||||
function Session:__processMessages()
|
||||
if self.disconnected then
|
||||
return Promise.resolve()
|
||||
end
|
||||
|
||||
return self.api:retrieveMessages()
|
||||
:andThen(function(messages)
|
||||
local promise = Promise.resolve(nil)
|
||||
|
||||
for _, message in ipairs(messages) do
|
||||
promise = promise:andThen(function()
|
||||
return self:__onMessage(message)
|
||||
end)
|
||||
end
|
||||
|
||||
return promise
|
||||
end)
|
||||
:andThen(function()
|
||||
return self:__processMessages()
|
||||
end)
|
||||
end
|
||||
|
||||
function Session:__onMessage(message)
|
||||
if self.disconnected then
|
||||
return Promise.resolve()
|
||||
end
|
||||
|
||||
local requestedIds = {}
|
||||
|
||||
for _, id in ipairs(message.added) do
|
||||
table.insert(requestedIds, id)
|
||||
end
|
||||
|
||||
for _, id in ipairs(message.updated) do
|
||||
table.insert(requestedIds, id)
|
||||
end
|
||||
|
||||
for _, id in ipairs(message.removed) do
|
||||
table.insert(requestedIds, id)
|
||||
end
|
||||
|
||||
return self.api:read(requestedIds)
|
||||
:andThen(function(response)
|
||||
return self.reconciler:applyUpdate(requestedIds, response.instances)
|
||||
end)
|
||||
end
|
||||
|
||||
function Session:disconnect()
|
||||
self.disconnected = true
|
||||
end
|
||||
|
||||
return Session
|
||||
46
plugin/src/Version.lua
Normal file
@@ -0,0 +1,46 @@
|
||||
local function compare(a, b)
|
||||
if a > b then
|
||||
return 1
|
||||
elseif a < b then
|
||||
return -1
|
||||
end
|
||||
|
||||
return 0
|
||||
end
|
||||
|
||||
local Version = {}
|
||||
|
||||
--[[
|
||||
Compares two versions of the form {major, minor, revision}.
|
||||
|
||||
If a is newer than b, 1.
|
||||
If a is older than b, -1.
|
||||
If a and b are the same, 0.
|
||||
]]
|
||||
function Version.compare(a, b)
|
||||
local major = compare(a[1], b[1])
|
||||
local minor = compare(a[2] or 0, b[2] or 0)
|
||||
local revision = compare(a[3] or 0, b[3] or 0)
|
||||
|
||||
if major ~= 0 then
|
||||
return major
|
||||
end
|
||||
|
||||
if minor ~= 0 then
|
||||
return minor
|
||||
end
|
||||
|
||||
return revision
|
||||
end
|
||||
|
||||
function Version.display(version)
|
||||
local output = ("%d.%d.%d"):format(version[1], version[2], version[3])
|
||||
|
||||
if version[4] ~= nil then
|
||||
output = output .. version[4]
|
||||
end
|
||||
|
||||
return output
|
||||
end
|
||||
|
||||
return Version
|
||||
28
plugin/src/Version.spec.lua
Normal file
@@ -0,0 +1,28 @@
|
||||
return function()
|
||||
local Version = require(script.Parent.Version)
|
||||
|
||||
it("should compare equal versions", function()
|
||||
expect(Version.compare({1, 2, 3}, {1, 2, 3})).to.equal(0)
|
||||
expect(Version.compare({0, 4, 0}, {0, 4})).to.equal(0)
|
||||
expect(Version.compare({0, 0, 123}, {0, 0, 123})).to.equal(0)
|
||||
expect(Version.compare({26}, {26})).to.equal(0)
|
||||
expect(Version.compare({26, 42}, {26, 42})).to.equal(0)
|
||||
expect(Version.compare({1, 0, 0}, {1})).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should compare newer, older versions", function()
|
||||
expect(Version.compare({1}, {0})).to.equal(1)
|
||||
expect(Version.compare({1, 1}, {1, 0})).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should compare different major versions", function()
|
||||
expect(Version.compare({1, 3, 2}, {2, 2, 1})).to.equal(-1)
|
||||
expect(Version.compare({1, 2}, {2, 1})).to.equal(-1)
|
||||
expect(Version.compare({1}, {2})).to.equal(-1)
|
||||
end)
|
||||
|
||||
it("should compare different minor versions", function()
|
||||
expect(Version.compare({1, 2, 3}, {1, 3, 2})).to.equal(-1)
|
||||
expect(Version.compare({50, 1}, {50, 2})).to.equal(-1)
|
||||
end)
|
||||
end
|
||||
19
plugin/src/init.server.lua
Normal file
@@ -0,0 +1,19 @@
|
||||
if not plugin then
|
||||
return
|
||||
end
|
||||
|
||||
local Roact = require(script.Parent.Roact)
|
||||
|
||||
Roact.setGlobalConfig({
|
||||
elementTracing = true,
|
||||
})
|
||||
|
||||
local App = require(script.Components.App)
|
||||
|
||||
local app = Roact.createElement(App, {
|
||||
plugin = plugin,
|
||||
})
|
||||
|
||||
Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
|
||||
|
||||
-- TODO: Detect another instance of Rojo coming online and shut down this one.
|
||||
4
plugin/src/runTests.lua
Normal file
@@ -0,0 +1,4 @@
|
||||
return function()
|
||||
local TestEZ = require(script.Parent.Parent.TestEZ)
|
||||
TestEZ.TestBootstrap:run(script.Parent)
|
||||
end
|
||||
38
plugin/test-plugin-project.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "rojo",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"Rojo": {
|
||||
"$className": "Folder",
|
||||
"Plugin": {
|
||||
"$path": "src"
|
||||
},
|
||||
"Roact": {
|
||||
"$path": "modules/roact/lib"
|
||||
},
|
||||
"Rodux": {
|
||||
"$path": "modules/rodux/lib"
|
||||
},
|
||||
"RoactRodux": {
|
||||
"$path": "modules/roact-rodux/lib"
|
||||
},
|
||||
"Promise": {
|
||||
"$path": "modules/promise/lib"
|
||||
}
|
||||
},
|
||||
"TestEZ": {
|
||||
"$path": "modules/testez/lib"
|
||||
}
|
||||
},
|
||||
|
||||
"TestService": {
|
||||
"$className": "TestService",
|
||||
"TestBootstrap": {
|
||||
"$path": "testBootstrap.server.lua"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
plugin/testBootstrap.server.lua
Normal file
@@ -0,0 +1,2 @@
|
||||
local TestEZ = require(game.ReplicatedStorage.TestEZ)
|
||||
TestEZ.TestBootstrap:run(game.ReplicatedStorage.Rojo.plugin)
|
||||
5
plugin/tests/empty.lua
Normal file
@@ -0,0 +1,5 @@
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Session = require(ReplicatedStorage.Modules.Rojo.Session)
|
||||
|
||||
Session.new()
|
||||
6
rojo-e2e/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "rojo-e2e"
|
||||
version = "0.1.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
|
||||
[dependencies]
|
||||
2
rojo-e2e/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Rojo End-to-End
|
||||
This is a WIP test runner designed for Rojo. It will eventually start up the Rojo server and plugin and test functionality end-to-end.
|
||||
32
rojo-e2e/src/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
process::Command,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let plugin_path = Path::new("../plugin");
|
||||
let server_path = Path::new("../server");
|
||||
let tests_path = Path::new("../tests");
|
||||
|
||||
let server = Command::new("cargo")
|
||||
.args(&["run", "--", "serve", "../test-projects/empty"])
|
||||
.current_dir(server_path)
|
||||
.spawn();
|
||||
|
||||
thread::sleep(Duration::from_millis(1000));
|
||||
|
||||
// TODO: Wait for server to start responding on the right port
|
||||
|
||||
let test_client = Command::new("lua")
|
||||
.args(&["runTest.lua", "tests/empty.lua"])
|
||||
.current_dir(plugin_path)
|
||||
.spawn();
|
||||
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
|
||||
// TODO: Collect output from the client for success/failure?
|
||||
|
||||
println!("Dying!");
|
||||
}
|
||||
45
server/Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "0.5.0-alpha.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "A tool to create robust Roblox projects"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/LPGhatguy/rojo"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "librojo"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rojo"
|
||||
path = "src/bin.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bundle-plugin = []
|
||||
|
||||
[dependencies]
|
||||
clap = "2.27"
|
||||
csv = "1.0"
|
||||
env_logger = "0.5"
|
||||
failure = "0.1.3"
|
||||
log = "0.4"
|
||||
maplit = "1.0.1"
|
||||
notify = "4.0"
|
||||
rand = "0.4"
|
||||
regex = "1.0"
|
||||
reqwest = "0.9.5"
|
||||
rouille = "2.1"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.7", features = ["v4", "serde"] }
|
||||
rbx_tree = "0.1.0"
|
||||
rbx_xml = "0.1.0"
|
||||
rbx_binary = "0.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
walkdir = "2.1"
|
||||
lazy_static = "1.2"
|
||||
4
server/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Rojo Server
|
||||
This is the source to the Rojo server.
|
||||
|
||||
Documentation is WIP.
|
||||
172
server/src/bin.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
#[macro_use] extern crate log;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
env,
|
||||
process,
|
||||
};
|
||||
|
||||
use clap::clap_app;
|
||||
|
||||
use librojo::commands;
|
||||
|
||||
fn make_path_absolute(value: &Path) -> PathBuf {
|
||||
if value.is_absolute() {
|
||||
PathBuf::from(value)
|
||||
} else {
|
||||
let current_dir = env::current_dir().unwrap();
|
||||
current_dir.join(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_default_env()
|
||||
.default_format_timestamp(false)
|
||||
.init();
|
||||
|
||||
let mut app = clap_app!(Rojo =>
|
||||
(version: env!("CARGO_PKG_VERSION"))
|
||||
(author: env!("CARGO_PKG_AUTHORS"))
|
||||
(about: env!("CARGO_PKG_DESCRIPTION"))
|
||||
|
||||
(@subcommand init =>
|
||||
(about: "Creates a new Rojo project")
|
||||
(@arg PATH: "Path to the place to create the project. Defaults to the current directory.")
|
||||
(@arg kind: --kind +takes_value "The kind of project to create, 'place' or 'model'. Defaults to place.")
|
||||
)
|
||||
|
||||
(@subcommand serve =>
|
||||
(about: "Serves the project's files for use with the Rojo Studio plugin.")
|
||||
(@arg PROJECT: "Path to the project to serve. Defaults to the current directory.")
|
||||
(@arg port: --port +takes_value "The port to listen on. Defaults to 8000.")
|
||||
)
|
||||
|
||||
(@subcommand build =>
|
||||
(about: "Generates an rbxmx model file from the project.")
|
||||
(@arg PROJECT: "Path to the project to serve. Defaults to the current directory.")
|
||||
(@arg output: --output -o +takes_value +required "Where to output the result.")
|
||||
)
|
||||
|
||||
(@subcommand upload =>
|
||||
(about: "Generates a place or model file out of the project and uploads it to Roblox.")
|
||||
(@arg PROJECT: "Path to the project to upload. Defaults to the current directory.")
|
||||
(@arg kind: --kind +takes_value "The kind of asset to generate, 'place', or 'model'. Defaults to place.")
|
||||
(@arg cookie: --cookie +takes_value +required "Security cookie to authenticate with.")
|
||||
(@arg asset_id: --asset_id +takes_value +required "Asset ID to upload to.")
|
||||
)
|
||||
);
|
||||
|
||||
// `get_matches` consumes self for some reason.
|
||||
let matches = app.clone().get_matches();
|
||||
|
||||
match matches.subcommand() {
|
||||
("init", Some(sub_matches)) => {
|
||||
let fuzzy_project_path = make_path_absolute(Path::new(sub_matches.value_of("PATH").unwrap_or("")));
|
||||
let kind = sub_matches.value_of("kind");
|
||||
|
||||
let options = commands::InitOptions {
|
||||
fuzzy_project_path,
|
||||
kind,
|
||||
};
|
||||
|
||||
match commands::init(&options) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
process::exit(1);
|
||||
},
|
||||
}
|
||||
},
|
||||
("serve", Some(sub_matches)) => {
|
||||
let fuzzy_project_path = match sub_matches.value_of("PROJECT") {
|
||||
Some(v) => make_path_absolute(Path::new(v)),
|
||||
None => std::env::current_dir().unwrap(),
|
||||
};
|
||||
|
||||
let port = match sub_matches.value_of("port") {
|
||||
Some(v) => match v.parse::<u16>() {
|
||||
Ok(port) => Some(port),
|
||||
Err(_) => {
|
||||
error!("Invalid port {}", v);
|
||||
process::exit(1);
|
||||
},
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let options = commands::ServeOptions {
|
||||
fuzzy_project_path,
|
||||
port,
|
||||
};
|
||||
|
||||
match commands::serve(&options) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
process::exit(1);
|
||||
},
|
||||
}
|
||||
},
|
||||
("build", Some(sub_matches)) => {
|
||||
let fuzzy_project_path = match sub_matches.value_of("PROJECT") {
|
||||
Some(v) => make_path_absolute(Path::new(v)),
|
||||
None => std::env::current_dir().unwrap(),
|
||||
};
|
||||
|
||||
let output_file = make_path_absolute(Path::new(sub_matches.value_of("output").unwrap()));
|
||||
|
||||
let options = commands::BuildOptions {
|
||||
fuzzy_project_path,
|
||||
output_file,
|
||||
output_kind: None, // TODO: Accept from argument
|
||||
};
|
||||
|
||||
match commands::build(&options) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
process::exit(1);
|
||||
},
|
||||
}
|
||||
},
|
||||
("upload", Some(sub_matches)) => {
|
||||
let fuzzy_project_path = match sub_matches.value_of("PROJECT") {
|
||||
Some(v) => make_path_absolute(Path::new(v)),
|
||||
None => std::env::current_dir().unwrap(),
|
||||
};
|
||||
|
||||
let kind = sub_matches.value_of("kind");
|
||||
let security_cookie = sub_matches.value_of("cookie").unwrap();
|
||||
|
||||
let asset_id: u64 = {
|
||||
let arg = sub_matches.value_of("asset_id").unwrap();
|
||||
|
||||
match arg.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
error!("Invalid place ID {}", arg);
|
||||
process::exit(1);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let options = commands::UploadOptions {
|
||||
fuzzy_project_path,
|
||||
security_cookie: security_cookie.to_string(),
|
||||
asset_id,
|
||||
kind,
|
||||
};
|
||||
|
||||
match commands::upload(&options) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
process::exit(1);
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
app.print_help().expect("Could not print help text to stdout!");
|
||||
},
|
||||
}
|
||||
}
|
||||
131
server/src/commands/build.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
fs::File,
|
||||
io,
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
rbx_session::construct_oneoff_tree,
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
imfs::Imfs,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutputKind {
|
||||
Rbxmx,
|
||||
Rbxlx,
|
||||
Rbxm,
|
||||
Rbxl,
|
||||
}
|
||||
|
||||
fn detect_output_kind(options: &BuildOptions) -> Option<OutputKind> {
|
||||
let extension = options.output_file.extension()?.to_str()?;
|
||||
|
||||
match extension {
|
||||
"rbxlx" => Some(OutputKind::Rbxlx),
|
||||
"rbxmx" => Some(OutputKind::Rbxmx),
|
||||
"rbxl" => Some(OutputKind::Rbxl),
|
||||
"rbxm" => Some(OutputKind::Rbxm),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BuildOptions {
|
||||
pub fuzzy_project_path: PathBuf,
|
||||
pub output_file: PathBuf,
|
||||
pub output_kind: Option<OutputKind>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum BuildError {
|
||||
#[fail(display = "Could not detect what kind of file to create")]
|
||||
UnknownOutputKind,
|
||||
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
|
||||
#[fail(display = "XML model file error")]
|
||||
XmlModelEncodeError(rbx_xml::EncodeError),
|
||||
|
||||
#[fail(display = "Binary model file error")]
|
||||
BinaryModelEncodeError(rbx_binary::EncodeError)
|
||||
}
|
||||
|
||||
impl From<ProjectLoadFuzzyError> for BuildError {
|
||||
fn from(error: ProjectLoadFuzzyError) -> BuildError {
|
||||
BuildError::ProjectLoadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for BuildError {
|
||||
fn from(error: io::Error) -> BuildError {
|
||||
BuildError::IoError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rbx_xml::EncodeError> for BuildError {
|
||||
fn from(error: rbx_xml::EncodeError) -> BuildError {
|
||||
BuildError::XmlModelEncodeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rbx_binary::EncodeError> for BuildError {
|
||||
fn from(error: rbx_binary::EncodeError) -> BuildError {
|
||||
BuildError::BinaryModelEncodeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
let output_kind = options.output_kind
|
||||
.or_else(|| detect_output_kind(options))
|
||||
.ok_or(BuildError::UnknownOutputKind)?;
|
||||
|
||||
info!("Hoping to generate file of type {:?}", output_kind);
|
||||
|
||||
info!("Looking for project at {}", options.fuzzy_project_path.display());
|
||||
|
||||
let project = Project::load_fuzzy(&options.fuzzy_project_path)?;
|
||||
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs);
|
||||
let mut file = File::create(&options.output_file)?;
|
||||
|
||||
match output_kind {
|
||||
OutputKind::Rbxmx => {
|
||||
// Model files include the root instance of the tree and all its
|
||||
// descendants.
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
rbx_xml::encode(&tree, &[root_id], &mut file)?;
|
||||
},
|
||||
OutputKind::Rbxlx => {
|
||||
// Place files don't contain an entry for the DataModel, but our
|
||||
// RbxTree representation does.
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
|
||||
rbx_xml::encode(&tree, top_level_ids, &mut file)?;
|
||||
},
|
||||
OutputKind::Rbxm => {
|
||||
let root_id = tree.get_root_id();
|
||||
rbx_binary::encode(&tree, &[root_id], &mut file)?;
|
||||
},
|
||||
OutputKind::Rbxl => {
|
||||
let root_id = tree.get_root_id();
|
||||
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
|
||||
rbx_binary::encode(&tree, top_level_ids, &mut file)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
server/src/commands/init.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use crate::project::{Project, ProjectInitError};
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum InitError {
|
||||
#[fail(display = "Invalid project kind '{}', valid kinds are 'place' and 'model'", _0)]
|
||||
InvalidKind(String),
|
||||
|
||||
#[fail(display = "Project init error: {}", _0)]
|
||||
ProjectInitError(#[fail(cause)] ProjectInitError)
|
||||
}
|
||||
|
||||
impl From<ProjectInitError> for InitError {
|
||||
fn from(error: ProjectInitError) -> InitError {
|
||||
InitError::ProjectInitError(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InitOptions<'a> {
|
||||
pub fuzzy_project_path: PathBuf,
|
||||
pub kind: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn init(options: &InitOptions) -> Result<(), InitError> {
|
||||
let (project_path, project_kind) = match options.kind {
|
||||
Some("place") | None => {
|
||||
let path = Project::init_place(&options.fuzzy_project_path)?;
|
||||
(path, "place")
|
||||
},
|
||||
Some("model") => {
|
||||
let path = Project::init_model(&options.fuzzy_project_path)?;
|
||||
(path, "model")
|
||||
},
|
||||
Some(invalid) => return Err(InitError::InvalidKind(invalid.to_string())),
|
||||
};
|
||||
|
||||
println!("Created new {} project file at {}", project_kind, project_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
9
server/src/commands/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod serve;
|
||||
mod init;
|
||||
mod build;
|
||||
mod upload;
|
||||
|
||||
pub use self::serve::*;
|
||||
pub use self::init::*;
|
||||
pub use self::build::*;
|
||||
pub use self::upload::*;
|
||||
54
server/src/commands/serve.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
web::Server,
|
||||
session::Session,
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 34872;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServeOptions {
|
||||
pub fuzzy_project_path: PathBuf,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ServeError {
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
}
|
||||
|
||||
impl From<ProjectLoadFuzzyError> for ServeError {
|
||||
fn from(error: ProjectLoadFuzzyError) -> ServeError {
|
||||
ServeError::ProjectLoadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
|
||||
info!("Looking for project at {}", options.fuzzy_project_path.display());
|
||||
|
||||
let project = Arc::new(Project::load_fuzzy(&options.fuzzy_project_path)?);
|
||||
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let session = Arc::new(Session::new(Arc::clone(&project)).unwrap());
|
||||
let server = Server::new(Arc::clone(&session));
|
||||
|
||||
let port = options.port
|
||||
.or(project.serve_port)
|
||||
.unwrap_or(DEFAULT_PORT);
|
||||
|
||||
println!("Rojo server listening on port {}", port);
|
||||
|
||||
server.listen(port);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
114
server/src/commands/upload.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
io,
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use reqwest::header::{ACCEPT, USER_AGENT, CONTENT_TYPE, COOKIE};
|
||||
|
||||
use crate::{
|
||||
rbx_session::construct_oneoff_tree,
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
imfs::Imfs,
|
||||
};
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum UploadError {
|
||||
#[fail(display = "Roblox API Error: {}", _0)]
|
||||
RobloxApiError(String),
|
||||
|
||||
#[fail(display = "Invalid asset kind: {}", _0)]
|
||||
InvalidKind(String),
|
||||
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
|
||||
#[fail(display = "HTTP error: {}", _0)]
|
||||
HttpError(#[fail(cause)] reqwest::Error),
|
||||
|
||||
#[fail(display = "XML model file error")]
|
||||
XmlModelEncodeError(rbx_xml::EncodeError),
|
||||
}
|
||||
|
||||
impl From<ProjectLoadFuzzyError> for UploadError {
|
||||
fn from(error: ProjectLoadFuzzyError) -> UploadError {
|
||||
UploadError::ProjectLoadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for UploadError {
|
||||
fn from(error: io::Error) -> UploadError {
|
||||
UploadError::IoError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for UploadError {
|
||||
fn from(error: reqwest::Error) -> UploadError {
|
||||
UploadError::HttpError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rbx_xml::EncodeError> for UploadError {
|
||||
fn from(error: rbx_xml::EncodeError) -> UploadError {
|
||||
UploadError::XmlModelEncodeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UploadOptions<'a> {
|
||||
pub fuzzy_project_path: PathBuf,
|
||||
pub security_cookie: String,
|
||||
pub asset_id: u64,
|
||||
pub kind: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn upload(options: &UploadOptions) -> Result<(), UploadError> {
|
||||
// TODO: Switch to uploading binary format?
|
||||
|
||||
info!("Looking for project at {}", options.fuzzy_project_path.display());
|
||||
|
||||
let project = Project::load_fuzzy(&options.fuzzy_project_path)?;
|
||||
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs);
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
let mut contents = Vec::new();
|
||||
|
||||
match options.kind {
|
||||
Some("place") | None => {
|
||||
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
|
||||
rbx_xml::encode(&tree, top_level_ids, &mut contents)?;
|
||||
},
|
||||
Some("model") => {
|
||||
rbx_xml::encode(&tree, &[root_id], &mut contents)?;
|
||||
},
|
||||
Some(invalid) => return Err(UploadError::InvalidKind(invalid.to_owned())),
|
||||
}
|
||||
|
||||
let url = format!("https://data.roblox.com/Data/Upload.ashx?assetid={}", options.asset_id);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut response = client.post(&url)
|
||||
.header(COOKIE, format!(".ROBLOSECURITY={}", &options.security_cookie))
|
||||
.header(USER_AGENT, "Roblox/WinInet")
|
||||
.header("Requester", "Client")
|
||||
.header(CONTENT_TYPE, "application/xml")
|
||||
.header(ACCEPT, "application/json")
|
||||
.body(contents)
|
||||
.send()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(UploadError::RobloxApiError(response.text()?));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
115
server/src/fs_watcher.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::{
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::Duration,
|
||||
thread,
|
||||
};
|
||||
|
||||
use notify::{
|
||||
self,
|
||||
DebouncedEvent,
|
||||
RecommendedWatcher,
|
||||
RecursiveMode,
|
||||
Watcher,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
imfs::Imfs,
|
||||
rbx_session::RbxSession,
|
||||
};
|
||||
|
||||
const WATCH_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: DebouncedEvent) {
|
||||
match event {
|
||||
DebouncedEvent::Create(path) => {
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_created(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_created(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Write(path) => {
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_updated(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_updated(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Remove(path) => {
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_removed(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_removed(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Rename(from_path, to_path) => {
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_moved(&from_path, &to_path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_renamed(&from_path, &to_path);
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches for changes on the filesystem and links together the in-memory
|
||||
/// filesystem and in-memory Roblox tree.
|
||||
pub struct FsWatcher {
|
||||
#[allow(unused)]
|
||||
watchers: Vec<RecommendedWatcher>,
|
||||
}
|
||||
|
||||
impl FsWatcher {
|
||||
pub fn start(imfs: Arc<Mutex<Imfs>>, rbx_session: Arc<Mutex<RbxSession>>) -> FsWatcher {
|
||||
let mut watchers = Vec::new();
|
||||
|
||||
{
|
||||
let imfs_temp = imfs.lock().unwrap();
|
||||
|
||||
for root_path in imfs_temp.get_roots() {
|
||||
let (watch_tx, watch_rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = notify::watcher(watch_tx, WATCH_TIMEOUT)
|
||||
.expect("Could not create `notify` watcher");
|
||||
|
||||
watcher.watch(root_path, RecursiveMode::Recursive)
|
||||
.expect("Could not watch directory");
|
||||
|
||||
watchers.push(watcher);
|
||||
|
||||
let imfs = Arc::clone(&imfs);
|
||||
let rbx_session = Arc::clone(&rbx_session);
|
||||
let root_path = root_path.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
info!("Watcher thread ({}) started", root_path.display());
|
||||
while let Ok(event) = watch_rx.recv() {
|
||||
handle_event(&imfs, &rbx_session, event);
|
||||
}
|
||||
info!("Watcher thread ({}) stopped", root_path.display());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
FsWatcher {
|
||||
watchers,
|
||||
}
|
||||
}
|
||||
}
|
||||
223
server/src/imfs.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
fs,
|
||||
io,
|
||||
};
|
||||
|
||||
use crate::project::{Project, ProjectNode};
|
||||
|
||||
fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> io::Result<()> {
|
||||
match project_node {
|
||||
ProjectNode::Instance(node) => {
|
||||
for child in node.children.values() {
|
||||
add_sync_points(imfs, child)?;
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(node) => {
|
||||
imfs.add_root(&node.path)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The in-memory filesystem keeps a mirror of all files being watcher by Rojo
|
||||
/// in order to deduplicate file changes in the case of bidirectional syncing
|
||||
/// from Roblox Studio.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Imfs {
|
||||
items: HashMap<PathBuf, ImfsItem>,
|
||||
roots: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl Imfs {
|
||||
pub fn new() -> Imfs {
|
||||
Imfs {
|
||||
items: HashMap::new(),
|
||||
roots: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_roots_from_project(&mut self, project: &Project) -> io::Result<()> {
|
||||
add_sync_points(self, &project.tree)
|
||||
}
|
||||
|
||||
pub fn get_roots(&self) -> &HashSet<PathBuf> {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
pub fn get_items(&self) -> &HashMap<PathBuf, ImfsItem> {
|
||||
&self.items
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &Path) -> Option<&ImfsItem> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.items.get(path)
|
||||
}
|
||||
|
||||
pub fn add_root(&mut self, path: &Path) -> io::Result<()> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(!self.is_within_roots(path));
|
||||
|
||||
self.roots.insert(path.to_path_buf());
|
||||
|
||||
self.read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_created(&mut self, path: &Path) -> io::Result<()> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_updated(&mut self, path: &Path) -> io::Result<()> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_removed(&mut self, path: &Path) -> io::Result<()> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.remove_item(path);
|
||||
|
||||
if let Some(parent_path) = path.parent() {
|
||||
self.unlink_child(parent_path, path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn path_moved(&mut self, from_path: &Path, to_path: &Path) -> io::Result<()> {
|
||||
debug_assert!(from_path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(from_path));
|
||||
debug_assert!(to_path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(to_path));
|
||||
|
||||
self.path_removed(from_path)?;
|
||||
self.path_created(to_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_root_for_path<'a>(&'a self, path: &Path) -> Option<&'a Path> {
|
||||
for root_path in &self.roots {
|
||||
if path.starts_with(root_path) {
|
||||
return Some(root_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn remove_item(&mut self, path: &Path) {
|
||||
if let Some(ImfsItem::Directory(directory)) = self.items.remove(path) {
|
||||
for child_path in &directory.children {
|
||||
self.remove_item(child_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unlink_child(&mut self, parent: &Path, child: &Path) {
|
||||
let parent_item = self.items.get_mut(parent);
|
||||
|
||||
match parent_item {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.remove(child);
|
||||
},
|
||||
_ => {
|
||||
panic!("Tried to unlink child of path that wasn't a directory!");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn link_child(&mut self, parent: &Path, child: &Path) {
|
||||
if self.is_within_roots(parent) {
|
||||
let parent_item = self.items.get_mut(parent);
|
||||
|
||||
match parent_item {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.insert(child.to_path_buf());
|
||||
},
|
||||
_ => {
|
||||
panic!("Tried to link child of path that wasn't a directory!");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_disk(&mut self, path: &Path) -> io::Result<()> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
|
||||
if metadata.is_file() {
|
||||
let contents = fs::read(path)?;
|
||||
let item = ImfsItem::File(ImfsFile {
|
||||
path: path.to_path_buf(),
|
||||
contents,
|
||||
});
|
||||
|
||||
self.items.insert(path.to_path_buf(), item);
|
||||
|
||||
if let Some(parent_path) = path.parent() {
|
||||
self.link_child(parent_path, path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else if metadata.is_dir() {
|
||||
let item = ImfsItem::Directory(ImfsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
children: HashSet::new(),
|
||||
});
|
||||
|
||||
self.items.insert(path.to_path_buf(), item);
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let child_path = entry.path();
|
||||
|
||||
self.read_from_disk(&child_path)?;
|
||||
}
|
||||
|
||||
if let Some(parent_path) = path.parent() {
|
||||
self.link_child(parent_path, path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
panic!("Unexpected non-file, non-directory item");
|
||||
}
|
||||
}
|
||||
|
||||
fn is_within_roots(&self, path: &Path) -> bool {
|
||||
for root_path in &self.roots {
|
||||
if path.starts_with(root_path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImfsFile {
|
||||
pub path: PathBuf,
|
||||
pub contents: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImfsDirectory {
|
||||
pub path: PathBuf,
|
||||
pub children: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ImfsItem {
|
||||
File(ImfsFile),
|
||||
Directory(ImfsDirectory),
|
||||
}
|
||||
23
server/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
|
||||
#[cfg(test)]
|
||||
extern crate tempfile;
|
||||
|
||||
// pub mod roblox_studio;
|
||||
pub mod commands;
|
||||
pub mod fs_watcher;
|
||||
pub mod imfs;
|
||||
pub mod message_queue;
|
||||
pub mod path_map;
|
||||
pub mod project;
|
||||
pub mod rbx_session;
|
||||
pub mod rbx_snapshot;
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
pub mod visualize;
|
||||
pub mod web;
|
||||
pub mod web_util;
|
||||
79
server/src/message_queue.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
mpsc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
RwLock,
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
/// A unique identifier, not guaranteed to be generated in any order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ListenerId(usize);
|
||||
|
||||
/// Generate a new ID, which has no defined ordering.
|
||||
pub fn get_listener_id() -> ListenerId {
|
||||
static LAST_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
ListenerId(LAST_ID.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MessageQueue<T> {
|
||||
messages: RwLock<Vec<T>>,
|
||||
message_listeners: Mutex<HashMap<ListenerId, mpsc::Sender<()>>>,
|
||||
}
|
||||
|
||||
impl<T: Clone> MessageQueue<T> {
|
||||
pub fn new() -> MessageQueue<T> {
|
||||
MessageQueue {
|
||||
messages: RwLock::new(Vec::new()),
|
||||
message_listeners: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_messages(&self, new_messages: &[T]) {
|
||||
let message_listeners = self.message_listeners.lock().unwrap();
|
||||
|
||||
{
|
||||
let mut messages = self.messages.write().unwrap();
|
||||
messages.extend_from_slice(new_messages);
|
||||
}
|
||||
|
||||
for listener in message_listeners.values() {
|
||||
listener.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self, sender: mpsc::Sender<()>) -> ListenerId {
|
||||
let id = get_listener_id();
|
||||
|
||||
let mut message_listeners = self.message_listeners.lock().unwrap();
|
||||
message_listeners.insert(id, sender);
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, id: ListenerId) {
|
||||
let mut message_listeners = self.message_listeners.lock().unwrap();
|
||||
message_listeners.remove(&id);
|
||||
}
|
||||
|
||||
pub fn get_message_cursor(&self) -> u32 {
|
||||
self.messages.read().unwrap().len() as u32
|
||||
}
|
||||
|
||||
pub fn get_messages_since(&self, cursor: u32) -> (u32, Vec<T>) {
|
||||
let messages = self.messages.read().unwrap();
|
||||
|
||||
let current_cursor = messages.len() as u32;
|
||||
|
||||
// Cursor is out of bounds or there are no new messages
|
||||
if cursor >= current_cursor {
|
||||
return (current_cursor, Vec::new());
|
||||
}
|
||||
|
||||
(current_cursor, messages[(cursor as usize)..].to_vec())
|
||||
}
|
||||
}
|
||||
96
server/src/path_map.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use std::{
|
||||
path::{self, Path, PathBuf},
|
||||
collections::{HashMap, HashSet},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PathMapNode<T> {
|
||||
value: T,
|
||||
children: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
/// A map from paths to instance IDs, with a bit of additional data that enables
|
||||
/// removing a path and all of its child paths from the tree more quickly.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PathMap<T> {
|
||||
nodes: HashMap<PathBuf, PathMapNode<T>>,
|
||||
}
|
||||
|
||||
impl<T> PathMap<T> {
|
||||
pub fn new() -> PathMap<T> {
|
||||
PathMap {
|
||||
nodes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &Path) -> Option<&T> {
|
||||
self.nodes.get(path).map(|v| &v.value)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, path: PathBuf, value: T) {
|
||||
if let Some(parent_path) = path.parent() {
|
||||
if let Some(parent) = self.nodes.get_mut(parent_path) {
|
||||
parent.children.insert(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
self.nodes.insert(path, PathMapNode {
|
||||
value,
|
||||
children: HashSet::new(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, root_path: &Path) -> Option<T> {
|
||||
if let Some(parent_path) = root_path.parent() {
|
||||
if let Some(parent) = self.nodes.get_mut(parent_path) {
|
||||
parent.children.remove(root_path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut root_node = match self.nodes.remove(root_path) {
|
||||
Some(node) => node,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let root_value = root_node.value;
|
||||
let mut to_visit: Vec<PathBuf> = root_node.children.drain().collect();
|
||||
|
||||
while let Some(path) = to_visit.pop() {
|
||||
match self.nodes.remove(&path) {
|
||||
Some(mut node) => {
|
||||
for child in node.children.drain() {
|
||||
to_visit.push(child);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("Consistency issue; tried to remove {} but it was already removed", path.display());
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Some(root_value)
|
||||
}
|
||||
|
||||
pub fn descend(&self, start_path: &Path, target_path: &Path) -> PathBuf {
|
||||
let relative_path = target_path.strip_prefix(start_path)
|
||||
.expect("target_path did not begin with start_path");
|
||||
let mut current_path = start_path.to_path_buf();
|
||||
|
||||
for component in relative_path.components() {
|
||||
match component {
|
||||
path::Component::Normal(name) => {
|
||||
let next_path = current_path.join(name);
|
||||
|
||||
if self.nodes.contains_key(&next_path) {
|
||||
current_path = next_path;
|
||||
} else {
|
||||
return current_path;
|
||||
}
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
current_path
|
||||
}
|
||||
}
|
||||
400
server/src/project.rs
Normal file
@@ -0,0 +1,400 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use maplit::hashmap;
|
||||
use failure::Fail;
|
||||
use rbx_tree::RbxValue;
|
||||
|
||||
pub static PROJECT_FILENAME: &'static str = "roblox-project.json";
|
||||
|
||||
// Serde is silly.
|
||||
const fn yeah() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn is_true(value: &bool) -> bool {
|
||||
*value
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum SourceProjectNode {
|
||||
Instance {
|
||||
#[serde(rename = "$className")]
|
||||
class_name: String,
|
||||
|
||||
#[serde(rename = "$properties", default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")]
|
||||
properties: HashMap<String, RbxValue>,
|
||||
|
||||
#[serde(rename = "$ignoreUnknownInstances", default = "yeah", skip_serializing_if = "is_true")]
|
||||
ignore_unknown_instances: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
children: HashMap<String, SourceProjectNode>,
|
||||
},
|
||||
SyncPoint {
|
||||
#[serde(rename = "$path")]
|
||||
path: String,
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceProjectNode {
|
||||
pub fn into_project_node(self, project_file_location: &Path) -> ProjectNode {
|
||||
match self {
|
||||
SourceProjectNode::Instance { class_name, mut children, properties, ignore_unknown_instances } => {
|
||||
let mut new_children = HashMap::new();
|
||||
|
||||
for (node_name, node) in children.drain() {
|
||||
new_children.insert(node_name, node.into_project_node(project_file_location));
|
||||
}
|
||||
|
||||
ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name,
|
||||
children: new_children,
|
||||
properties,
|
||||
metadata: InstanceProjectNodeMetadata {
|
||||
ignore_unknown_instances,
|
||||
},
|
||||
})
|
||||
},
|
||||
SourceProjectNode::SyncPoint { path: source_path } => {
|
||||
let path = if Path::new(&source_path).is_absolute() {
|
||||
PathBuf::from(source_path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(source_path)
|
||||
};
|
||||
|
||||
ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SourceProject {
|
||||
name: String,
|
||||
tree: SourceProjectNode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
serve_port: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
serve_place_ids: Option<HashSet<u64>>,
|
||||
}
|
||||
|
||||
impl SourceProject {
|
||||
pub fn into_project(self, project_file_location: &Path) -> Project {
|
||||
let tree = self.tree.into_project_node(project_file_location);
|
||||
|
||||
Project {
|
||||
name: self.name,
|
||||
tree,
|
||||
serve_port: self.serve_port,
|
||||
serve_place_ids: self.serve_place_ids,
|
||||
file_location: PathBuf::from(project_file_location),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ProjectLoadExactError {
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
|
||||
#[fail(display = "JSON error: {}", _0)]
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ProjectLoadFuzzyError {
|
||||
#[fail(display = "Project not found")]
|
||||
NotFound,
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
|
||||
#[fail(display = "JSON error: {}", _0)]
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
}
|
||||
|
||||
impl From<ProjectLoadExactError> for ProjectLoadFuzzyError {
|
||||
fn from(error: ProjectLoadExactError) -> ProjectLoadFuzzyError {
|
||||
match error {
|
||||
ProjectLoadExactError::IoError(inner) => ProjectLoadFuzzyError::IoError(inner),
|
||||
ProjectLoadExactError::JsonError(inner) => ProjectLoadFuzzyError::JsonError(inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ProjectInitError {
|
||||
AlreadyExists(PathBuf),
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
SaveError(#[fail(cause)] ProjectSaveError),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectInitError {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ProjectInitError::AlreadyExists(path) => write!(output, "Path {} already exists", path.display()),
|
||||
ProjectInitError::IoError(inner) => write!(output, "IO error: {}", inner),
|
||||
ProjectInitError::SaveError(inner) => write!(output, "{}", inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ProjectSaveError {
|
||||
#[fail(display = "JSON error: {}", _0)]
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceProjectNodeMetadata {
|
||||
pub ignore_unknown_instances: bool,
|
||||
}
|
||||
|
||||
impl Default for InstanceProjectNodeMetadata {
|
||||
fn default() -> InstanceProjectNodeMetadata {
|
||||
InstanceProjectNodeMetadata {
|
||||
ignore_unknown_instances: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProjectNode {
|
||||
Instance(InstanceProjectNode),
|
||||
SyncPoint(SyncPointProjectNode),
|
||||
}
|
||||
|
||||
impl ProjectNode {
|
||||
fn to_source_node(&self, project_file_location: &Path) -> SourceProjectNode {
|
||||
match self {
|
||||
ProjectNode::Instance(node) => {
|
||||
let mut children = HashMap::new();
|
||||
|
||||
for (key, child) in &node.children {
|
||||
children.insert(key.clone(), child.to_source_node(project_file_location));
|
||||
}
|
||||
|
||||
SourceProjectNode::Instance {
|
||||
class_name: node.class_name.clone(),
|
||||
children,
|
||||
properties: node.properties.clone(),
|
||||
ignore_unknown_instances: node.metadata.ignore_unknown_instances,
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(sync_node) => {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
|
||||
let friendly_path = match sync_node.path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", sync_node.path.display()),
|
||||
};
|
||||
|
||||
SourceProjectNode::SyncPoint {
|
||||
path: friendly_path,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceProjectNode {
|
||||
pub class_name: String,
|
||||
pub children: HashMap<String, ProjectNode>,
|
||||
pub properties: HashMap<String, RbxValue>,
|
||||
pub metadata: InstanceProjectNodeMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncPointProjectNode {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub tree: ProjectNode,
|
||||
pub serve_port: Option<u16>,
|
||||
pub serve_place_ids: Option<HashSet<u64>>,
|
||||
pub file_location: PathBuf,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn init_place(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let project_path = Project::init_pick_path(project_fuzzy_path)?;
|
||||
let project_folder_path = project_path.parent().unwrap();
|
||||
let project_name = if project_fuzzy_path == project_path {
|
||||
project_fuzzy_path.parent().unwrap().file_name().unwrap().to_str().unwrap()
|
||||
} else {
|
||||
project_fuzzy_path.file_name().unwrap().to_str().unwrap()
|
||||
};
|
||||
|
||||
let tree = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "DataModel".to_string(),
|
||||
children: hashmap! {
|
||||
String::from("ReplicatedStorage") => ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: String::from("ReplicatedStorage"),
|
||||
children: hashmap! {
|
||||
String::from("Source") => ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_folder_path.join("src"),
|
||||
}),
|
||||
},
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
}),
|
||||
String::from("HttpService") => ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: String::from("HttpService"),
|
||||
children: HashMap::new(),
|
||||
properties: hashmap! {
|
||||
String::from("HttpEnabled") => RbxValue::Bool {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
metadata: Default::default(),
|
||||
}),
|
||||
},
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
|
||||
let project = Project {
|
||||
name: project_name.to_string(),
|
||||
tree,
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_path.clone(),
|
||||
};
|
||||
|
||||
project.save()
|
||||
.map_err(ProjectInitError::SaveError)?;
|
||||
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
pub fn init_model(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let project_path = Project::init_pick_path(project_fuzzy_path)?;
|
||||
let project_folder_path = project_path.parent().unwrap();
|
||||
let project_name = if project_fuzzy_path == project_path {
|
||||
project_fuzzy_path.parent().unwrap().file_name().unwrap().to_str().unwrap()
|
||||
} else {
|
||||
project_fuzzy_path.file_name().unwrap().to_str().unwrap()
|
||||
};
|
||||
|
||||
let tree = ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_folder_path.join("src"),
|
||||
});
|
||||
|
||||
let project = Project {
|
||||
name: project_name.to_string(),
|
||||
tree,
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_path.clone(),
|
||||
};
|
||||
|
||||
project.save()
|
||||
.map_err(ProjectInitError::SaveError)?;
|
||||
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
fn init_pick_path(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let is_exact = project_fuzzy_path.extension().is_some();
|
||||
|
||||
let project_path = if is_exact {
|
||||
project_fuzzy_path.to_path_buf()
|
||||
} else {
|
||||
project_fuzzy_path.join(PROJECT_FILENAME)
|
||||
};
|
||||
|
||||
match fs::metadata(&project_path) {
|
||||
Err(error) => match error.kind() {
|
||||
io::ErrorKind::NotFound => {},
|
||||
_ => return Err(ProjectInitError::IoError(error)),
|
||||
},
|
||||
Ok(_) => return Err(ProjectInitError::AlreadyExists(project_path)),
|
||||
}
|
||||
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
pub fn locate(start_location: &Path) -> Option<PathBuf> {
|
||||
// TODO: Check for specific error kinds, convert 'not found' to Result.
|
||||
let location_metadata = fs::metadata(start_location).ok()?;
|
||||
|
||||
// If this is a file, we should assume it's the config we want
|
||||
if location_metadata.is_file() {
|
||||
return Some(start_location.to_path_buf());
|
||||
} else if location_metadata.is_dir() {
|
||||
let with_file = start_location.join(PROJECT_FILENAME);
|
||||
|
||||
if let Ok(with_file_metadata) = fs::metadata(&with_file) {
|
||||
if with_file_metadata.is_file() {
|
||||
return Some(with_file);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match start_location.parent() {
|
||||
Some(parent_location) => Self::locate(parent_location),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadFuzzyError> {
|
||||
let project_path = Self::locate(fuzzy_project_location)
|
||||
.ok_or(ProjectLoadFuzzyError::NotFound)?;
|
||||
|
||||
Self::load_exact(&project_path).map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn load_exact(project_file_location: &Path) -> Result<Project, ProjectLoadExactError> {
|
||||
let contents = fs::read_to_string(project_file_location)
|
||||
.map_err(ProjectLoadExactError::IoError)?;
|
||||
|
||||
let parsed: SourceProject = serde_json::from_str(&contents)
|
||||
.map_err(ProjectLoadExactError::JsonError)?;
|
||||
|
||||
Ok(parsed.into_project(project_file_location))
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), ProjectSaveError> {
|
||||
let source_project = self.to_source_project();
|
||||
let mut file = File::create(&self.file_location)
|
||||
.map_err(ProjectSaveError::IoError)?;
|
||||
|
||||
serde_json::to_writer_pretty(&mut file, &source_project)
|
||||
.map_err(ProjectSaveError::JsonError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_source_project(&self) -> SourceProject {
|
||||
SourceProject {
|
||||
name: self.name.clone(),
|
||||
tree: self.tree.to_source_node(&self.file_location),
|
||||
serve_port: self.serve_port,
|
||||
serve_place_ids: self.serve_place_ids.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
539
server/src/rbx_session.rs
Normal file
@@ -0,0 +1,539 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use rbx_tree::{RbxTree, RbxInstanceProperties, RbxValue, RbxId};
|
||||
|
||||
use crate::{
|
||||
project::{Project, ProjectNode, InstanceProjectNodeMetadata},
|
||||
message_queue::MessageQueue,
|
||||
imfs::{Imfs, ImfsItem, ImfsFile},
|
||||
path_map::PathMap,
|
||||
rbx_snapshot::{RbxSnapshotInstance, InstanceChanges, snapshot_from_tree, reify_root, reconcile_subtree},
|
||||
};
|
||||
|
||||
const INIT_SCRIPT: &str = "init.lua";
|
||||
const INIT_SERVER_SCRIPT: &str = "init.server.lua";
|
||||
const INIT_CLIENT_SCRIPT: &str = "init.client.lua";
|
||||
|
||||
pub struct RbxSession {
|
||||
tree: RbxTree,
|
||||
path_map: PathMap<RbxId>,
|
||||
instance_metadata_map: HashMap<RbxId, InstanceProjectNodeMetadata>,
|
||||
sync_point_names: HashMap<PathBuf, String>,
|
||||
message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
imfs: Arc<Mutex<Imfs>>,
|
||||
}
|
||||
|
||||
impl RbxSession {
|
||||
pub fn new(
|
||||
project: Arc<Project>,
|
||||
imfs: Arc<Mutex<Imfs>>,
|
||||
message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
) -> RbxSession {
|
||||
let mut sync_point_names = HashMap::new();
|
||||
let mut path_map = PathMap::new();
|
||||
let mut instance_metadata_map = HashMap::new();
|
||||
|
||||
let tree = {
|
||||
let temp_imfs = imfs.lock().unwrap();
|
||||
construct_initial_tree(&project, &temp_imfs, &mut path_map, &mut instance_metadata_map, &mut sync_point_names)
|
||||
};
|
||||
|
||||
RbxSession {
|
||||
tree,
|
||||
path_map,
|
||||
instance_metadata_map,
|
||||
sync_point_names,
|
||||
message_queue,
|
||||
imfs,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_created_or_updated(&mut self, path: &Path) {
|
||||
// TODO: Track paths actually updated in each step so we can ignore
|
||||
// redundant changes.
|
||||
let mut changes = InstanceChanges::default();
|
||||
|
||||
{
|
||||
let imfs = self.imfs.lock().unwrap();
|
||||
let root_path = imfs.get_root_for_path(path)
|
||||
.expect("Path was outside in-memory filesystem roots");
|
||||
|
||||
// Find the closest instance in the tree that currently exists
|
||||
let mut path_to_snapshot = self.path_map.descend(root_path, path);
|
||||
let &instance_id = self.path_map.get(&path_to_snapshot).unwrap();
|
||||
|
||||
// If this is a file that might affect its parent if modified, we
|
||||
// should snapshot its parent instead.
|
||||
match path_to_snapshot.file_name().unwrap().to_str() {
|
||||
Some(INIT_SCRIPT) | Some(INIT_SERVER_SCRIPT) | Some(INIT_CLIENT_SCRIPT) => {
|
||||
path_to_snapshot.pop();
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
trace!("Snapshotting path {}", path_to_snapshot.display());
|
||||
|
||||
let maybe_snapshot = snapshot_instances_from_imfs(&imfs, &path_to_snapshot, &mut self.sync_point_names)
|
||||
.unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()));
|
||||
|
||||
let snapshot = match maybe_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => {
|
||||
trace!("Path resulted in no snapshot being generated.");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
trace!("Snapshot: {:#?}", snapshot);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut self.tree,
|
||||
instance_id,
|
||||
&snapshot,
|
||||
&mut self.path_map,
|
||||
&mut self.instance_metadata_map,
|
||||
&mut changes,
|
||||
);
|
||||
}
|
||||
|
||||
if changes.is_empty() {
|
||||
trace!("No instance changes triggered from file update.");
|
||||
} else {
|
||||
trace!("Pushing changes: {}", changes);
|
||||
self.message_queue.push_messages(&[changes]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_created(&mut self, path: &Path) {
|
||||
info!("Path created: {}", path.display());
|
||||
self.path_created_or_updated(path);
|
||||
}
|
||||
|
||||
pub fn path_updated(&mut self, path: &Path) {
|
||||
info!("Path updated: {}", path.display());
|
||||
|
||||
{
|
||||
let imfs = self.imfs.lock().unwrap();
|
||||
|
||||
// If the path doesn't exist or is a directory, we don't care if it
|
||||
// updated
|
||||
match imfs.get(path) {
|
||||
Some(ImfsItem::Directory(_)) | None => {
|
||||
trace!("Updated path was a directory, ignoring.");
|
||||
return;
|
||||
},
|
||||
Some(ImfsItem::File(_)) => {},
|
||||
}
|
||||
}
|
||||
|
||||
self.path_created_or_updated(path);
|
||||
}
|
||||
|
||||
pub fn path_removed(&mut self, path: &Path) {
|
||||
info!("Path removed: {}", path.display());
|
||||
self.path_map.remove(path);
|
||||
self.path_created_or_updated(path);
|
||||
}
|
||||
|
||||
pub fn path_renamed(&mut self, from_path: &Path, to_path: &Path) {
|
||||
info!("Path renamed from {} to {}", from_path.display(), to_path.display());
|
||||
self.path_map.remove(from_path);
|
||||
self.path_created_or_updated(from_path);
|
||||
self.path_created_or_updated(to_path);
|
||||
}
|
||||
|
||||
pub fn get_tree(&self) -> &RbxTree {
|
||||
&self.tree
|
||||
}
|
||||
|
||||
pub fn get_instance_metadata(&self, id: RbxId) -> Option<&InstanceProjectNodeMetadata> {
|
||||
self.instance_metadata_map.get(&id)
|
||||
}
|
||||
|
||||
pub fn debug_get_path_map(&self) -> &PathMap<RbxId> {
|
||||
&self.path_map
|
||||
}
|
||||
}
|
||||
|
||||
pub fn construct_oneoff_tree(project: &Project, imfs: &Imfs) -> RbxTree {
|
||||
let mut path_map = PathMap::new();
|
||||
let mut instance_metadata_map = HashMap::new();
|
||||
let mut sync_point_names = HashMap::new();
|
||||
construct_initial_tree(project, imfs, &mut path_map, &mut instance_metadata_map, &mut sync_point_names)
|
||||
}
|
||||
|
||||
fn construct_initial_tree(
|
||||
project: &Project,
|
||||
imfs: &Imfs,
|
||||
path_map: &mut PathMap<RbxId>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, InstanceProjectNodeMetadata>,
|
||||
sync_point_names: &mut HashMap<PathBuf, String>,
|
||||
) -> RbxTree {
|
||||
let snapshot = construct_project_node(
|
||||
imfs,
|
||||
&project.name,
|
||||
&project.tree,
|
||||
sync_point_names,
|
||||
);
|
||||
|
||||
let mut changes = InstanceChanges::default();
|
||||
let tree = reify_root(&snapshot, path_map, instance_metadata_map, &mut changes);
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
fn construct_project_node<'a>(
|
||||
imfs: &'a Imfs,
|
||||
instance_name: &'a str,
|
||||
project_node: &'a ProjectNode,
|
||||
sync_point_names: &mut HashMap<PathBuf, String>,
|
||||
) -> RbxSnapshotInstance<'a> {
|
||||
match project_node {
|
||||
ProjectNode::Instance(node) => {
|
||||
let mut children = Vec::new();
|
||||
|
||||
for (child_name, child_project_node) in &node.children {
|
||||
children.push(construct_project_node(imfs, child_name, child_project_node, sync_point_names));
|
||||
}
|
||||
|
||||
RbxSnapshotInstance {
|
||||
class_name: Cow::Borrowed(&node.class_name),
|
||||
name: Cow::Borrowed(instance_name),
|
||||
properties: node.properties.clone(),
|
||||
children,
|
||||
source_path: None,
|
||||
metadata: Some(node.metadata.clone()),
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(node) => {
|
||||
// TODO: Propagate errors upward instead of dying
|
||||
let mut snapshot = snapshot_instances_from_imfs(imfs, &node.path, sync_point_names)
|
||||
.expect("Could not reify nodes from Imfs")
|
||||
.expect("Sync point node did not result in an instance");
|
||||
|
||||
snapshot.name = Cow::Borrowed(instance_name);
|
||||
sync_point_names.insert(node.path.clone(), instance_name.to_string());
|
||||
|
||||
snapshot
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FileType {
|
||||
ModuleScript,
|
||||
ServerScript,
|
||||
ClientScript,
|
||||
StringValue,
|
||||
LocalizationTable,
|
||||
XmlModel,
|
||||
BinaryModel,
|
||||
}
|
||||
|
||||
fn get_trailing<'a>(input: &'a str, trailer: &str) -> Option<&'a str> {
|
||||
if input.ends_with(trailer) {
|
||||
let end = input.len().saturating_sub(trailer.len());
|
||||
Some(&input[..end])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_file(file: &ImfsFile) -> Option<(&str, FileType)> {
|
||||
static EXTENSIONS_TO_TYPES: &[(&str, FileType)] = &[
|
||||
(".server.lua", FileType::ServerScript),
|
||||
(".client.lua", FileType::ClientScript),
|
||||
(".lua", FileType::ModuleScript),
|
||||
(".csv", FileType::LocalizationTable),
|
||||
(".txt", FileType::StringValue),
|
||||
(".rbxmx", FileType::XmlModel),
|
||||
(".rbxm", FileType::BinaryModel),
|
||||
];
|
||||
|
||||
let file_name = file.path.file_name()?.to_str()?;
|
||||
|
||||
for (extension, file_type) in EXTENSIONS_TO_TYPES {
|
||||
if let Some(instance_name) = get_trailing(file_name, extension) {
|
||||
return Some((instance_name, *file_type))
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct LocalizationEntryCsv {
|
||||
key: String,
|
||||
context: String,
|
||||
example: String,
|
||||
source: String,
|
||||
#[serde(flatten)]
|
||||
values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl LocalizationEntryCsv {
|
||||
fn to_json(self) -> LocalizationEntryJson {
|
||||
LocalizationEntryJson {
|
||||
key: self.key,
|
||||
context: self.context,
|
||||
example: self.example,
|
||||
source: self.source,
|
||||
values: self.values,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalizationEntryJson {
|
||||
key: String,
|
||||
context: String,
|
||||
example: String,
|
||||
source: String,
|
||||
values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
enum SnapshotError {
|
||||
DidNotExist(PathBuf),
|
||||
|
||||
// TODO: Add file path to the error message?
|
||||
Utf8Error {
|
||||
#[fail(cause)]
|
||||
inner: str::Utf8Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
XmlModelDecodeError {
|
||||
inner: rbx_xml::DecodeError,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
BinaryModelDecodeError {
|
||||
inner: rbx_binary::DecodeError,
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for SnapshotError {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
SnapshotError::DidNotExist(path) => write!(output, "Path did not exist: {}", path.display()),
|
||||
SnapshotError::Utf8Error { inner, path } => {
|
||||
write!(output, "Invalid UTF-8: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::XmlModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed rbxmx model: {:?} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::BinaryModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed rbxm model: {:?} in path {}", inner, path.display())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_xml_model<'a>(
|
||||
instance_name: Cow<'a, str>,
|
||||
file: &ImfsFile,
|
||||
) -> Result<Option<RbxSnapshotInstance<'a>>, SnapshotError> {
|
||||
let mut temp_tree = RbxTree::new(RbxInstanceProperties {
|
||||
name: "Temp".to_owned(),
|
||||
class_name: "Folder".to_owned(),
|
||||
properties: HashMap::new(),
|
||||
});
|
||||
|
||||
let root_id = temp_tree.get_root_id();
|
||||
rbx_xml::decode(&mut temp_tree, root_id, file.contents.as_slice())
|
||||
.map_err(|inner| SnapshotError::XmlModelDecodeError {
|
||||
inner,
|
||||
path: file.path.clone(),
|
||||
})?;
|
||||
|
||||
let root_instance = temp_tree.get_instance(root_id).unwrap();
|
||||
let children = root_instance.get_children_ids();
|
||||
|
||||
match children.len() {
|
||||
0 => Ok(None),
|
||||
1 => {
|
||||
let mut snapshot = snapshot_from_tree(&temp_tree, children[0]).unwrap();
|
||||
snapshot.name = instance_name;
|
||||
Ok(Some(snapshot))
|
||||
},
|
||||
_ => panic!("Rojo doesn't have support for model files with multiple roots yet"),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_binary_model<'a>(
|
||||
instance_name: Cow<'a, str>,
|
||||
file: &ImfsFile,
|
||||
) -> Result<Option<RbxSnapshotInstance<'a>>, SnapshotError> {
|
||||
let mut temp_tree = RbxTree::new(RbxInstanceProperties {
|
||||
name: "Temp".to_owned(),
|
||||
class_name: "Folder".to_owned(),
|
||||
properties: HashMap::new(),
|
||||
});
|
||||
|
||||
let root_id = temp_tree.get_root_id();
|
||||
rbx_binary::decode(&mut temp_tree, root_id, file.contents.as_slice())
|
||||
.map_err(|inner| SnapshotError::BinaryModelDecodeError {
|
||||
inner,
|
||||
path: file.path.clone(),
|
||||
})?;
|
||||
|
||||
let root_instance = temp_tree.get_instance(root_id).unwrap();
|
||||
let children = root_instance.get_children_ids();
|
||||
|
||||
match children.len() {
|
||||
0 => Ok(None),
|
||||
1 => {
|
||||
let mut snapshot = snapshot_from_tree(&temp_tree, children[0]).unwrap();
|
||||
snapshot.name = instance_name;
|
||||
Ok(Some(snapshot))
|
||||
},
|
||||
_ => panic!("Rojo doesn't have support for model files with multiple roots yet"),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_instances_from_imfs<'a>(
|
||||
imfs: &'a Imfs,
|
||||
imfs_path: &Path,
|
||||
sync_point_names: &HashMap<PathBuf, String>,
|
||||
) -> Result<Option<RbxSnapshotInstance<'a>>, SnapshotError> {
|
||||
match imfs.get(imfs_path) {
|
||||
Some(ImfsItem::File(file)) => {
|
||||
let (instance_name, file_type) = match classify_file(file) {
|
||||
Some(info) => info,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let instance_name = if let Some(actual_name) = sync_point_names.get(imfs_path) {
|
||||
Cow::Owned(actual_name.clone())
|
||||
} else {
|
||||
Cow::Borrowed(instance_name)
|
||||
};
|
||||
|
||||
let class_name = match file_type {
|
||||
FileType::ModuleScript => "ModuleScript",
|
||||
FileType::ServerScript => "Script",
|
||||
FileType::ClientScript => "LocalScript",
|
||||
FileType::StringValue => "StringValue",
|
||||
FileType::LocalizationTable => "LocalizationTable",
|
||||
FileType::XmlModel => return snapshot_xml_model(instance_name, file),
|
||||
FileType::BinaryModel => return snapshot_binary_model(instance_name, file),
|
||||
};
|
||||
|
||||
let contents = str::from_utf8(&file.contents)
|
||||
.map_err(|inner| SnapshotError::Utf8Error {
|
||||
inner,
|
||||
path: imfs_path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
match file_type {
|
||||
FileType::ModuleScript | FileType::ServerScript | FileType::ClientScript => {
|
||||
properties.insert(String::from("Source"), RbxValue::String {
|
||||
value: contents.to_string(),
|
||||
});
|
||||
},
|
||||
FileType::StringValue => {
|
||||
properties.insert(String::from("Value"), RbxValue::String {
|
||||
value: contents.to_string(),
|
||||
});
|
||||
},
|
||||
FileType::LocalizationTable => {
|
||||
let entries: Vec<LocalizationEntryJson> = csv::Reader::from_reader(contents.as_bytes())
|
||||
.deserialize()
|
||||
.map(|result| result.expect("Malformed localization table found!"))
|
||||
.map(LocalizationEntryCsv::to_json)
|
||||
.collect();
|
||||
|
||||
let table_contents = serde_json::to_string(&entries)
|
||||
.expect("Could not encode JSON for localization table");
|
||||
|
||||
properties.insert(String::from("Contents"), RbxValue::String {
|
||||
value: table_contents,
|
||||
});
|
||||
},
|
||||
FileType::XmlModel | FileType::BinaryModel => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(Some(RbxSnapshotInstance {
|
||||
name: instance_name,
|
||||
class_name: Cow::Borrowed(class_name),
|
||||
properties,
|
||||
children: Vec::new(),
|
||||
source_path: Some(file.path.clone()),
|
||||
metadata: None,
|
||||
}))
|
||||
},
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
// TODO: Expand init support to handle server and client scripts
|
||||
let init_path = directory.path.join(INIT_SCRIPT);
|
||||
let init_server_path = directory.path.join(INIT_SERVER_SCRIPT);
|
||||
let init_client_path = directory.path.join(INIT_CLIENT_SCRIPT);
|
||||
|
||||
let mut instance = if directory.children.contains(&init_path) {
|
||||
snapshot_instances_from_imfs(imfs, &init_path, sync_point_names)?
|
||||
.expect("Could not snapshot instance from file that existed!")
|
||||
} else if directory.children.contains(&init_server_path) {
|
||||
snapshot_instances_from_imfs(imfs, &init_server_path, sync_point_names)?
|
||||
.expect("Could not snapshot instance from file that existed!")
|
||||
} else if directory.children.contains(&init_client_path) {
|
||||
snapshot_instances_from_imfs(imfs, &init_client_path, sync_point_names)?
|
||||
.expect("Could not snapshot instance from file that existed!")
|
||||
} else {
|
||||
RbxSnapshotInstance {
|
||||
class_name: Cow::Borrowed("Folder"),
|
||||
name: Cow::Borrowed(""),
|
||||
properties: HashMap::new(),
|
||||
children: Vec::new(),
|
||||
source_path: Some(directory.path.clone()),
|
||||
metadata: None,
|
||||
}
|
||||
};
|
||||
|
||||
// We have to be careful not to lose instance names that are
|
||||
// specified in the project manifest. We store them in
|
||||
// sync_point_names when the original tree is constructed.
|
||||
instance.name = if let Some(actual_name) = sync_point_names.get(&directory.path) {
|
||||
Cow::Owned(actual_name.clone())
|
||||
} else {
|
||||
Cow::Borrowed(directory.path
|
||||
.file_name().expect("Could not extract file name")
|
||||
.to_str().expect("Could not convert path to UTF-8"))
|
||||
};
|
||||
|
||||
for child_path in &directory.children {
|
||||
match child_path.file_name().unwrap().to_str().unwrap() {
|
||||
INIT_SCRIPT | INIT_SERVER_SCRIPT | INIT_CLIENT_SCRIPT => {
|
||||
// The existence of files with these names modifies the
|
||||
// parent instance and is handled above, so we can skip
|
||||
// them here.
|
||||
},
|
||||
_ => {
|
||||
match snapshot_instances_from_imfs(imfs, child_path, sync_point_names)? {
|
||||
Some(child) => {
|
||||
instance.children.push(child);
|
||||
},
|
||||
None => {},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(instance))
|
||||
},
|
||||
None => Err(SnapshotError::DidNotExist(imfs_path.to_path_buf())),
|
||||
}
|
||||
}
|
||||
307
server/src/rbx_snapshot.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use std::{
|
||||
str,
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use rbx_tree::{RbxTree, RbxId, RbxInstanceProperties, RbxValue};
|
||||
|
||||
use crate::{
|
||||
path_map::PathMap,
|
||||
project::InstanceProjectNodeMetadata,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceChanges {
|
||||
pub added: HashSet<RbxId>,
|
||||
pub removed: HashSet<RbxId>,
|
||||
pub updated: HashSet<RbxId>,
|
||||
}
|
||||
|
||||
impl fmt::Display for InstanceChanges {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "InstanceChanges {{")?;
|
||||
|
||||
if !self.added.is_empty() {
|
||||
writeln!(output, " Added:")?;
|
||||
for id in &self.added {
|
||||
writeln!(output, " {}", id)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.removed.is_empty() {
|
||||
writeln!(output, " Removed:")?;
|
||||
for id in &self.removed {
|
||||
writeln!(output, " {}", id)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.updated.is_empty() {
|
||||
writeln!(output, " Updated:")?;
|
||||
for id in &self.updated {
|
||||
writeln!(output, " {}", id)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl InstanceChanges {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.added.is_empty() && self.removed.is_empty() && self.updated.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RbxSnapshotInstance<'a> {
|
||||
pub name: Cow<'a, str>,
|
||||
pub class_name: Cow<'a, str>,
|
||||
pub properties: HashMap<String, RbxValue>,
|
||||
pub children: Vec<RbxSnapshotInstance<'a>>,
|
||||
pub source_path: Option<PathBuf>,
|
||||
pub metadata: Option<InstanceProjectNodeMetadata>,
|
||||
}
|
||||
|
||||
pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option<RbxSnapshotInstance<'static>> {
|
||||
let instance = tree.get_instance(id)?;
|
||||
|
||||
let mut children = Vec::new();
|
||||
for &child_id in instance.get_children_ids() {
|
||||
children.push(snapshot_from_tree(tree, child_id)?);
|
||||
}
|
||||
|
||||
Some(RbxSnapshotInstance {
|
||||
name: Cow::Owned(instance.name.to_owned()),
|
||||
class_name: Cow::Owned(instance.class_name.to_owned()),
|
||||
properties: instance.properties.clone(),
|
||||
children,
|
||||
source_path: None,
|
||||
metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reify_root(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
path_map: &mut PathMap<RbxId>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, InstanceProjectNodeMetadata>,
|
||||
changes: &mut InstanceChanges,
|
||||
) -> RbxTree {
|
||||
let instance = reify_core(snapshot);
|
||||
let mut tree = RbxTree::new(instance);
|
||||
let root_id = tree.get_root_id();
|
||||
|
||||
if let Some(source_path) = &snapshot.source_path {
|
||||
path_map.insert(source_path.clone(), root_id);
|
||||
}
|
||||
|
||||
if let Some(metadata) = &snapshot.metadata {
|
||||
instance_metadata_map.insert(root_id, metadata.clone());
|
||||
}
|
||||
|
||||
changes.added.insert(root_id);
|
||||
|
||||
for child in &snapshot.children {
|
||||
reify_subtree(child, &mut tree, root_id, path_map, instance_metadata_map, changes);
|
||||
}
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
pub fn reify_subtree(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
tree: &mut RbxTree,
|
||||
parent_id: RbxId,
|
||||
path_map: &mut PathMap<RbxId>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, InstanceProjectNodeMetadata>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
let instance = reify_core(snapshot);
|
||||
let id = tree.insert_instance(instance, parent_id);
|
||||
|
||||
if let Some(source_path) = &snapshot.source_path {
|
||||
path_map.insert(source_path.clone(), id);
|
||||
}
|
||||
|
||||
if let Some(metadata) = &snapshot.metadata {
|
||||
instance_metadata_map.insert(id, metadata.clone());
|
||||
}
|
||||
|
||||
changes.added.insert(id);
|
||||
|
||||
for child in &snapshot.children {
|
||||
reify_subtree(child, tree, id, path_map, instance_metadata_map, changes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reconcile_subtree(
|
||||
tree: &mut RbxTree,
|
||||
id: RbxId,
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
path_map: &mut PathMap<RbxId>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, InstanceProjectNodeMetadata>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
if let Some(source_path) = &snapshot.source_path {
|
||||
path_map.insert(source_path.clone(), id);
|
||||
}
|
||||
|
||||
if let Some(metadata) = &snapshot.metadata {
|
||||
instance_metadata_map.insert(id, metadata.clone());
|
||||
}
|
||||
|
||||
if reconcile_instance_properties(tree.get_instance_mut(id).unwrap(), snapshot) {
|
||||
changes.updated.insert(id);
|
||||
}
|
||||
|
||||
reconcile_instance_children(tree, id, snapshot, path_map, instance_metadata_map, changes);
|
||||
}
|
||||
|
||||
fn reify_core(snapshot: &RbxSnapshotInstance) -> RbxInstanceProperties {
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in &snapshot.properties {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
let instance = RbxInstanceProperties {
|
||||
name: snapshot.name.to_string(),
|
||||
class_name: snapshot.class_name.to_string(),
|
||||
properties,
|
||||
};
|
||||
|
||||
instance
|
||||
}
|
||||
|
||||
fn reconcile_instance_properties(instance: &mut RbxInstanceProperties, snapshot: &RbxSnapshotInstance) -> bool {
|
||||
let mut has_diffs = false;
|
||||
|
||||
if instance.name != snapshot.name {
|
||||
instance.name = snapshot.name.to_string();
|
||||
has_diffs = true;
|
||||
}
|
||||
|
||||
if instance.class_name != snapshot.class_name {
|
||||
instance.class_name = snapshot.class_name.to_string();
|
||||
has_diffs = true;
|
||||
}
|
||||
|
||||
let mut property_updates = HashMap::new();
|
||||
|
||||
for (key, instance_value) in &instance.properties {
|
||||
match snapshot.properties.get(key) {
|
||||
Some(snapshot_value) => {
|
||||
if snapshot_value != instance_value {
|
||||
property_updates.insert(key.clone(), Some(snapshot_value.clone()));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
property_updates.insert(key.clone(), None);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (key, snapshot_value) in &snapshot.properties {
|
||||
if property_updates.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match instance.properties.get(key) {
|
||||
Some(instance_value) => {
|
||||
if snapshot_value != instance_value {
|
||||
property_updates.insert(key.clone(), Some(snapshot_value.clone()));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
property_updates.insert(key.clone(), Some(snapshot_value.clone()));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
has_diffs = has_diffs || !property_updates.is_empty();
|
||||
|
||||
for (key, change) in property_updates.drain() {
|
||||
match change {
|
||||
Some(value) => instance.properties.insert(key, value),
|
||||
None => instance.properties.remove(&key),
|
||||
};
|
||||
}
|
||||
|
||||
has_diffs
|
||||
}
|
||||
|
||||
fn reconcile_instance_children(
|
||||
tree: &mut RbxTree,
|
||||
id: RbxId,
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
path_map: &mut PathMap<RbxId>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, InstanceProjectNodeMetadata>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
let mut visited_snapshot_indices = HashSet::new();
|
||||
|
||||
let mut children_to_update: Vec<(RbxId, &RbxSnapshotInstance)> = Vec::new();
|
||||
let mut children_to_add: Vec<&RbxSnapshotInstance> = Vec::new();
|
||||
let mut children_to_remove: Vec<RbxId> = Vec::new();
|
||||
|
||||
let children_ids = tree.get_instance(id).unwrap().get_children_ids();
|
||||
|
||||
// Find all instances that were removed or updated, which we derive by
|
||||
// trying to pair up existing instances to snapshots.
|
||||
for &child_id in children_ids {
|
||||
let child_instance = tree.get_instance(child_id).unwrap();
|
||||
|
||||
// Locate a matching snapshot for this instance
|
||||
let mut matching_snapshot = None;
|
||||
for (snapshot_index, child_snapshot) in snapshot.children.iter().enumerate() {
|
||||
if visited_snapshot_indices.contains(&snapshot_index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// We assume that instances with the same name are probably pretty
|
||||
// similar. This heuristic is similar to React's reconciliation
|
||||
// strategy.
|
||||
if child_snapshot.name == child_instance.name {
|
||||
visited_snapshot_indices.insert(snapshot_index);
|
||||
matching_snapshot = Some(child_snapshot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match matching_snapshot {
|
||||
Some(child_snapshot) => {
|
||||
children_to_update.push((child_instance.get_id(), child_snapshot));
|
||||
},
|
||||
None => {
|
||||
children_to_remove.push(child_instance.get_id());
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Find all instancs that were added, which is just the snapshots we didn't
|
||||
// match up to existing instances above.
|
||||
for (snapshot_index, child_snapshot) in snapshot.children.iter().enumerate() {
|
||||
if !visited_snapshot_indices.contains(&snapshot_index) {
|
||||
children_to_add.push(child_snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
for child_snapshot in &children_to_add {
|
||||
reify_subtree(child_snapshot, tree, id, path_map, instance_metadata_map, changes);
|
||||
}
|
||||
|
||||
for child_id in &children_to_remove {
|
||||
if let Some(subtree) = tree.remove_instance(*child_id) {
|
||||
for id in subtree.iter_all_ids() {
|
||||
instance_metadata_map.remove(&id);
|
||||
changes.removed.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (child_id, child_snapshot) in &children_to_update {
|
||||
reconcile_subtree(tree, *child_id, child_snapshot, path_map, instance_metadata_map, changes);
|
||||
}
|
||||
}
|
||||
63
server/src/roblox_studio.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Interactions with Roblox Studio's installation, including its location and
|
||||
//! mechanisms like PluginSettings.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(all(not(debug_assertions), not(feature = "bundle-plugin")))]
|
||||
compile_error!("`bundle-plugin` feature must be set for release builds.");
|
||||
|
||||
#[cfg(feature = "bundle-plugin")]
|
||||
static PLUGIN_RBXM: &'static [u8] = include_bytes!("../target/plugin.rbxmx");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn get_install_location() -> Option<PathBuf> {
|
||||
use std::env;
|
||||
|
||||
let local_app_data = env::var("LocalAppData").ok()?;
|
||||
let mut location = PathBuf::from(local_app_data);
|
||||
|
||||
location.push("Roblox");
|
||||
|
||||
Some(location)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_install_location() -> Option<PathBuf> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
pub fn get_install_location() -> Option<PathBuf> {
|
||||
// Roblox Studio doesn't install on any other platforms!
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_plugin_location() -> Option<PathBuf> {
|
||||
let mut location = get_install_location()?;
|
||||
|
||||
location.push("Plugins/Rojo.rbxmx");
|
||||
|
||||
Some(location)
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundle-plugin")]
|
||||
pub fn install_bundled_plugin() -> Option<()> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
info!("Installing plugin...");
|
||||
|
||||
let mut file = File::create(get_plugin_location()?).ok()?;
|
||||
file.write_all(PLUGIN_RBXM).ok()?;
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "bundle-plugin"))]
|
||||
pub fn install_bundled_plugin() -> Option<()> {
|
||||
info!("Skipping plugin installation, bundle-plugin not set.");
|
||||
|
||||
Some(())
|
||||
}
|
||||
61
server/src/session.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
io,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
message_queue::MessageQueue,
|
||||
project::Project,
|
||||
imfs::Imfs,
|
||||
session_id::SessionId,
|
||||
rbx_session::RbxSession,
|
||||
rbx_snapshot::InstanceChanges,
|
||||
fs_watcher::FsWatcher,
|
||||
};
|
||||
|
||||
pub struct Session {
|
||||
pub project: Arc<Project>,
|
||||
pub session_id: SessionId,
|
||||
pub message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
pub rbx_session: Arc<Mutex<RbxSession>>,
|
||||
pub imfs: Arc<Mutex<Imfs>>,
|
||||
_fs_watcher: FsWatcher,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(project: Arc<Project>) -> io::Result<Session> {
|
||||
let imfs = {
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
|
||||
Arc::new(Mutex::new(imfs))
|
||||
};
|
||||
let message_queue = Arc::new(MessageQueue::new());
|
||||
|
||||
let rbx_session = Arc::new(Mutex::new(RbxSession::new(
|
||||
Arc::clone(&project),
|
||||
Arc::clone(&imfs),
|
||||
Arc::clone(&message_queue),
|
||||
)));
|
||||
|
||||
let fs_watcher = FsWatcher::start(
|
||||
Arc::clone(&imfs),
|
||||
Arc::clone(&rbx_session),
|
||||
);
|
||||
|
||||
let session_id = SessionId::new();
|
||||
|
||||
Ok(Session {
|
||||
project,
|
||||
session_id,
|
||||
message_queue,
|
||||
rbx_session,
|
||||
imfs,
|
||||
_fs_watcher: fs_watcher,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project(&self) -> &Project {
|
||||
&self.project
|
||||
}
|
||||
}
|
||||
11
server/src/session_id.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SessionId(Uuid);
|
||||
|
||||
impl SessionId {
|
||||
pub fn new() -> SessionId {
|
||||
SessionId(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
141
server/src/visualize.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use std::{
|
||||
fmt,
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use rbx_tree::RbxId;
|
||||
|
||||
use crate::{
|
||||
imfs::{Imfs, ImfsItem},
|
||||
rbx_session::RbxSession,
|
||||
};
|
||||
|
||||
static GRAPHVIZ_HEADER: &str = r#"
|
||||
digraph RojoTree {
|
||||
rankdir = "LR";
|
||||
graph [
|
||||
ranksep = "0.7",
|
||||
nodesep = "0.5",
|
||||
];
|
||||
node [
|
||||
fontname = "Hack",
|
||||
shape = "record",
|
||||
];
|
||||
"#;
|
||||
|
||||
pub fn graphviz_to_svg(source: &str) -> String {
|
||||
let mut child = Command::new("dot")
|
||||
.arg("-Tsvg")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to spawn GraphViz process -- make sure it's installed in order to use /api/visualize");
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
|
||||
stdin.write_all(source.as_bytes()).expect("Failed to write to stdin");
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().expect("Failed to read stdout");
|
||||
String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8")
|
||||
}
|
||||
|
||||
pub struct VisualizeRbxSession<'a>(pub &'a RbxSession);
|
||||
|
||||
impl<'a> fmt::Display for VisualizeRbxSession<'a> {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "{}", GRAPHVIZ_HEADER)?;
|
||||
|
||||
visualize_rbx_node(self.0, self.0.get_tree().get_root_id(), output)?;
|
||||
|
||||
writeln!(output, "}}")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn visualize_rbx_node(session: &RbxSession, id: RbxId, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
let node = session.get_tree().get_instance(id).unwrap();
|
||||
|
||||
let mut node_label = format!("{}|{}|{}", node.name, node.class_name, id);
|
||||
|
||||
if let Some(metadata) = session.get_instance_metadata(id) {
|
||||
node_label.push('|');
|
||||
node_label.push_str(&serde_json::to_string(metadata).unwrap());
|
||||
}
|
||||
|
||||
node_label = node_label
|
||||
.replace("\"", """)
|
||||
.replace("{", "\\{")
|
||||
.replace("}", "\\}");
|
||||
|
||||
writeln!(output, " \"{}\" [label=\"{}\"]", id, node_label)?;
|
||||
|
||||
for &child_id in node.get_children_ids() {
|
||||
writeln!(output, " \"{}\" -> \"{}\"", id, child_id)?;
|
||||
visualize_rbx_node(session, child_id, output)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct VisualizeImfs<'a>(pub &'a Imfs);
|
||||
|
||||
impl<'a> fmt::Display for VisualizeImfs<'a> {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "{}", GRAPHVIZ_HEADER)?;
|
||||
|
||||
for root_path in self.0.get_roots() {
|
||||
visualize_root_path(self.0, root_path, output)?;
|
||||
}
|
||||
|
||||
writeln!(output, "}}")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_name(path: &Path) -> String {
|
||||
path.to_str().unwrap().replace("\\", "/")
|
||||
}
|
||||
|
||||
fn visualize_root_path(imfs: &Imfs, path: &Path, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
let normalized_name = normalize_name(path);
|
||||
let item = imfs.get(path).unwrap();
|
||||
|
||||
writeln!(output, " \"{}\"", normalized_name)?;
|
||||
|
||||
match item {
|
||||
ImfsItem::File(_) => {},
|
||||
ImfsItem::Directory(directory) => {
|
||||
for child_path in &directory.children {
|
||||
writeln!(output, " \"{}\" -> \"{}\"", normalized_name, normalize_name(child_path))?;
|
||||
visualize_path(imfs, child_path, output)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn visualize_path(imfs: &Imfs, path: &Path, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
let normalized_name = normalize_name(path);
|
||||
let short_name = path.file_name().unwrap().to_string_lossy();
|
||||
let item = imfs.get(path).unwrap();
|
||||
|
||||
writeln!(output, " \"{}\" [label = \"{}\"]", normalized_name, short_name)?;
|
||||
|
||||
match item {
|
||||
ImfsItem::File(_) => {},
|
||||
ImfsItem::Directory(directory) => {
|
||||
for child_path in &directory.children {
|
||||
writeln!(output, " \"{}\" -> \"{}\"", normalized_name, normalize_name(child_path))?;
|
||||
visualize_path(imfs, child_path, output)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
221
server/src/web.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{mpsc, Arc},
|
||||
};
|
||||
|
||||
use rouille::{
|
||||
self,
|
||||
router,
|
||||
Request,
|
||||
Response,
|
||||
};
|
||||
use rbx_tree::{RbxId, RbxInstance};
|
||||
|
||||
use crate::{
|
||||
session::Session,
|
||||
session_id::SessionId,
|
||||
project::InstanceProjectNodeMetadata,
|
||||
rbx_snapshot::InstanceChanges,
|
||||
visualize::{VisualizeRbxSession, VisualizeImfs, graphviz_to_svg},
|
||||
};
|
||||
|
||||
/// Used to attach metadata specific to Rojo to instances, which come from the
|
||||
/// rbx_tree crate.
|
||||
///
|
||||
/// Both fields are wrapped in Cow in order to make owned-vs-borrowed simpler
|
||||
/// for tests.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceWithMetadata<'a> {
|
||||
#[serde(flatten)]
|
||||
pub instance: Cow<'a, RbxInstance>,
|
||||
|
||||
#[serde(rename = "Metadata")]
|
||||
pub metadata: Option<Cow<'a, InstanceProjectNodeMetadata>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerInfoResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub server_version: &'a str,
|
||||
pub protocol_version: u64,
|
||||
pub expected_place_ids: Option<HashSet<u64>>,
|
||||
pub root_instance_id: RbxId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub message_cursor: u32,
|
||||
pub instances: HashMap<RbxId, InstanceWithMetadata<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscribeResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub message_cursor: u32,
|
||||
pub messages: Cow<'a, [InstanceChanges]>,
|
||||
}
|
||||
|
||||
pub struct Server {
|
||||
session: Arc<Session>,
|
||||
server_version: &'static str,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(session: Arc<Session>) -> Server {
|
||||
Server {
|
||||
session,
|
||||
server_version: env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
pub fn handle_request(&self, request: &Request) -> Response {
|
||||
trace!("Request {} {}", request.method(), request.url());
|
||||
|
||||
router!(request,
|
||||
(GET) (/) => {
|
||||
Response::text("Rojo is up and running!")
|
||||
},
|
||||
|
||||
(GET) (/api/rojo) => {
|
||||
// Get a summary of information about the server.
|
||||
|
||||
let rbx_session = self.session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
Response::json(&ServerInfoResponse {
|
||||
server_version: self.server_version,
|
||||
protocol_version: 2,
|
||||
session_id: self.session.session_id,
|
||||
expected_place_ids: self.session.project.serve_place_ids.clone(),
|
||||
root_instance_id: tree.get_root_id(),
|
||||
})
|
||||
},
|
||||
|
||||
(GET) (/api/subscribe/{ cursor: u32 }) => {
|
||||
// Retrieve any messages past the given cursor index, and if
|
||||
// there weren't any, subscribe to receive any new messages.
|
||||
|
||||
let message_queue = Arc::clone(&self.session.message_queue);
|
||||
|
||||
// Did the client miss any messages since the last subscribe?
|
||||
{
|
||||
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
|
||||
|
||||
if !new_messages.is_empty() {
|
||||
return Response::json(&SubscribeResponse {
|
||||
session_id: self.session.session_id,
|
||||
messages: Cow::Borrowed(&new_messages),
|
||||
message_cursor: new_cursor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let sender_id = message_queue.subscribe(tx);
|
||||
|
||||
match rx.recv() {
|
||||
Ok(_) => (),
|
||||
Err(_) => return Response::text("error!").with_status_code(500),
|
||||
}
|
||||
|
||||
message_queue.unsubscribe(sender_id);
|
||||
|
||||
{
|
||||
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
|
||||
|
||||
return Response::json(&SubscribeResponse {
|
||||
session_id: self.session.session_id,
|
||||
messages: Cow::Owned(new_messages),
|
||||
message_cursor: new_cursor,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
(GET) (/api/read/{ id_list: String }) => {
|
||||
let message_queue = Arc::clone(&self.session.message_queue);
|
||||
|
||||
let requested_ids: Option<Vec<RbxId>> = id_list
|
||||
.split(',')
|
||||
.map(RbxId::parse_str)
|
||||
.collect();
|
||||
|
||||
let requested_ids = match requested_ids {
|
||||
Some(id) => id,
|
||||
None => return rouille::Response::text("Malformed ID list").with_status_code(400),
|
||||
};
|
||||
|
||||
let rbx_session = self.session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
let message_cursor = message_queue.get_message_cursor();
|
||||
|
||||
let mut instances = HashMap::new();
|
||||
|
||||
for &requested_id in &requested_ids {
|
||||
if let Some(instance) = tree.get_instance(requested_id) {
|
||||
let metadata = rbx_session.get_instance_metadata(requested_id)
|
||||
.map(Cow::Borrowed);
|
||||
|
||||
instances.insert(instance.get_id(), InstanceWithMetadata {
|
||||
instance: Cow::Borrowed(instance),
|
||||
metadata,
|
||||
});
|
||||
|
||||
for descendant in tree.descendants(requested_id) {
|
||||
let descendant_meta = rbx_session.get_instance_metadata(descendant.get_id())
|
||||
.map(Cow::Borrowed);
|
||||
|
||||
instances.insert(descendant.get_id(), InstanceWithMetadata {
|
||||
instance: Cow::Borrowed(descendant),
|
||||
metadata: descendant_meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Response::json(&ReadResponse {
|
||||
session_id: self.session.session_id,
|
||||
message_cursor,
|
||||
instances,
|
||||
})
|
||||
},
|
||||
|
||||
(GET) (/visualize/rbx) => {
|
||||
let rbx_session = self.session.rbx_session.lock().unwrap();
|
||||
|
||||
let dot_source = format!("{}", VisualizeRbxSession(&rbx_session));
|
||||
|
||||
Response::svg(graphviz_to_svg(&dot_source))
|
||||
},
|
||||
|
||||
(GET) (/visualize/imfs) => {
|
||||
let imfs = self.session.imfs.lock().unwrap();
|
||||
|
||||
let dot_source = format!("{}", VisualizeImfs(&imfs));
|
||||
|
||||
Response::svg(graphviz_to_svg(&dot_source))
|
||||
},
|
||||
|
||||
(GET) (/visualize/path_map) => {
|
||||
let rbx_session = self.session.rbx_session.lock().unwrap();
|
||||
|
||||
Response::json(&rbx_session.debug_get_path_map())
|
||||
},
|
||||
|
||||
_ => Response::empty_404()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn listen(self, port: u16) {
|
||||
let address = format!("0.0.0.0:{}", port);
|
||||
|
||||
rouille::start_server(address, move |request| self.handle_request(request));
|
||||
}
|
||||
}
|
||||
43
server/src/web_util.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::io::Read;
|
||||
|
||||
use rouille;
|
||||
use serde;
|
||||
use serde_json;
|
||||
|
||||
static MAX_BODY_SIZE: usize = 100 * 1024 * 1024; // 100 MiB
|
||||
|
||||
/// Pulls text that may be JSON out of a Rouille Request object.
|
||||
///
|
||||
/// Doesn't do any actual parsing -- all this method does is verify the content
|
||||
/// type of the request and read the request's body.
|
||||
fn read_json_text(request: &rouille::Request) -> Option<String> {
|
||||
// Bail out if the request body isn't marked as JSON
|
||||
let content_type = request.header("Content-Type")?;
|
||||
|
||||
if !content_type.starts_with("application/json") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = request.data()?;
|
||||
|
||||
// Allocate a buffer and read up to MAX_BODY_SIZE+1 bytes into it.
|
||||
let mut out = Vec::new();
|
||||
body.take(MAX_BODY_SIZE.saturating_add(1) as u64).read_to_end(&mut out).ok()?;
|
||||
|
||||
// If the body was too big (MAX_BODY_SIZE+1), we abort instead of trying to
|
||||
// process it.
|
||||
if out.len() > MAX_BODY_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// Reads the body out of a Rouille Request and attempts to turn it into JSON.
|
||||
pub fn read_json<T>(request: &rouille::Request) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
let body = read_json_text(&request)?;
|
||||
serde_json::from_str(&body).ok()?
|
||||
}
|
||||
18
server/test-scratch-project
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
if [ ! -d "../test-projects/$1" ]
|
||||
then
|
||||
echo "Pick a project that exists!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "scratch" ]
|
||||
then
|
||||
rm -rf scratch
|
||||
fi
|
||||
|
||||
mkdir -p scratch
|
||||
cp -r "../test-projects/$1" scratch
|
||||
cargo run -- serve "scratch/$1"
|
||||
307
server/tests/imfs.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
use librojo::{
|
||||
imfs::{Imfs, ImfsItem, ImfsFile, ImfsDirectory},
|
||||
};
|
||||
|
||||
#[allow(unused)]
|
||||
enum FsEvent {
|
||||
Created(PathBuf),
|
||||
Updated(PathBuf),
|
||||
Removed(PathBuf),
|
||||
Moved(PathBuf, PathBuf),
|
||||
}
|
||||
|
||||
fn send_events(imfs: &mut Imfs, events: &[FsEvent]) -> io::Result<()> {
|
||||
for event in events {
|
||||
match event {
|
||||
FsEvent::Created(path) => imfs.path_created(path)?,
|
||||
FsEvent::Updated(path) => imfs.path_updated(path)?,
|
||||
FsEvent::Removed(path) => imfs.path_removed(path)?,
|
||||
FsEvent::Moved(from, to) => imfs.path_moved(from, to)?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct ExpectedImfs {
|
||||
roots: HashSet<PathBuf>,
|
||||
items: HashMap<PathBuf, ImfsItem>,
|
||||
}
|
||||
|
||||
struct TestResources {
|
||||
foo_path: PathBuf,
|
||||
bar_path: PathBuf,
|
||||
baz_path: PathBuf,
|
||||
}
|
||||
|
||||
fn check_expected(real: &Imfs, expected: &ExpectedImfs) {
|
||||
assert_eq!(real.get_roots(), &expected.roots);
|
||||
|
||||
let real_items = real.get_items();
|
||||
if real_items != &expected.items {
|
||||
let real_str = serde_json::to_string(real_items).unwrap();
|
||||
let expected_str = serde_json::to_string(&expected.items).unwrap();
|
||||
|
||||
panic!("Items differed!\nReal:\n{}\nExpected:\n{}\n", real_str, expected_str);
|
||||
}
|
||||
}
|
||||
|
||||
fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
|
||||
let root = tempdir()?;
|
||||
|
||||
let foo_path = root.path().join("foo");
|
||||
let bar_path = root.path().join("bar.txt");
|
||||
let baz_path = foo_path.join("baz.txt");
|
||||
|
||||
let resources = TestResources {
|
||||
foo_path: foo_path.clone(),
|
||||
bar_path: bar_path.clone(),
|
||||
baz_path: baz_path.clone(),
|
||||
};
|
||||
|
||||
fs::create_dir(&foo_path)?;
|
||||
fs::write(&bar_path, b"bar")?;
|
||||
fs::write(&baz_path, b"baz")?;
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_root(root.path())?;
|
||||
|
||||
let mut expected_roots = HashSet::new();
|
||||
expected_roots.insert(root.path().to_path_buf());
|
||||
|
||||
let root_item = {
|
||||
let mut children = HashSet::new();
|
||||
children.insert(foo_path.clone());
|
||||
children.insert(bar_path.clone());
|
||||
|
||||
ImfsItem::Directory(ImfsDirectory {
|
||||
path: root.path().to_path_buf(),
|
||||
children,
|
||||
})
|
||||
};
|
||||
|
||||
let foo_item = {
|
||||
let mut children = HashSet::new();
|
||||
children.insert(baz_path.clone());
|
||||
|
||||
ImfsItem::Directory(ImfsDirectory {
|
||||
path: foo_path.clone(),
|
||||
children,
|
||||
})
|
||||
};
|
||||
|
||||
let bar_item = ImfsItem::File(ImfsFile {
|
||||
path: bar_path.clone(),
|
||||
contents: b"bar".to_vec(),
|
||||
});
|
||||
|
||||
let baz_item = ImfsItem::File(ImfsFile {
|
||||
path: baz_path.clone(),
|
||||
contents: b"baz".to_vec(),
|
||||
});
|
||||
|
||||
let mut expected_items = HashMap::new();
|
||||
expected_items.insert(root.path().to_path_buf(), root_item);
|
||||
expected_items.insert(foo_path.clone(), foo_item);
|
||||
expected_items.insert(bar_path.clone(), bar_item);
|
||||
expected_items.insert(baz_path.clone(), baz_item);
|
||||
|
||||
let expected_imfs = ExpectedImfs {
|
||||
roots: expected_roots,
|
||||
items: expected_items,
|
||||
};
|
||||
|
||||
Ok((root, imfs, expected_imfs, resources))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_read() -> io::Result<()> {
|
||||
let (_root, imfs, expected_imfs, _resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_files() -> io::Result<()> {
|
||||
let (root, mut imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
let add_one_path = root.path().join("add_one.txt");
|
||||
let add_two_path = resources.foo_path.join("add_two.txt");
|
||||
|
||||
fs::write(&add_one_path, b"add_one")?;
|
||||
fs::write(&add_two_path, b"add_two")?;
|
||||
|
||||
match expected_imfs.items.get_mut(root.path()) {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.insert(add_one_path.clone());
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
match expected_imfs.items.get_mut(&resources.foo_path) {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.insert(add_two_path.clone());
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
expected_imfs.items.insert(add_one_path.clone(), ImfsItem::File(ImfsFile {
|
||||
path: add_one_path.clone(),
|
||||
contents: b"add_one".to_vec(),
|
||||
}));
|
||||
|
||||
expected_imfs.items.insert(add_two_path.clone(), ImfsItem::File(ImfsFile {
|
||||
path: add_two_path.clone(),
|
||||
contents: b"add_two".to_vec(),
|
||||
}));
|
||||
|
||||
imfs.path_created(&add_one_path)?;
|
||||
imfs.path_created(&add_two_path)?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_folder() -> io::Result<()> {
|
||||
let (root, imfs, mut expected_imfs, _resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
let folder_path = root.path().join("folder");
|
||||
let file1_path = folder_path.join("file1.txt");
|
||||
let file2_path = folder_path.join("file2.txt");
|
||||
|
||||
fs::create_dir(&folder_path)?;
|
||||
fs::write(&file1_path, b"file1")?;
|
||||
fs::write(&file2_path, b"file2")?;
|
||||
|
||||
match expected_imfs.items.get_mut(root.path()) {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.insert(folder_path.clone());
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let folder_item = {
|
||||
let mut children = HashSet::new();
|
||||
children.insert(file1_path.clone());
|
||||
children.insert(file2_path.clone());
|
||||
|
||||
ImfsItem::Directory(ImfsDirectory {
|
||||
path: folder_path.clone(),
|
||||
children,
|
||||
})
|
||||
};
|
||||
|
||||
expected_imfs.items.insert(folder_path.clone(), folder_item);
|
||||
|
||||
let file1_item = ImfsItem::File(ImfsFile {
|
||||
path: file1_path.clone(),
|
||||
contents: b"file1".to_vec(),
|
||||
});
|
||||
expected_imfs.items.insert(file1_path.clone(), file1_item);
|
||||
|
||||
let file2_item = ImfsItem::File(ImfsFile {
|
||||
path: file2_path.clone(),
|
||||
contents: b"file2".to_vec(),
|
||||
});
|
||||
expected_imfs.items.insert(file2_path.clone(), file2_item);
|
||||
|
||||
let possible_event_sequences = vec![
|
||||
vec![
|
||||
FsEvent::Created(folder_path.clone())
|
||||
],
|
||||
vec![
|
||||
FsEvent::Created(folder_path.clone()),
|
||||
FsEvent::Created(file1_path.clone()),
|
||||
FsEvent::Created(file2_path.clone()),
|
||||
],
|
||||
];
|
||||
|
||||
for events in &possible_event_sequences {
|
||||
let mut imfs = imfs.clone();
|
||||
|
||||
send_events(&mut imfs, events)?;
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_file() -> io::Result<()> {
|
||||
let (root, mut imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
fs::remove_file(&resources.bar_path)?;
|
||||
|
||||
imfs.path_removed(&resources.bar_path)?;
|
||||
|
||||
match expected_imfs.items.get_mut(root.path()) {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.remove(&resources.bar_path);
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
expected_imfs.items.remove(&resources.bar_path);
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_folder() -> io::Result<()> {
|
||||
let (root, imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
fs::remove_dir_all(&resources.foo_path)?;
|
||||
|
||||
match expected_imfs.items.get_mut(root.path()) {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.remove(&resources.foo_path);
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
expected_imfs.items.remove(&resources.foo_path);
|
||||
expected_imfs.items.remove(&resources.baz_path);
|
||||
|
||||
let possible_event_sequences = vec![
|
||||
vec![
|
||||
FsEvent::Removed(resources.foo_path.clone()),
|
||||
],
|
||||
vec![
|
||||
FsEvent::Removed(resources.baz_path.clone()),
|
||||
FsEvent::Removed(resources.foo_path.clone()),
|
||||
],
|
||||
];
|
||||
|
||||
for events in &possible_event_sequences {
|
||||
let mut imfs = imfs.clone();
|
||||
|
||||
send_events(&mut imfs, events)?;
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
107
server/tests/read_projects.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
#[macro_use] extern crate lazy_static;
|
||||
|
||||
extern crate librojo;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use rbx_tree::RbxValue;
|
||||
|
||||
use librojo::{
|
||||
project::{Project, ProjectNode, InstanceProjectNode, SyncPointProjectNode},
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref TEST_PROJECTS_ROOT: PathBuf = {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects")
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("empty/roblox-project.json");
|
||||
let project = Project::load_exact(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_fuzzy_file() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("empty/roblox-project.json");
|
||||
let project = Project::load_fuzzy(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_fuzzy_folder() {
|
||||
let project_location = TEST_PROJECTS_ROOT.join("empty");
|
||||
let project = Project::load_fuzzy(&project_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sync_point() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("single-sync-point/roblox-project.json");
|
||||
let project = Project::load_exact(&project_file_location).unwrap();
|
||||
|
||||
let expected_project = {
|
||||
let foo = ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_file_location.parent().unwrap().join("lib"),
|
||||
});
|
||||
|
||||
let mut replicated_storage_children = HashMap::new();
|
||||
replicated_storage_children.insert("Foo".to_string(), foo);
|
||||
|
||||
let replicated_storage = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "ReplicatedStorage".to_string(),
|
||||
children: replicated_storage_children,
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
|
||||
let mut http_service_properties = HashMap::new();
|
||||
http_service_properties.insert("HttpEnabled".to_string(), RbxValue::Bool {
|
||||
value: true,
|
||||
});
|
||||
|
||||
let http_service = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "HttpService".to_string(),
|
||||
children: HashMap::new(),
|
||||
properties: http_service_properties,
|
||||
metadata: Default::default(),
|
||||
});
|
||||
|
||||
let mut root_children = HashMap::new();
|
||||
root_children.insert("ReplicatedStorage".to_string(), replicated_storage);
|
||||
root_children.insert("HttpService".to_string(), http_service);
|
||||
|
||||
let root_node = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "DataModel".to_string(),
|
||||
children: root_children,
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
|
||||
Project {
|
||||
name: "single-sync-point".to_string(),
|
||||
tree: root_node,
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_file_location.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(project, expected_project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("test-model/roblox-project.json");
|
||||
let project = Project::load_exact(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "test-model");
|
||||
}
|
||||
54
server/tests/test_util/mod.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::fs::{create_dir, copy};
|
||||
use std::path::Path;
|
||||
use std::io;
|
||||
|
||||
use rouille::Request;
|
||||
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use librojo::web::Server;
|
||||
|
||||
pub trait HttpTestUtil {
|
||||
fn get_string(&self, url: &str) -> String;
|
||||
}
|
||||
|
||||
impl HttpTestUtil for Server {
|
||||
fn get_string(&self, url: &str) -> String {
|
||||
let info_request = Request::fake_http("GET", url, vec![], vec![]);
|
||||
let response = self.handle_request(&info_request);
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
|
||||
let (mut reader, _) = response.data.into_reader_and_size();
|
||||
let mut body = String::new();
|
||||
reader.read_to_string(&mut body).unwrap();
|
||||
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
|
||||
for entry in WalkDir::new(from) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let new_path = to.join(path.strip_prefix(from).unwrap());
|
||||
|
||||
let file_type = entry.file_type();
|
||||
|
||||
if file_type.is_dir() {
|
||||
match create_dir(new_path) {
|
||||
Ok(_) => {},
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::AlreadyExists => {},
|
||||
_ => panic!(err),
|
||||
}
|
||||
}
|
||||
} else if file_type.is_file() {
|
||||
copy(path, new_path)?;
|
||||
} else {
|
||||
unimplemented!("no symlinks please");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||