Compare commits
276 Commits
v0.4.2
...
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 |
@@ -3,8 +3,16 @@ root = true
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = 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
|
||||
4
.gitignore
vendored
@@ -1 +1,5 @@
|
||||
/site
|
||||
/target
|
||||
/server/scratch
|
||||
**/*.rs.bk
|
||||
/generate-docs.run
|
||||
3
.gitmodules
vendored
@@ -13,3 +13,6 @@
|
||||
[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
|
||||
|
||||
@@ -29,11 +29,10 @@ matrix:
|
||||
rust: stable
|
||||
|
||||
script:
|
||||
- cd server
|
||||
- cargo test --verbose
|
||||
|
||||
- language: rust
|
||||
rust: beta
|
||||
|
||||
script:
|
||||
- cd server
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose
|
||||
93
CHANGES.md
@@ -1,11 +1,86 @@
|
||||
# Rojo Change Log
|
||||
|
||||
## Current Master
|
||||
*No changes*
|
||||
## 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.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.
|
||||
* 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.
|
||||
@@ -13,9 +88,9 @@
|
||||
|
||||
## 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.
|
||||
* 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))
|
||||
|
||||
@@ -25,18 +100,18 @@
|
||||
|
||||
## 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 (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 (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 (December 1, 2017)
|
||||
* Plugin only release
|
||||
|
||||
2068
Cargo.lock
generated
Normal file
5
Cargo.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[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) 2018 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.
|
||||
198
README.md
@@ -1,21 +1,28 @@
|
||||
<div align="center">
|
||||
<img src="assets/rojo-logo.png" alt="Rojo" height="217" />
|
||||
<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>
|
||||
<img src="https://img.shields.io/badge/latest_version-0.4.2-brightgreen.svg" alt="Current server version" />
|
||||
<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 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.
|
||||
|
||||
Rojo is designed for **power users** who want to use the **best tools available** for building games, libraries, and plugins.
|
||||
|
||||
## Features
|
||||
Rojo lets you:
|
||||
@@ -24,183 +31,38 @@ Rojo lets you:
|
||||
* 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 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:
|
||||
## [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.
|
||||
|
||||
* 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.
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
## Server Usage
|
||||
For more help, use `rojo help`.
|
||||
|
||||
### New Project
|
||||
Create a new folder, then use `rojo init` inside that folder to initialize an empty project:
|
||||
|
||||
```sh
|
||||
mkdir my-new-project
|
||||
cd my-new-project
|
||||
|
||||
rojo init
|
||||
```
|
||||
|
||||
Rojo will create an empty project file named `rojo.json` in the directory.
|
||||
|
||||
The default project file is:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-new-project",
|
||||
"servePort": 8000,
|
||||
"partitions": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Start Dev Server
|
||||
To start the Rojo dev server, 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
|
||||
[Bi-directional script syncing](https://github.com/LPGhatguy/rojo/issues/5) is on the roadmap for Rojo this year, but isn't implemented.
|
||||
|
||||
In the mean-time, manually migrating scripts is probably the best route forward.
|
||||
|
||||
### 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 on your filesystem to Roblox objects.
|
||||
|
||||
Each entry in the `partitions` map 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, starting at `game`. If any objects don't exist along the way, Rojo will try to create them.
|
||||
|
||||
**Any objects in a partition may be wiped away by Rojo after syncing! If this is not desired, use multiple, smaller partitions.**
|
||||
|
||||
Run `rojo serve` in the directory containing this project, then press the "Sync In" or "Toggle Polling" buttons in the Roblox Studio plugin to sync into your game.
|
||||
|
||||
### Sync Details
|
||||
The structure of files and diectories 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 the example below |
|
||||
| `*` | `StringValue` | `Value` will contain the file's contents |
|
||||
|
||||
Any directories on the filesystem will turn into `Folder` objects.
|
||||
|
||||
Any directory containing one of these files will instead be a `ModuleScript`, `Script`, `LocalScript` containing the directory's contents:
|
||||
|
||||
* `init.lua`
|
||||
* `init.server.lua`
|
||||
* `init.client.lua`
|
||||
|
||||
For example, this file tree:
|
||||
|
||||
* my-game
|
||||
* init.client.lua
|
||||
* foo.lua
|
||||
|
||||
Will turn into these instances in Roblox:
|
||||
|
||||
* `my-game` (`LocalScript` with source from `my-game/init.client.lua`)
|
||||
* `foo` (`ModuleScript` with source from `my-game/foo.lua`)
|
||||
|
||||
`*.model.json` files are a way to represent simple Roblox instances on the filesystem until `rbxmx` and `rbxlx` support is implemented in Rojo.
|
||||
|
||||
This feature is intended for small instances, like `RemoteEvent` or `*Value` objects.
|
||||
|
||||
JSON Model files are strict, with every property being required. They look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "hello",
|
||||
"ClassName": "Model",
|
||||
"Children": [
|
||||
{
|
||||
"Name": "Some Part",
|
||||
"ClassName": "Part",
|
||||
"Children": [],
|
||||
"Properties": {}
|
||||
},
|
||||
{
|
||||
"Name": "Some StringValue",
|
||||
"ClassName": "StringValue",
|
||||
"Children": [],
|
||||
"Properties": {
|
||||
"Value": {
|
||||
"Type": "String",
|
||||
"Value": "Hello, world!"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"Properties": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Inspiration
|
||||
## 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)
|
||||
* [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)
|
||||
|
||||
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
|
||||
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!
|
||||
|
||||
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-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 |
@@ -1,6 +1,11 @@
|
||||
# Home
|
||||
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 work in progress, and is incomplete.
|
||||
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.
|
||||
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"
|
||||
10
mkdocs.yml
@@ -1,5 +1,4 @@
|
||||
site_name: Rojo Documentation
|
||||
site_url: https://lpghatguy.github.io/rojo/
|
||||
repo_name: LPGhatguy/rojo
|
||||
repo_url: https://github.com/LPGhatguy/rojo
|
||||
|
||||
@@ -9,10 +8,17 @@ theme:
|
||||
primary: 'Red'
|
||||
accent: 'Red'
|
||||
|
||||
pages:
|
||||
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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[*.lua]
|
||||
indent_style = tab
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
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/promise
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,31 @@
|
||||
"partitions": {
|
||||
"plugin": {
|
||||
"path": "src",
|
||||
"target": "ReplicatedStorage.Rojo.plugin"
|
||||
"target": "ReplicatedStorage.Rojo.Plugin"
|
||||
},
|
||||
"modules/roact": {
|
||||
"path": "modules/roact/lib",
|
||||
"target": "ReplicatedStorage.Rojo.modules.Roact"
|
||||
"target": "ReplicatedStorage.Rojo.Roact"
|
||||
},
|
||||
"modules/rodux": {
|
||||
"path": "modules/rodux/lib",
|
||||
"target": "ReplicatedStorage.Rojo.modules.Rodux"
|
||||
"target": "ReplicatedStorage.Rojo.Rodux"
|
||||
},
|
||||
"modules/roact-rodux": {
|
||||
"path": "modules/roact-rodux/lib",
|
||||
"target": "ReplicatedStorage.Rojo.modules.RoactRodux"
|
||||
"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": "tests",
|
||||
"target": "TestService"
|
||||
"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)
|
||||
@@ -2,68 +2,16 @@
|
||||
Loads our library and all of its dependencies, then runs tests using TestEZ.
|
||||
]]
|
||||
|
||||
-- If you add any dependencies, add them to this table so they'll be loaded!
|
||||
local LOAD_MODULES = {
|
||||
{"src", "Plugin"},
|
||||
{"modules/testez/lib", "TestEZ"},
|
||||
}
|
||||
local loadEnvironment = require("loadEnvironment")
|
||||
|
||||
-- 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")
|
||||
|
||||
--[[
|
||||
Collapses ModuleScripts named 'init' into their parent folders.
|
||||
|
||||
This is the same result as the collapsing mechanism from Rojo.
|
||||
]]
|
||||
local function collapse(root)
|
||||
local init = root:FindFirstChild("init")
|
||||
if init then
|
||||
init.Name = root.Name
|
||||
init.Parent = root.Parent
|
||||
|
||||
for _, child in ipairs(root:GetChildren()) do
|
||||
child.Parent = init
|
||||
end
|
||||
|
||||
root:Destroy()
|
||||
root = init
|
||||
end
|
||||
|
||||
for _, child in ipairs(root:GetChildren()) do
|
||||
if child:IsA("Folder") then
|
||||
collapse(child)
|
||||
end
|
||||
end
|
||||
|
||||
return root
|
||||
end
|
||||
|
||||
-- Create a virtual Roblox tree
|
||||
local habitat = lemur.Habitat.new()
|
||||
|
||||
-- We'll put all of our library code and dependencies here
|
||||
local Root = lemur.Instance.new("Folder")
|
||||
Root.Name = "Root"
|
||||
|
||||
-- Load all of the modules specified above
|
||||
for _, module in ipairs(LOAD_MODULES) do
|
||||
local container = lemur.Instance.new("Folder", Root)
|
||||
container.Name = module[2]
|
||||
habitat:loadFromFs(module[1], container)
|
||||
end
|
||||
|
||||
collapse(Root)
|
||||
local habitat, modules = loadEnvironment()
|
||||
|
||||
-- Load TestEZ and run our tests
|
||||
local TestEZ = habitat:require(Root.TestEZ)
|
||||
local TestEZ = habitat:require(modules.TestEZ)
|
||||
|
||||
local results = TestEZ.TestBootstrap:run(Root.Plugin, TestEZ.Reporters.TextReporter)
|
||||
local results = TestEZ.TestBootstrap:run({modules.Rojo}, TestEZ.Reporters.TextReporter)
|
||||
|
||||
-- Did something go wrong?
|
||||
if results.failureCount > 0 then
|
||||
os.exit(1)
|
||||
end
|
||||
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
|
||||
@@ -1,7 +1,8 @@
|
||||
return {
|
||||
pollingRate = 0.2,
|
||||
version = {0, 4, 2},
|
||||
expectedServerVersionString = "0.4.x",
|
||||
protocolVersion = 1,
|
||||
dev = false,
|
||||
}
|
||||
codename = "Epiphany",
|
||||
version = {0, 5, 0, "-alpha.0"},
|
||||
expectedServerVersionString = "0.5.0 or newer",
|
||||
protocolVersion = 2,
|
||||
defaultHost = "localhost",
|
||||
defaultPort = 34872,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
return function()
|
||||
local Config = require(script.Parent.Config)
|
||||
|
||||
it("should have 'dev' disabled", function()
|
||||
expect(Config.dev).to.equal(false)
|
||||
end)
|
||||
end
|
||||
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,
|
||||
}
|
||||
@@ -1,67 +1,75 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local HTTP_DEBUG = false
|
||||
local Promise = require(script.Parent.Parent.Promise)
|
||||
|
||||
local Promise = require(script.Parent.Promise)
|
||||
local Logging = require(script.Parent.Logging)
|
||||
local HttpError = require(script.Parent.HttpError)
|
||||
local HttpResponse = require(script.Parent.HttpResponse)
|
||||
|
||||
local function dprint(...)
|
||||
if HTTP_DEBUG then
|
||||
print(...)
|
||||
end
|
||||
end
|
||||
local lastRequestId = 0
|
||||
|
||||
-- TODO: Factor out into separate library, especially error handling
|
||||
local Http = {}
|
||||
Http.__index = Http
|
||||
|
||||
function Http.new(baseUrl)
|
||||
assert(type(baseUrl) == "string", "Http.new needs a baseUrl!")
|
||||
function Http.get(url)
|
||||
local requestId = lastRequestId + 1
|
||||
lastRequestId = requestId
|
||||
|
||||
local http = {
|
||||
baseUrl = baseUrl
|
||||
}
|
||||
Logging.trace("GET(%d) %s", requestId, url)
|
||||
|
||||
setmetatable(http, Http)
|
||||
|
||||
return http
|
||||
end
|
||||
|
||||
function Http:get(endpoint)
|
||||
dprint("\nGET", endpoint)
|
||||
return Promise.new(function(resolve, reject)
|
||||
spawn(function()
|
||||
local ok, result = pcall(function()
|
||||
return HttpService:GetAsync(self.baseUrl .. endpoint, true)
|
||||
coroutine.wrap(function()
|
||||
local success, response = pcall(function()
|
||||
return HttpService:RequestAsync({
|
||||
Url = url,
|
||||
Method = "GET",
|
||||
})
|
||||
end)
|
||||
|
||||
if ok then
|
||||
dprint("\t", result, "\n")
|
||||
resolve(HttpResponse.new(result))
|
||||
if success then
|
||||
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
|
||||
resolve(HttpResponse.fromRobloxResponse(response))
|
||||
else
|
||||
reject(HttpError.fromErrorString(result))
|
||||
Logging.trace("Request %d failure: %s", requestId, response)
|
||||
reject(HttpError.fromErrorString(response))
|
||||
end
|
||||
end)
|
||||
end)()
|
||||
end)
|
||||
end
|
||||
|
||||
function Http:post(endpoint, body)
|
||||
dprint("\nPOST", endpoint)
|
||||
dprint(body)
|
||||
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)
|
||||
spawn(function()
|
||||
local ok, result = pcall(function()
|
||||
return HttpService:PostAsync(self.baseUrl .. endpoint, body)
|
||||
coroutine.wrap(function()
|
||||
local success, response = pcall(function()
|
||||
return HttpService:RequestAsync({
|
||||
Url = url,
|
||||
Method = "POST",
|
||||
Body = body,
|
||||
})
|
||||
end)
|
||||
|
||||
if ok then
|
||||
dprint("\t", result, "\n")
|
||||
resolve(HttpResponse.new(result))
|
||||
if success then
|
||||
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
|
||||
resolve(HttpResponse.fromRobloxResponse(response))
|
||||
else
|
||||
reject(HttpError.fromErrorString(result))
|
||||
Logging.trace("Request %d failure: %s", requestId, response)
|
||||
reject(HttpError.fromErrorString(response))
|
||||
end
|
||||
end)
|
||||
end)()
|
||||
end)
|
||||
end
|
||||
|
||||
return Http
|
||||
function Http.jsonEncode(object)
|
||||
return HttpService:JSONEncode(object)
|
||||
end
|
||||
|
||||
function Http.jsonDecode(source)
|
||||
return HttpService:JSONDecode(source)
|
||||
end
|
||||
|
||||
return Http
|
||||
@@ -1,3 +1,5 @@
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
local HttpError = {}
|
||||
HttpError.__index = HttpError
|
||||
|
||||
@@ -7,14 +9,23 @@ HttpError.Error = {
|
||||
"Check your game settings, located in the 'Home' tab of Studio.",
|
||||
},
|
||||
ConnectFailed = {
|
||||
message = "Rojo plugin couldn't connect to the Rojo server.\n" ..
|
||||
"Make sure the server is running -- use 'Rojo serve' to run it!",
|
||||
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 = "Rojo encountered an unknown error: {{message}}",
|
||||
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)
|
||||
@@ -36,22 +47,26 @@ end
|
||||
--[[
|
||||
This method shouldn't have to exist. Ugh.
|
||||
]]
|
||||
function HttpError.fromErrorString(err)
|
||||
err = err:lower()
|
||||
function HttpError.fromErrorString(message)
|
||||
local lower = message:lower()
|
||||
|
||||
if err:find("^http requests are not enabled") then
|
||||
if lower:find("^http requests are not enabled") then
|
||||
return HttpError.new(HttpError.Error.HttpNotEnabled)
|
||||
end
|
||||
|
||||
if err:find("^curl error") then
|
||||
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, err)
|
||||
return HttpError.new(HttpError.Error.Unknown, message)
|
||||
end
|
||||
|
||||
function HttpError:report()
|
||||
warn(self.message)
|
||||
Logging.warn(self.message)
|
||||
end
|
||||
|
||||
return HttpError
|
||||
return HttpError
|
||||
@@ -1,16 +1,30 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local stringTemplate = [[
|
||||
HttpResponse {
|
||||
code: %d
|
||||
body: %s
|
||||
}]]
|
||||
|
||||
local HttpResponse = {}
|
||||
HttpResponse.__index = HttpResponse
|
||||
|
||||
function HttpResponse.new(body)
|
||||
local response = {
|
||||
body = body,
|
||||
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,
|
||||
}
|
||||
|
||||
setmetatable(response, HttpResponse)
|
||||
return setmetatable(self, HttpResponse)
|
||||
end
|
||||
|
||||
return response
|
||||
function HttpResponse:isSuccess()
|
||||
return self.code >= 200 and self.code < 300
|
||||
end
|
||||
|
||||
function HttpResponse:json()
|
||||
|
||||
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
|
||||
@@ -1,77 +0,0 @@
|
||||
if not plugin then
|
||||
return
|
||||
end
|
||||
|
||||
local Plugin = require(script.Parent.Plugin)
|
||||
local Config = require(script.Parent.Config)
|
||||
local Version = require(script.Parent.Version)
|
||||
|
||||
--[[
|
||||
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()
|
||||
local lastVersion = plugin:GetSetting("LastRojoVersion")
|
||||
|
||||
if lastVersion then
|
||||
local wasUpgraded = Version.compare(Config.version, lastVersion) == 1
|
||||
|
||||
if wasUpgraded then
|
||||
local message = (
|
||||
"\nRojo detected an upgrade from version %s to version %s." ..
|
||||
"\nMake sure you have also upgraded your server!" ..
|
||||
"\n\nRojo version %s is intended for use with server version %s.\n"
|
||||
):format(
|
||||
Version.display(lastVersion), Version.display(Config.version),
|
||||
Version.display(Config.version), Config.expectedServerVersionString
|
||||
)
|
||||
|
||||
print(message)
|
||||
end
|
||||
end
|
||||
|
||||
-- When developing Rojo, there's no use in storing that version number.
|
||||
if not Config.dev then
|
||||
plugin:SetSetting("LastRojoVersion", Config.version)
|
||||
end
|
||||
end
|
||||
|
||||
local function main()
|
||||
local pluginInstance = Plugin.new()
|
||||
|
||||
local displayedVersion = Config.dev and "DEV" or Version.display(Config.version)
|
||||
|
||||
local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion)
|
||||
|
||||
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "")
|
||||
.Click:Connect(function()
|
||||
checkUpgrade()
|
||||
|
||||
pluginInstance:connect()
|
||||
:catch(function(err)
|
||||
warn(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "")
|
||||
.Click:Connect(function()
|
||||
checkUpgrade()
|
||||
|
||||
pluginInstance:syncIn()
|
||||
:catch(function(err)
|
||||
warn(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
toolbar:CreateButton("Toggle Polling", "Poll server for changes", "")
|
||||
.Click:Connect(function()
|
||||
checkUpgrade()
|
||||
|
||||
pluginInstance:togglePolling()
|
||||
:catch(function(err)
|
||||
warn(err)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
main()
|
||||
@@ -1,198 +0,0 @@
|
||||
local Config = require(script.Parent.Config)
|
||||
local Http = require(script.Parent.Http)
|
||||
local Server = require(script.Parent.Server)
|
||||
local Promise = require(script.Parent.Promise)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
local function collectMatch(source, pattern)
|
||||
local result = {}
|
||||
|
||||
for match in source:gmatch(pattern) do
|
||||
table.insert(result, match)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
local Plugin = {}
|
||||
Plugin.__index = Plugin
|
||||
|
||||
function Plugin.new()
|
||||
local address = "localhost"
|
||||
local port = Config.dev and 8001 or 8000
|
||||
|
||||
local remote = ("http://%s:%d"):format(address, port)
|
||||
|
||||
local self = {
|
||||
_http = Http.new(remote),
|
||||
_reconciler = Reconciler.new(),
|
||||
_server = nil,
|
||||
_polling = false,
|
||||
}
|
||||
|
||||
setmetatable(self, Plugin)
|
||||
|
||||
do
|
||||
local screenGui = Instance.new("ScreenGui")
|
||||
screenGui.Name = "Rojo UI"
|
||||
screenGui.Parent = game.CoreGui
|
||||
screenGui.DisplayOrder = -1
|
||||
screenGui.Enabled = false
|
||||
|
||||
local label = Instance.new("TextLabel")
|
||||
label.Font = Enum.Font.SourceSans
|
||||
label.TextSize = 20
|
||||
label.Text = "Rojo polling..."
|
||||
label.BackgroundColor3 = Color3.fromRGB(31, 31, 31)
|
||||
label.BackgroundTransparency = 0.5
|
||||
label.BorderSizePixel = 0
|
||||
label.TextColor3 = Color3.new(1, 1, 1)
|
||||
label.Size = UDim2.new(0, 120, 0, 28)
|
||||
label.Position = UDim2.new(0, 0, 0, 0)
|
||||
label.Parent = screenGui
|
||||
|
||||
self._label = screenGui
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function Plugin:server()
|
||||
if not self._server then
|
||||
self._server = Server.connect(self._http)
|
||||
:catch(function(err)
|
||||
self._server = nil
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
end
|
||||
|
||||
return self._server
|
||||
end
|
||||
|
||||
function Plugin:connect()
|
||||
print("Testing connection...")
|
||||
|
||||
return self:server()
|
||||
:andThen(function(server)
|
||||
return server:getInfo()
|
||||
end)
|
||||
:andThen(function(result)
|
||||
print("Server found!")
|
||||
print("Protocol version:", result.protocolVersion)
|
||||
print("Server version:", result.serverVersion)
|
||||
end)
|
||||
end
|
||||
|
||||
function Plugin:togglePolling()
|
||||
if self._polling then
|
||||
self:stopPolling()
|
||||
|
||||
return Promise.resolve(nil)
|
||||
else
|
||||
return self:startPolling()
|
||||
end
|
||||
end
|
||||
|
||||
function Plugin:stopPolling()
|
||||
if not self._polling then
|
||||
return
|
||||
end
|
||||
|
||||
print("Stopped polling.")
|
||||
|
||||
self._polling = false
|
||||
self._label.Enabled = false
|
||||
end
|
||||
|
||||
function Plugin:_pull(server, project, routes)
|
||||
local items = server:read(routes):await()
|
||||
|
||||
for index = 1, #routes do
|
||||
local itemRoute = routes[index]
|
||||
local partitionName = itemRoute[1]
|
||||
local partition = project.partitions[partitionName]
|
||||
local item = items[index]
|
||||
|
||||
local partitionRoute = collectMatch(partition.target, "[^.]+")
|
||||
|
||||
-- If the item route's length was 1, we need to rename the instance to
|
||||
-- line up with the partition's root object name.
|
||||
--
|
||||
-- This is a HACK!
|
||||
if #itemRoute == 1 then
|
||||
if item then
|
||||
local objectName = partition.target:match("[^.]+$")
|
||||
item.Name = objectName
|
||||
end
|
||||
end
|
||||
|
||||
local fullRoute = {}
|
||||
for _, piece in ipairs(partitionRoute) do
|
||||
table.insert(fullRoute, piece)
|
||||
end
|
||||
|
||||
for i = 2, #itemRoute do
|
||||
table.insert(fullRoute, itemRoute[i])
|
||||
end
|
||||
|
||||
self._reconciler:reconcileRoute(fullRoute, item, itemRoute)
|
||||
end
|
||||
end
|
||||
|
||||
function Plugin:startPolling()
|
||||
if self._polling then
|
||||
return
|
||||
end
|
||||
|
||||
print("Starting to poll...")
|
||||
|
||||
self._polling = true
|
||||
self._label.Enabled = true
|
||||
|
||||
return self:server()
|
||||
:andThen(function(server)
|
||||
self:syncIn():await()
|
||||
|
||||
local project = server:getInfo():await().project
|
||||
|
||||
while self._polling do
|
||||
local changes = server:getChanges():await()
|
||||
|
||||
if #changes > 0 then
|
||||
local routes = {}
|
||||
|
||||
for _, change in ipairs(changes) do
|
||||
table.insert(routes, change.route)
|
||||
end
|
||||
|
||||
self:_pull(server, project, routes)
|
||||
end
|
||||
|
||||
wait(Config.pollingRate)
|
||||
end
|
||||
end)
|
||||
:catch(function()
|
||||
self:stopPolling()
|
||||
end)
|
||||
end
|
||||
|
||||
function Plugin:syncIn()
|
||||
print("Syncing from server...")
|
||||
|
||||
return self:server()
|
||||
:andThen(function(server)
|
||||
local project = server:getInfo():await().project
|
||||
|
||||
local routes = {}
|
||||
|
||||
for name in pairs(project.partitions) do
|
||||
table.insert(routes, {name})
|
||||
end
|
||||
|
||||
self:_pull(server, project, routes)
|
||||
|
||||
print("Sync successful!")
|
||||
end)
|
||||
end
|
||||
|
||||
return Plugin
|
||||
@@ -1,311 +0,0 @@
|
||||
--[[
|
||||
An implementation of Promises similar to Promise/A+.
|
||||
]]
|
||||
|
||||
local PROMISE_DEBUG = false
|
||||
|
||||
-- If promise debugging is on, use a version of pcall that warns on failure.
|
||||
-- This is useful for finding errors that happen within Promise itself.
|
||||
local wpcall
|
||||
if PROMISE_DEBUG then
|
||||
wpcall = function(f, ...)
|
||||
local result = { pcall(f, ...) }
|
||||
|
||||
if not result[1] then
|
||||
warn(result[2])
|
||||
end
|
||||
|
||||
return unpack(result)
|
||||
end
|
||||
else
|
||||
wpcall = pcall
|
||||
end
|
||||
|
||||
--[[
|
||||
Creates a function that invokes a callback with correct error handling and
|
||||
resolution mechanisms.
|
||||
]]
|
||||
local function createAdvancer(callback, resolve, reject)
|
||||
return function(...)
|
||||
local result = { wpcall(callback, ...) }
|
||||
local ok = table.remove(result, 1)
|
||||
|
||||
if ok then
|
||||
resolve(unpack(result))
|
||||
else
|
||||
reject(unpack(result))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function isEmpty(t)
|
||||
return next(t) == nil
|
||||
end
|
||||
|
||||
local Promise = {}
|
||||
Promise.__index = Promise
|
||||
|
||||
Promise.Status = {
|
||||
Started = "Started",
|
||||
Resolved = "Resolved",
|
||||
Rejected = "Rejected",
|
||||
}
|
||||
|
||||
--[[
|
||||
Constructs a new Promise with the given initializing callback.
|
||||
|
||||
This is generally only called when directly wrapping a non-promise API into
|
||||
a promise-based version.
|
||||
|
||||
The callback will receive 'resolve' and 'reject' methods, used to start
|
||||
invoking the promise chain.
|
||||
|
||||
For example:
|
||||
|
||||
local function get(url)
|
||||
return Promise.new(function(resolve, reject)
|
||||
spawn(function()
|
||||
resolve(HttpService:GetAsync(url))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
get("https://google.com")
|
||||
:andThen(function(stuff)
|
||||
print("Got some stuff!", stuff)
|
||||
end)
|
||||
]]
|
||||
function Promise.new(callback)
|
||||
local promise = {
|
||||
-- Used to locate where a promise was created
|
||||
_source = debug.traceback(),
|
||||
|
||||
-- A tag to identify us as a promise
|
||||
_type = "Promise",
|
||||
|
||||
_status = Promise.Status.Started,
|
||||
|
||||
-- A table containing a list of all results, whether success or failure.
|
||||
-- Only valid if _status is set to something besides Started
|
||||
_value = nil,
|
||||
|
||||
-- If an error occurs with no observers, this will be set.
|
||||
_unhandledRejection = false,
|
||||
|
||||
-- Queues representing functions we should invoke when we update!
|
||||
_queuedResolve = {},
|
||||
_queuedReject = {},
|
||||
}
|
||||
|
||||
setmetatable(promise, Promise)
|
||||
|
||||
local function resolve(...)
|
||||
promise:_resolve(...)
|
||||
end
|
||||
|
||||
local function reject(...)
|
||||
promise:_reject(...)
|
||||
end
|
||||
|
||||
local ok, err = wpcall(callback, resolve, reject)
|
||||
|
||||
if not ok and promise._status == Promise.Status.Started then
|
||||
reject(err)
|
||||
end
|
||||
|
||||
return promise
|
||||
end
|
||||
|
||||
--[[
|
||||
Create a promise that represents the immediately resolved value.
|
||||
]]
|
||||
function Promise.resolve(value)
|
||||
return Promise.new(function(resolve)
|
||||
resolve(value)
|
||||
end)
|
||||
end
|
||||
|
||||
--[[
|
||||
Create a promise that represents the immediately rejected value.
|
||||
]]
|
||||
function Promise.reject(value)
|
||||
return Promise.new(function(_, reject)
|
||||
reject(value)
|
||||
end)
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns a new promise that:
|
||||
* is resolved when all input promises resolve
|
||||
* is rejected if ANY input promises reject
|
||||
]]
|
||||
function Promise.all(...)
|
||||
error("unimplemented", 2)
|
||||
end
|
||||
|
||||
--[[
|
||||
Is the given object a Promise instance?
|
||||
]]
|
||||
function Promise.is(object)
|
||||
if type(object) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
return object._type == "Promise"
|
||||
end
|
||||
|
||||
--[[
|
||||
Creates a new promise that receives the result of this promise.
|
||||
|
||||
The given callbacks are invoked depending on that result.
|
||||
]]
|
||||
function Promise:andThen(successHandler, failureHandler)
|
||||
self._unhandledRejection = false
|
||||
|
||||
-- Create a new promise to follow this part of the chain
|
||||
return Promise.new(function(resolve, reject)
|
||||
-- Our default callbacks just pass values onto the next promise.
|
||||
-- This lets success and failure cascade correctly!
|
||||
|
||||
local successCallback = resolve
|
||||
if successHandler then
|
||||
successCallback = createAdvancer(successHandler, resolve, reject)
|
||||
end
|
||||
|
||||
local failureCallback = reject
|
||||
if failureHandler then
|
||||
failureCallback = createAdvancer(failureHandler, resolve, reject)
|
||||
end
|
||||
|
||||
if self._status == Promise.Status.Started then
|
||||
-- If we haven't resolved yet, put ourselves into the queue
|
||||
table.insert(self._queuedResolve, successCallback)
|
||||
table.insert(self._queuedReject, failureCallback)
|
||||
elseif self._status == Promise.Status.Resolved then
|
||||
-- This promise has already resolved! Trigger success immediately.
|
||||
successCallback(unpack(self._value))
|
||||
elseif self._status == Promise.Status.Rejected then
|
||||
-- This promise died a terrible death! Trigger failure immediately.
|
||||
failureCallback(unpack(self._value))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--[[
|
||||
Used to catch any errors that may have occurred in the promise.
|
||||
]]
|
||||
function Promise:catch(failureCallback)
|
||||
return self:andThen(nil, failureCallback)
|
||||
end
|
||||
|
||||
--[[
|
||||
Yield until the promise is completed.
|
||||
|
||||
This matches the execution model of normal Roblox functions.
|
||||
]]
|
||||
function Promise:await()
|
||||
self._unhandledRejection = false
|
||||
|
||||
if self._status == Promise.Status.Started then
|
||||
local result
|
||||
local bindable = Instance.new("BindableEvent")
|
||||
|
||||
self:andThen(function(...)
|
||||
result = {...}
|
||||
bindable:Fire(true)
|
||||
end, function(...)
|
||||
result = {...}
|
||||
bindable:Fire(false)
|
||||
end)
|
||||
|
||||
local ok = bindable.Event:Wait()
|
||||
bindable:Destroy()
|
||||
|
||||
if not ok then
|
||||
error(tostring(result[1]), 2)
|
||||
end
|
||||
|
||||
return unpack(result)
|
||||
elseif self._status == Promise.Status.Resolved then
|
||||
return unpack(self._value)
|
||||
elseif self._status == Promise.Status.Rejected then
|
||||
error(tostring(self._value[1]), 2)
|
||||
end
|
||||
end
|
||||
|
||||
function Promise:_resolve(...)
|
||||
if self._status ~= Promise.Status.Started then
|
||||
return
|
||||
end
|
||||
|
||||
-- If the resolved value was a Promise, we chain onto it!
|
||||
if Promise.is((...)) then
|
||||
-- Without this warning, arguments sometimes mysteriously disappear
|
||||
if select("#", ...) > 1 then
|
||||
local message = (
|
||||
"When returning a Promise from andThen, extra arguments are " ..
|
||||
"discarded! See:\n\n%s"
|
||||
):format(
|
||||
self._source
|
||||
)
|
||||
warn(message)
|
||||
end
|
||||
|
||||
(...):andThen(function(...)
|
||||
self:_resolve(...)
|
||||
end, function(...)
|
||||
self:_reject(...)
|
||||
end)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
self._status = Promise.Status.Resolved
|
||||
self._value = {...}
|
||||
|
||||
-- We assume that these callbacks will not throw errors.
|
||||
for _, callback in ipairs(self._queuedResolve) do
|
||||
callback(...)
|
||||
end
|
||||
end
|
||||
|
||||
function Promise:_reject(...)
|
||||
if self._status ~= Promise.Status.Started then
|
||||
return
|
||||
end
|
||||
|
||||
self._status = Promise.Status.Rejected
|
||||
self._value = {...}
|
||||
|
||||
-- If there are any rejection handlers, call those!
|
||||
if not isEmpty(self._queuedReject) then
|
||||
-- We assume that these callbacks will not throw errors.
|
||||
for _, callback in ipairs(self._queuedReject) do
|
||||
callback(...)
|
||||
end
|
||||
else
|
||||
-- At this point, no one was able to observe the error.
|
||||
-- An error handler might still be attached if the error occurred
|
||||
-- synchronously. We'll wait one tick, and if there are still no
|
||||
-- observers, then we should put a message in the console.
|
||||
|
||||
self._unhandledRejection = true
|
||||
local err = tostring((...))
|
||||
|
||||
spawn(function()
|
||||
-- Someone observed the error, hooray!
|
||||
if not self._unhandledRejection then
|
||||
return
|
||||
end
|
||||
|
||||
-- Build a reasonable message
|
||||
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
|
||||
err,
|
||||
self._source
|
||||
)
|
||||
warn(message)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return Promise
|
||||
@@ -1,262 +0,0 @@
|
||||
return function()
|
||||
local Promise = require(script.Parent.Promise)
|
||||
|
||||
describe("Promise.new", function()
|
||||
it("should instantiate with a callback", function()
|
||||
local promise = Promise.new(function() end)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should invoke the given callback with resolve and reject", function()
|
||||
local callCount = 0
|
||||
local resolveArg
|
||||
local rejectArg
|
||||
|
||||
local promise = Promise.new(function(resolve, reject)
|
||||
callCount = callCount + 1
|
||||
resolveArg = resolve
|
||||
rejectArg = reject
|
||||
end)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(resolveArg).to.be.a("function")
|
||||
expect(rejectArg).to.be.a("function")
|
||||
expect(promise._status).to.equal(Promise.Status.Started)
|
||||
end)
|
||||
|
||||
it("should resolve promises on resolve()", function()
|
||||
local callCount = 0
|
||||
|
||||
local promise = Promise.new(function(resolve)
|
||||
callCount = callCount + 1
|
||||
resolve()
|
||||
end)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(callCount).to.equal(1)
|
||||
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||
end)
|
||||
|
||||
it("should reject promises on reject()", function()
|
||||
local callCount = 0
|
||||
|
||||
local promise = Promise.new(function(resolve, reject)
|
||||
callCount = callCount + 1
|
||||
reject()
|
||||
end)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(callCount).to.equal(1)
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
end)
|
||||
|
||||
it("should reject on error in callback", function()
|
||||
local callCount = 0
|
||||
|
||||
local promise = Promise.new(function()
|
||||
callCount = callCount + 1
|
||||
error("hahah")
|
||||
end)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(callCount).to.equal(1)
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
expect(promise._value[1]:find("hahah")).to.be.ok()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Promise.resolve", function()
|
||||
it("should immediately resolve with a value", function()
|
||||
local promise = Promise.resolve(5)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||
expect(promise._value[1]).to.equal(5)
|
||||
end)
|
||||
|
||||
it("should chain onto passed promises", function()
|
||||
local promise = Promise.resolve(Promise.new(function(_, reject)
|
||||
reject(7)
|
||||
end))
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
expect(promise._value[1]).to.equal(7)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Promise.reject", function()
|
||||
it("should immediately reject with a value", function()
|
||||
local promise = Promise.reject(6)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
expect(promise._value[1]).to.equal(6)
|
||||
end)
|
||||
|
||||
it("should pass a promise as-is as an error", function()
|
||||
local innerPromise = Promise.new(function(resolve)
|
||||
resolve(6)
|
||||
end)
|
||||
|
||||
local promise = Promise.reject(innerPromise)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
expect(promise._value[1]).to.equal(innerPromise)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Promise:andThen", function()
|
||||
it("should chain onto resolved promises", function()
|
||||
local args
|
||||
local argsLength
|
||||
local callCount = 0
|
||||
local badCallCount = 0
|
||||
|
||||
local promise = Promise.resolve(5)
|
||||
|
||||
local chained = promise
|
||||
:andThen(function(...)
|
||||
args = {...}
|
||||
argsLength = select("#", ...)
|
||||
callCount = callCount + 1
|
||||
end, function()
|
||||
badCallCount = badCallCount + 1
|
||||
end)
|
||||
|
||||
expect(badCallCount).to.equal(0)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(argsLength).to.equal(1)
|
||||
expect(args[1]).to.equal(5)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||
expect(promise._value[1]).to.equal(5)
|
||||
|
||||
expect(chained).to.be.ok()
|
||||
expect(chained).never.to.equal(promise)
|
||||
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||
expect(#chained._value).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should chain onto rejected promises", function()
|
||||
local args
|
||||
local argsLength
|
||||
local callCount = 0
|
||||
local badCallCount = 0
|
||||
|
||||
local promise = Promise.reject(5)
|
||||
|
||||
local chained = promise
|
||||
:andThen(function(...)
|
||||
badCallCount = badCallCount + 1
|
||||
end, function(...)
|
||||
args = {...}
|
||||
argsLength = select("#", ...)
|
||||
callCount = callCount + 1
|
||||
end)
|
||||
|
||||
expect(badCallCount).to.equal(0)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(argsLength).to.equal(1)
|
||||
expect(args[1]).to.equal(5)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
expect(promise._value[1]).to.equal(5)
|
||||
|
||||
expect(chained).to.be.ok()
|
||||
expect(chained).never.to.equal(promise)
|
||||
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||
expect(#chained._value).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should chain onto asynchronously resolved promises", function()
|
||||
local args
|
||||
local argsLength
|
||||
local callCount = 0
|
||||
local badCallCount = 0
|
||||
|
||||
local startResolution
|
||||
local promise = Promise.new(function(resolve)
|
||||
startResolution = resolve
|
||||
end)
|
||||
|
||||
local chained = promise
|
||||
:andThen(function(...)
|
||||
args = {...}
|
||||
argsLength = select("#", ...)
|
||||
callCount = callCount + 1
|
||||
end, function()
|
||||
badCallCount = badCallCount + 1
|
||||
end)
|
||||
|
||||
expect(callCount).to.equal(0)
|
||||
expect(badCallCount).to.equal(0)
|
||||
|
||||
startResolution(6)
|
||||
|
||||
expect(badCallCount).to.equal(0)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(argsLength).to.equal(1)
|
||||
expect(args[1]).to.equal(6)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||
expect(promise._value[1]).to.equal(6)
|
||||
|
||||
expect(chained).to.be.ok()
|
||||
expect(chained).never.to.equal(promise)
|
||||
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||
expect(#chained._value).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should chain onto asynchronously rejected promises", function()
|
||||
local args
|
||||
local argsLength
|
||||
local callCount = 0
|
||||
local badCallCount = 0
|
||||
|
||||
local startResolution
|
||||
local promise = Promise.new(function(_, reject)
|
||||
startResolution = reject
|
||||
end)
|
||||
|
||||
local chained = promise
|
||||
:andThen(function()
|
||||
badCallCount = badCallCount + 1
|
||||
end, function(...)
|
||||
args = {...}
|
||||
argsLength = select("#", ...)
|
||||
callCount = callCount + 1
|
||||
end)
|
||||
|
||||
expect(callCount).to.equal(0)
|
||||
expect(badCallCount).to.equal(0)
|
||||
|
||||
startResolution(6)
|
||||
|
||||
expect(badCallCount).to.equal(0)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(argsLength).to.equal(1)
|
||||
expect(args[1]).to.equal(6)
|
||||
|
||||
expect(promise).to.be.ok()
|
||||
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||
expect(promise._value[1]).to.equal(6)
|
||||
|
||||
expect(chained).to.be.ok()
|
||||
expect(chained).never.to.equal(promise)
|
||||
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||
expect(#chained._value).to.equal(0)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -1,207 +1,281 @@
|
||||
local RouteMap = require(script.Parent.RouteMap)
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
local function classEqual(rbx, className)
|
||||
if className == "*" then
|
||||
return true
|
||||
local function makeInstanceMap()
|
||||
local self = {
|
||||
fromIds = {},
|
||||
fromInstances = {},
|
||||
}
|
||||
|
||||
function self:insert(id, instance)
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
end
|
||||
|
||||
return rbx.ClassName == className
|
||||
end
|
||||
function self:removeId(id)
|
||||
local instance = self.fromIds[id]
|
||||
|
||||
local function reparent(rbx, parent)
|
||||
if rbx then
|
||||
-- It's possible that 'rbx' is a service or some other object that we
|
||||
-- can't change the parent of. That's the only reason why Parent would
|
||||
-- fail except for rbx being previously destroyed!
|
||||
pcall(function()
|
||||
rbx.Parent = parent
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Attempts to match up Roblox instances and object specifiers for
|
||||
reconciliation.
|
||||
|
||||
An object is considered a match if they have the same Name and ClassName.
|
||||
|
||||
primaryChildren and secondaryChildren can each be either a list of Roblox
|
||||
instances or object specifiers. Since they share a common shape, switching
|
||||
the two around isn't problematic!
|
||||
|
||||
visited is expected to be an empty table initially. It will be filled with
|
||||
the set of children that have been visited so far.
|
||||
]]
|
||||
local function findNextChildPair(primaryChildren, secondaryChildren, visited)
|
||||
for _, primaryChild in ipairs(primaryChildren) do
|
||||
if not visited[primaryChild] then
|
||||
visited[primaryChild] = true
|
||||
|
||||
for _, secondaryChild in ipairs(secondaryChildren) do
|
||||
if primaryChild.ClassName == secondaryChild.ClassName and primaryChild.Name == secondaryChild.Name then
|
||||
visited[secondaryChild] = true
|
||||
|
||||
return primaryChild, secondaryChild
|
||||
end
|
||||
end
|
||||
|
||||
return primaryChild, nil
|
||||
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
|
||||
|
||||
return nil, nil
|
||||
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 reconciler = {
|
||||
_routeMap = RouteMap.new(),
|
||||
local self = {
|
||||
instanceMap = makeInstanceMap(),
|
||||
}
|
||||
|
||||
setmetatable(reconciler, Reconciler)
|
||||
|
||||
return reconciler
|
||||
return setmetatable(self, Reconciler)
|
||||
end
|
||||
|
||||
--[[
|
||||
A semi-smart algorithm that attempts to apply the given item's children to
|
||||
an existing Roblox object.
|
||||
]]
|
||||
function Reconciler:_reconcileChildren(rbx, item)
|
||||
local visited = {}
|
||||
local rbxChildren = rbx:GetChildren()
|
||||
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 = {}
|
||||
|
||||
-- Reconcile any children that were added or updated
|
||||
while true do
|
||||
local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited)
|
||||
|
||||
if not itemChild then
|
||||
break
|
||||
end
|
||||
|
||||
reparent(self:reconcile(rbxChild, itemChild), rbx)
|
||||
end
|
||||
|
||||
-- Reconcile any children that were deleted
|
||||
while true do
|
||||
local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited)
|
||||
|
||||
if not rbxChild then
|
||||
break
|
||||
end
|
||||
|
||||
reparent(self:reconcile(rbxChild, itemChild), rbx)
|
||||
for _, id in ipairs(requestedIds) do
|
||||
self:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Construct a new Roblox object from the given item.
|
||||
Update an existing instance, including its properties and children, to match
|
||||
the given information.
|
||||
]]
|
||||
function Reconciler:_reify(item)
|
||||
local className = item.ClassName
|
||||
function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
-- "*" represents a match of any class. It reifies as a folder!
|
||||
if className == "*" then
|
||||
className = "Folder"
|
||||
-- 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
|
||||
|
||||
local rbx = Instance.new(className)
|
||||
rbx.Name = item.Name
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
for key, property in pairs(item.Properties) do
|
||||
-- TODO: Check for compound types, like Vector3!
|
||||
rbx[key] = property.Value
|
||||
-- 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
|
||||
|
||||
self:_reconcileChildren(rbx, item)
|
||||
local existingChildren = instance:GetChildren()
|
||||
|
||||
if item.Route then
|
||||
self._routeMap:insert(item.Route, rbx)
|
||||
local unvisitedExistingChildren = {}
|
||||
for _, child in ipairs(existingChildren) do
|
||||
unvisitedExistingChildren[child] = true
|
||||
end
|
||||
|
||||
return rbx
|
||||
end
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
local childData = virtualInstancesById[childId]
|
||||
|
||||
--[[
|
||||
Apply the changes represented by the given item to a Roblox object that's a
|
||||
child of the given instance.
|
||||
]]
|
||||
function Reconciler:reconcile(rbx, item)
|
||||
-- Item was deleted
|
||||
if not item then
|
||||
if rbx then
|
||||
rbx:Destroy()
|
||||
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
|
||||
|
||||
return nil
|
||||
if existingChildInstance ~= nil then
|
||||
unvisitedExistingChildren[existingChildInstance] = nil
|
||||
self:reconcile(virtualInstancesById, childId, existingChildInstance)
|
||||
else
|
||||
self:__reify(virtualInstancesById, childId, instance)
|
||||
end
|
||||
end
|
||||
|
||||
-- Item was created!
|
||||
if not rbx then
|
||||
return self:_reify(item)
|
||||
if self:__shouldClearUnknownInstances(virtualInstance) then
|
||||
for existingChildInstance in pairs(unvisitedExistingChildren) do
|
||||
self.instanceMap:removeInstance(existingChildInstance)
|
||||
existingChildInstance:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- Item changed type!
|
||||
if not classEqual(rbx, item.ClassName) then
|
||||
rbx:Destroy()
|
||||
-- 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]
|
||||
|
||||
rbx = self:_reify(item)
|
||||
end
|
||||
|
||||
-- Apply all properties, Roblox will de-duplicate changes
|
||||
for key, property in pairs(item.Properties) do
|
||||
-- TODO: Transform property value based on property.Type
|
||||
-- Right now, we assume that 'value' is primitive!
|
||||
|
||||
rbx[key] = property.Value
|
||||
end
|
||||
|
||||
-- Use a dumb algorithm for reconciling children
|
||||
self:_reconcileChildren(rbx, item)
|
||||
|
||||
if item.Route then
|
||||
self._routeMap:insert(item.Route, rbx)
|
||||
end
|
||||
|
||||
return rbx
|
||||
end
|
||||
|
||||
function Reconciler:reconcileRoute(route, item, itemRoute)
|
||||
local parent
|
||||
local rbx = game
|
||||
|
||||
for i = 1, #route do
|
||||
local piece = route[i]
|
||||
|
||||
local child = rbx:FindFirstChild(piece)
|
||||
|
||||
-- We should get services instead of making folders here.
|
||||
if rbx == game and not child then
|
||||
local _
|
||||
_, child = pcall(game.GetService, game, piece)
|
||||
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
|
||||
|
||||
-- We don't want to create a folder if we're reaching our target item!
|
||||
if not child and i ~= #route then
|
||||
child = Instance.new("Folder")
|
||||
child.Parent = rbx
|
||||
child.Name = piece
|
||||
-- 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
|
||||
|
||||
parent = rbx
|
||||
rbx = child
|
||||
end
|
||||
|
||||
-- Let's check the route map!
|
||||
if not rbx then
|
||||
rbx = self._routeMap:get(itemRoute)
|
||||
end
|
||||
|
||||
rbx = self:reconcile(rbx, item)
|
||||
|
||||
reparent(rbx, parent)
|
||||
return instance
|
||||
end
|
||||
|
||||
return Reconciler
|
||||
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
|
||||
@@ -1,103 +0,0 @@
|
||||
--[[
|
||||
A map from Route objects (given by the server) to Roblox instances (created
|
||||
by the plugin).
|
||||
]]
|
||||
|
||||
local function hashRoute(route)
|
||||
return table.concat(route, "/")
|
||||
end
|
||||
|
||||
local RouteMap = {}
|
||||
RouteMap.__index = RouteMap
|
||||
|
||||
function RouteMap.new()
|
||||
local self = {
|
||||
_map = {},
|
||||
_reverseMap = {},
|
||||
_connectionsByRbx = {},
|
||||
}
|
||||
|
||||
setmetatable(self, RouteMap)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function RouteMap:insert(route, rbx)
|
||||
local hashed = hashRoute(route)
|
||||
|
||||
self._map[hashed] = rbx
|
||||
self._reverseMap[rbx] = hashed
|
||||
self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent)
|
||||
if parent == nil then
|
||||
self:removeByRbx(rbx)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function RouteMap:get(route)
|
||||
return self._map[hashRoute(route)]
|
||||
end
|
||||
|
||||
function RouteMap:removeByRoute(route)
|
||||
local hashedRoute = hashRoute(route)
|
||||
local rbx = self._map[hashedRoute]
|
||||
|
||||
if rbx then
|
||||
self._map[hashedRoute] = nil
|
||||
self._reverseMap[rbx] = nil
|
||||
self._connectionsByRbx[rbx] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function RouteMap:removeByRbx(rbx)
|
||||
local hashedRoute = self._reverseMap[rbx]
|
||||
|
||||
if hashedRoute then
|
||||
self._map[hashedRoute] = nil
|
||||
self._reverseMap[rbx] = nil
|
||||
self._connectionsByRbx[rbx] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function RouteMap:removeRbxDescendants(parentRbx)
|
||||
for rbx in pairs(self._reverseMap) do
|
||||
if rbx:IsDescendantOf(parentRbx) then
|
||||
self:removeByRbx(rbx)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RouteMap:clear()
|
||||
self._map = {}
|
||||
self._reverseMap = {}
|
||||
|
||||
for object in pairs(self._connectionsByRbx) do
|
||||
object:Disconnect()
|
||||
end
|
||||
|
||||
self._connectionsByRbx = {}
|
||||
end
|
||||
|
||||
function RouteMap:visualize()
|
||||
-- Log all of our keys so that the visualization has a stable order.
|
||||
local keys = {}
|
||||
|
||||
for key in pairs(self._map) do
|
||||
table.insert(keys, key)
|
||||
end
|
||||
|
||||
table.sort(keys)
|
||||
|
||||
local buffer = {}
|
||||
for _, key in ipairs(keys) do
|
||||
local visualized = ("- %s: %s"):format(
|
||||
key,
|
||||
self._map[key]:GetFullName()
|
||||
)
|
||||
table.insert(buffer, visualized)
|
||||
end
|
||||
|
||||
return table.concat(buffer, "\n")
|
||||
end
|
||||
|
||||
return RouteMap
|
||||
@@ -1,89 +0,0 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Config = require(script.Parent.Config)
|
||||
local Promise = require(script.Parent.Promise)
|
||||
local Version = require(script.Parent.Version)
|
||||
|
||||
local Server = {}
|
||||
Server.__index = Server
|
||||
|
||||
--[[
|
||||
Create a new Server using the given HTTP implementation and replacer.
|
||||
|
||||
If the context becomes invalid, `replacer` will be invoked with a new
|
||||
context that should be suitable to replace this one.
|
||||
|
||||
Attempting to invoke methods on an invalid conext will throw errors!
|
||||
]]
|
||||
function Server.connect(http)
|
||||
local context = {
|
||||
http = http,
|
||||
serverId = nil,
|
||||
currentTime = 0,
|
||||
}
|
||||
|
||||
setmetatable(context, Server)
|
||||
|
||||
return context:_start()
|
||||
end
|
||||
|
||||
function Server:_start()
|
||||
return self:getInfo()
|
||||
:andThen(function(response)
|
||||
if response.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.expectedServerVersionString,
|
||||
response.serverVersion, response.protocolVersion
|
||||
)
|
||||
|
||||
return Promise.reject(message)
|
||||
end
|
||||
|
||||
self.serverId = response.serverId
|
||||
self.currentTime = response.currentTime
|
||||
|
||||
return self
|
||||
end)
|
||||
end
|
||||
|
||||
function Server:getInfo()
|
||||
return self.http:get("/")
|
||||
:andThen(function(response)
|
||||
response = response:json()
|
||||
|
||||
return response
|
||||
end)
|
||||
end
|
||||
|
||||
function Server:read(paths)
|
||||
local body = HttpService:JSONEncode(paths)
|
||||
|
||||
return self.http:post("/read", body)
|
||||
:andThen(function(response)
|
||||
response = response:json()
|
||||
|
||||
return response.items
|
||||
end)
|
||||
end
|
||||
|
||||
function Server:getChanges()
|
||||
local url = ("/changes/%f"):format(self.currentTime)
|
||||
|
||||
return self.http:get(url)
|
||||
:andThen(function(response)
|
||||
response = response:json()
|
||||
|
||||
self.currentTime = response.currentTime
|
||||
|
||||
return response.changes
|
||||
end)
|
||||
end
|
||||
|
||||
return Server
|
||||
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
|
||||
@@ -34,7 +34,13 @@ function Version.compare(a, b)
|
||||
end
|
||||
|
||||
function Version.display(version)
|
||||
return table.concat(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
|
||||
return Version
|
||||
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.
|
||||
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()
|
||||
@@ -1,2 +0,0 @@
|
||||
local TestEZ = require(game.ReplicatedStorage.TestEZ)
|
||||
TestEZ.TestBootstrap:run(game.ReplicatedStorage.Rojo.plugin)
|
||||
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!");
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
2
server/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
1051
server/Cargo.lock
generated
@@ -1,22 +1,45 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "0.4.2"
|
||||
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.1"
|
||||
rouille = "1.0"
|
||||
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"
|
||||
notify = "4.0.0"
|
||||
rand = "0.3"
|
||||
regex = "0.2"
|
||||
lazy_static = "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.
|
||||
@@ -1,30 +1,30 @@
|
||||
#[macro_use] extern crate serde_derive;
|
||||
#[macro_use] extern crate rouille;
|
||||
#[macro_use] extern crate clap;
|
||||
#[macro_use] extern crate lazy_static;
|
||||
extern crate notify;
|
||||
extern crate rand;
|
||||
extern crate serde;
|
||||
extern crate serde_json;
|
||||
extern crate regex;
|
||||
#[macro_use] extern crate log;
|
||||
|
||||
pub mod web;
|
||||
pub mod core;
|
||||
pub mod project;
|
||||
pub mod pathext;
|
||||
pub mod vfs;
|
||||
pub mod rbx;
|
||||
pub mod plugin;
|
||||
pub mod plugins;
|
||||
pub mod commands;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
env,
|
||||
process,
|
||||
};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
use clap::clap_app;
|
||||
|
||||
use pathext::canonicalish;
|
||||
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() {
|
||||
let matches = clap_app!(rojo =>
|
||||
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"))
|
||||
@@ -32,6 +32,7 @@ fn main() {
|
||||
(@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 =>
|
||||
@@ -40,58 +41,132 @@ fn main() {
|
||||
(@arg port: --port +takes_value "The port to listen on. Defaults to 8000.")
|
||||
)
|
||||
|
||||
(@subcommand pack =>
|
||||
(about: "Packs the project into a GUI installer bundle. NOT YET IMPLEMENTED!")
|
||||
(@arg PROJECT: "Path to the project to pack. Defaults to the current directory.")
|
||||
(@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.")
|
||||
)
|
||||
|
||||
(@arg verbose: --verbose "Enable extended logging.")
|
||||
).get_matches();
|
||||
(@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.")
|
||||
)
|
||||
);
|
||||
|
||||
let verbose = match matches.occurrences_of("verbose") {
|
||||
0 => false,
|
||||
_ => true,
|
||||
};
|
||||
// `get_matches` consumes self for some reason.
|
||||
let matches = app.clone().get_matches();
|
||||
|
||||
match matches.subcommand() {
|
||||
("init", sub_matches) => {
|
||||
let sub_matches = sub_matches.unwrap();
|
||||
let project_path = Path::new(sub_matches.value_of("PATH").unwrap_or("."));
|
||||
let full_path = canonicalish(project_path);
|
||||
("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");
|
||||
|
||||
commands::init(&full_path);
|
||||
let options = commands::InitOptions {
|
||||
fuzzy_project_path,
|
||||
kind,
|
||||
};
|
||||
|
||||
match commands::init(&options) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
process::exit(1);
|
||||
},
|
||||
}
|
||||
},
|
||||
("serve", sub_matches) => {
|
||||
let sub_matches = sub_matches.unwrap();
|
||||
|
||||
let project_path = match sub_matches.value_of("PROJECT") {
|
||||
Some(v) => canonicalish(PathBuf::from(v)),
|
||||
("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(source) => match source.parse::<u64>() {
|
||||
Ok(value) => Some(value),
|
||||
Err(_) => {
|
||||
eprintln!("Invalid port '{}'", source);
|
||||
process::exit(1);
|
||||
},
|
||||
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);
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
commands::serve(&project_path, verbose, port);
|
||||
},
|
||||
("pack", _) => {
|
||||
eprintln!("'rojo pack' is not yet implemented!");
|
||||
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);
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
eprintln!("Please specify a subcommand!");
|
||||
eprintln!("Try 'rojo help' for information.");
|
||||
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(())
|
||||
}
|
||||
@@ -1,16 +1,46 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use project::Project;
|
||||
use failure::Fail;
|
||||
|
||||
pub fn init(project_path: &PathBuf) {
|
||||
match Project::init(project_path) {
|
||||
Ok(_) => {
|
||||
println!("Created new empty project at {}", project_path.display());
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create new project.\n{}", e);
|
||||
process::exit(1);
|
||||
},
|
||||
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(())
|
||||
}
|
||||
@@ -1,5 +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::*;
|
||||
@@ -1,99 +1,54 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use rand;
|
||||
use failure::Fail;
|
||||
|
||||
use project::{Project, ProjectLoadError};
|
||||
use plugin::{PluginChain};
|
||||
use plugins::{DefaultPlugin, JsonModelPlugin, ScriptPlugin};
|
||||
use vfs::{VfsSession, VfsWatcher};
|
||||
use web;
|
||||
use crate::{
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
web::Server,
|
||||
session::Session,
|
||||
};
|
||||
|
||||
pub fn serve(project_path: &PathBuf, verbose: bool, port: Option<u64>) {
|
||||
let server_id = rand::random::<u64>();
|
||||
const DEFAULT_PORT: u16 = 34872;
|
||||
|
||||
if verbose {
|
||||
println!("Attempting to locate project at {}...", project_path.display());
|
||||
}
|
||||
|
||||
let project = match Project::load(project_path) {
|
||||
Ok(v) => {
|
||||
println!("Using project from {}", project_path.display());
|
||||
v
|
||||
},
|
||||
Err(err) => {
|
||||
match err {
|
||||
ProjectLoadError::InvalidJson(serde_err) => {
|
||||
eprintln!(
|
||||
"Found invalid JSON!\nProject in: {}\nError: {}",
|
||||
project_path.display(),
|
||||
serde_err,
|
||||
);
|
||||
|
||||
process::exit(1);
|
||||
},
|
||||
ProjectLoadError::FailedToOpen | ProjectLoadError::FailedToRead => {
|
||||
eprintln!("Found project file, but failed to read it!");
|
||||
eprintln!(
|
||||
"Check the permissions of the project file at\n{}",
|
||||
project_path.display(),
|
||||
);
|
||||
|
||||
process::exit(1);
|
||||
},
|
||||
_ => {
|
||||
// Any other error is fine; use the default project.
|
||||
println!("Found no project file, using default project...");
|
||||
Project::default()
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let web_config = web::WebConfig {
|
||||
verbose,
|
||||
port: port.unwrap_or(project.serve_port),
|
||||
server_id,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref PLUGIN_CHAIN: PluginChain = PluginChain::new(vec![
|
||||
Box::new(ScriptPlugin::new()),
|
||||
Box::new(JsonModelPlugin::new()),
|
||||
Box::new(DefaultPlugin::new()),
|
||||
]);
|
||||
}
|
||||
|
||||
let vfs = {
|
||||
let mut vfs = VfsSession::new(&PLUGIN_CHAIN);
|
||||
|
||||
for (name, project_partition) in &project.partitions {
|
||||
let path = {
|
||||
let given_path = Path::new(&project_partition.path);
|
||||
|
||||
if given_path.is_absolute() {
|
||||
given_path.to_path_buf()
|
||||
} else {
|
||||
project_path.join(given_path)
|
||||
}
|
||||
};
|
||||
|
||||
vfs.insert_partition(name, path);
|
||||
}
|
||||
|
||||
Arc::new(Mutex::new(vfs))
|
||||
};
|
||||
|
||||
println!("Server listening on port {}", web_config.port);
|
||||
|
||||
{
|
||||
let vfs = vfs.clone();
|
||||
thread::spawn(move || {
|
||||
VfsWatcher::new(vfs).start();
|
||||
});
|
||||
}
|
||||
|
||||
web::start(web_config, project.clone(), &PLUGIN_CHAIN, vfs.clone());
|
||||
#[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(())
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub type Route = Vec<String>;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
use std::env::current_dir;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Converts a path to a 'route', used as the paths in Rojo.
|
||||
pub fn path_to_route<A, B>(root: A, value: B) -> Option<Vec<String>>
|
||||
where
|
||||
A: AsRef<Path>,
|
||||
B: AsRef<Path>,
|
||||
{
|
||||
let root = root.as_ref();
|
||||
let value = value.as_ref();
|
||||
|
||||
let relative = match value.strip_prefix(root) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let result = relative
|
||||
.components()
|
||||
.map(|component| {
|
||||
component.as_os_str().to_string_lossy().into_owned()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_route() {
|
||||
fn t(root: &Path, value: &Path, result: Option<Vec<String>>) {
|
||||
assert_eq!(path_to_route(root, value), result);
|
||||
}
|
||||
|
||||
t(
|
||||
Path::new("/a/b/c"),
|
||||
Path::new("/a/b/c/d"),
|
||||
Some(vec!["d".to_string()]),
|
||||
);
|
||||
t(Path::new("/a/b"), Path::new("a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn test_path_to_route_windows() {
|
||||
fn t(root: &Path, value: &Path, result: Option<Vec<String>>) {
|
||||
assert_eq!(path_to_route(root, value), result);
|
||||
}
|
||||
|
||||
t(
|
||||
Path::new("C:\\foo"),
|
||||
Path::new("C:\\foo\\bar\\baz"),
|
||||
Some(vec!["bar".to_string(), "baz".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Turns the path into an absolute one, using the current working directory if
|
||||
/// necessary.
|
||||
pub fn canonicalish<T: AsRef<Path>>(value: T) -> PathBuf {
|
||||
let cwd = current_dir().unwrap();
|
||||
|
||||
absoluteify(&cwd, value)
|
||||
}
|
||||
|
||||
/// Converts the given path to be absolute if it isn't already using a given
|
||||
/// root.
|
||||
pub fn absoluteify<A, B>(root: A, value: B) -> PathBuf
|
||||
where
|
||||
A: AsRef<Path>,
|
||||
B: AsRef<Path>,
|
||||
{
|
||||
let root = root.as_ref();
|
||||
let value = value.as_ref();
|
||||
|
||||
if value.is_absolute() {
|
||||
PathBuf::from(value)
|
||||
} else {
|
||||
root.join(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses any `.` values along with any `..` values not at the start of the
|
||||
/// path.
|
||||
pub fn collapse<T: AsRef<Path>>(value: T) -> PathBuf {
|
||||
let value = value.as_ref();
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
for component in value.components() {
|
||||
match component {
|
||||
Component::ParentDir => match buffer.pop() {
|
||||
Some(_) => {},
|
||||
None => buffer.push(component.as_os_str()),
|
||||
},
|
||||
Component::CurDir => {},
|
||||
_ => {
|
||||
buffer.push(component.as_os_str());
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
buffer.iter().fold(PathBuf::new(), |mut acc, &x| {
|
||||
acc.push(x);
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collapse() {
|
||||
fn identity(buf: PathBuf) {
|
||||
assert_eq!(buf, collapse(&buf));
|
||||
}
|
||||
|
||||
identity(PathBuf::from("C:\\foo\\bar"));
|
||||
identity(PathBuf::from("/a/b/c"));
|
||||
identity(PathBuf::from("a/b"));
|
||||
|
||||
assert_eq!(collapse(PathBuf::from("a/b/..")), PathBuf::from("a"));
|
||||
assert_eq!(collapse(PathBuf::from("./a/b/c/..")), PathBuf::from("a/b"));
|
||||
assert_eq!(collapse(PathBuf::from("../a")), PathBuf::from("../a"));
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
use rbx::RbxInstance;
|
||||
use vfs::VfsItem;
|
||||
use core::Route;
|
||||
|
||||
pub enum TransformFileResult {
|
||||
Value(Option<RbxInstance>),
|
||||
Pass,
|
||||
|
||||
// TODO: Error case
|
||||
}
|
||||
|
||||
pub enum RbxChangeResult {
|
||||
Write(Option<VfsItem>),
|
||||
Pass,
|
||||
|
||||
// TODO: Error case
|
||||
}
|
||||
|
||||
pub enum FileChangeResult {
|
||||
MarkChanged(Option<Vec<Route>>),
|
||||
Pass,
|
||||
}
|
||||
|
||||
pub trait Plugin {
|
||||
/// Invoked when a file is read from the filesystem and needs to be turned
|
||||
/// into a Roblox instance.
|
||||
fn transform_file(&self, plugins: &PluginChain, vfs_item: &VfsItem) -> TransformFileResult;
|
||||
|
||||
/// Invoked when a Roblox Instance change is reported by the Roblox Studio
|
||||
/// plugin and needs to be turned into a file to save.
|
||||
fn handle_rbx_change(&self, route: &Route, rbx_item: &RbxInstance) -> RbxChangeResult;
|
||||
|
||||
/// Invoked when a file changes on the filesystem. The result defines what
|
||||
/// routes are marked as needing to be refreshed.
|
||||
fn handle_file_change(&self, route: &Route) -> FileChangeResult;
|
||||
}
|
||||
|
||||
/// A set of plugins that are composed in order.
|
||||
pub struct PluginChain {
|
||||
plugins: Vec<Box<Plugin + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl PluginChain {
|
||||
pub fn new(plugins: Vec<Box<Plugin + Send + Sync>>) -> PluginChain {
|
||||
PluginChain {
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transform_file(&self, vfs_item: &VfsItem) -> Option<RbxInstance> {
|
||||
for plugin in &self.plugins {
|
||||
match plugin.transform_file(self, vfs_item) {
|
||||
TransformFileResult::Value(rbx_item) => return rbx_item,
|
||||
TransformFileResult::Pass => {},
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn handle_rbx_change(&self, route: &Route, rbx_item: &RbxInstance) -> Option<VfsItem> {
|
||||
for plugin in &self.plugins {
|
||||
match plugin.handle_rbx_change(route, rbx_item) {
|
||||
RbxChangeResult::Write(vfs_item) => return vfs_item,
|
||||
RbxChangeResult::Pass => {},
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn handle_file_change(&self, route: &Route) -> Option<Vec<Route>> {
|
||||
for plugin in &self.plugins {
|
||||
match plugin.handle_file_change(route) {
|
||||
FileChangeResult::MarkChanged(changes) => return changes,
|
||||
FileChangeResult::Pass => {},
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use core::Route;
|
||||
use plugin::{Plugin, PluginChain, TransformFileResult, RbxChangeResult, FileChangeResult};
|
||||
use rbx::{RbxInstance, RbxValue};
|
||||
use vfs::VfsItem;
|
||||
|
||||
/// A plugin with simple transforms:
|
||||
/// * Directories become Folder instances
|
||||
/// * Files become StringValue objects with 'Value' as their contents
|
||||
pub struct DefaultPlugin;
|
||||
|
||||
impl DefaultPlugin {
|
||||
pub fn new() -> DefaultPlugin {
|
||||
DefaultPlugin
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for DefaultPlugin {
|
||||
fn transform_file(&self, plugins: &PluginChain, vfs_item: &VfsItem) -> TransformFileResult {
|
||||
match vfs_item {
|
||||
&VfsItem::File { ref contents, .. } => {
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
properties.insert("Value".to_string(), RbxValue::String {
|
||||
value: contents.clone(),
|
||||
});
|
||||
|
||||
TransformFileResult::Value(Some(RbxInstance {
|
||||
name: vfs_item.name().clone(),
|
||||
class_name: "StringValue".to_string(),
|
||||
children: Vec::new(),
|
||||
properties,
|
||||
route: Some(vfs_item.route().to_vec()),
|
||||
}))
|
||||
},
|
||||
&VfsItem::Dir { ref children, .. } => {
|
||||
let mut rbx_children = Vec::new();
|
||||
|
||||
for (_, child_item) in children {
|
||||
match plugins.transform_file(child_item) {
|
||||
Some(rbx_item) => {
|
||||
rbx_children.push(rbx_item);
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
TransformFileResult::Value(Some(RbxInstance {
|
||||
name: vfs_item.name().clone(),
|
||||
class_name: "*".to_string(),
|
||||
children: rbx_children,
|
||||
properties: HashMap::new(),
|
||||
route: Some(vfs_item.route().to_vec()),
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_file_change(&self, route: &Route) -> FileChangeResult {
|
||||
FileChangeResult::MarkChanged(Some(vec![route.clone()]))
|
||||
}
|
||||
|
||||
fn handle_rbx_change(&self, _route: &Route, _rbx_item: &RbxInstance) -> RbxChangeResult {
|
||||
RbxChangeResult::Pass
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use regex::Regex;
|
||||
use serde_json;
|
||||
|
||||
use core::Route;
|
||||
use plugin::{Plugin, PluginChain, TransformFileResult, RbxChangeResult, FileChangeResult};
|
||||
use rbx::RbxInstance;
|
||||
use vfs::VfsItem;
|
||||
|
||||
lazy_static! {
|
||||
static ref JSON_MODEL_PATTERN: Regex = Regex::new(r"^(.*?)\.model\.json$").unwrap();
|
||||
}
|
||||
|
||||
pub struct JsonModelPlugin;
|
||||
|
||||
impl JsonModelPlugin {
|
||||
pub fn new() -> JsonModelPlugin {
|
||||
JsonModelPlugin
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for JsonModelPlugin {
|
||||
fn transform_file(&self, _plugins: &PluginChain, vfs_item: &VfsItem) -> TransformFileResult {
|
||||
match vfs_item {
|
||||
&VfsItem::File { ref contents, .. } => {
|
||||
let rbx_name = match JSON_MODEL_PATTERN.captures(vfs_item.name()) {
|
||||
Some(captures) => captures.get(1).unwrap().as_str().to_string(),
|
||||
None => return TransformFileResult::Pass,
|
||||
};
|
||||
|
||||
let mut rbx_item: RbxInstance = match serde_json::from_str(contents) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Unable to parse JSON Model File named {}: {}", vfs_item.name(), e);
|
||||
|
||||
return TransformFileResult::Pass; // This should be an error in the future!
|
||||
},
|
||||
};
|
||||
|
||||
rbx_item.route = Some(vfs_item.route().to_vec());
|
||||
rbx_item.name = rbx_name;
|
||||
|
||||
TransformFileResult::Value(Some(rbx_item))
|
||||
},
|
||||
&VfsItem::Dir { .. } => TransformFileResult::Pass,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_file_change(&self, _route: &Route) -> FileChangeResult {
|
||||
FileChangeResult::Pass
|
||||
}
|
||||
|
||||
fn handle_rbx_change(&self, _route: &Route, _rbx_item: &RbxInstance) -> RbxChangeResult {
|
||||
RbxChangeResult::Pass
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mod default_plugin;
|
||||
mod script_plugin;
|
||||
mod json_model_plugin;
|
||||
|
||||
pub use self::default_plugin::*;
|
||||
pub use self::script_plugin::*;
|
||||
pub use self::json_model_plugin::*;
|
||||
@@ -1,124 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use core::Route;
|
||||
use plugin::{Plugin, PluginChain, TransformFileResult, RbxChangeResult, FileChangeResult};
|
||||
use rbx::{RbxInstance, RbxValue};
|
||||
use vfs::VfsItem;
|
||||
|
||||
lazy_static! {
|
||||
static ref SERVER_PATTERN: Regex = Regex::new(r"^(.*?)\.server\.lua$").unwrap();
|
||||
static ref CLIENT_PATTERN: Regex = Regex::new(r"^(.*?)\.client\.lua$").unwrap();
|
||||
static ref MODULE_PATTERN: Regex = Regex::new(r"^(.*?)\.lua$").unwrap();
|
||||
}
|
||||
|
||||
static SERVER_INIT: &'static str = "init.server.lua";
|
||||
static CLIENT_INIT: &'static str = "init.client.lua";
|
||||
static MODULE_INIT: &'static str = "init.lua";
|
||||
|
||||
pub struct ScriptPlugin;
|
||||
|
||||
impl ScriptPlugin {
|
||||
pub fn new() -> ScriptPlugin {
|
||||
ScriptPlugin
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ScriptPlugin {
|
||||
fn transform_file(&self, plugins: &PluginChain, vfs_item: &VfsItem) -> TransformFileResult {
|
||||
match vfs_item {
|
||||
&VfsItem::File { ref contents, .. } => {
|
||||
let name = vfs_item.name();
|
||||
|
||||
let (class_name, rbx_name) = {
|
||||
if let Some(captures) = SERVER_PATTERN.captures(name) {
|
||||
("Script".to_string(), captures.get(1).unwrap().as_str().to_string())
|
||||
} else if let Some(captures) = CLIENT_PATTERN.captures(name) {
|
||||
("LocalScript".to_string(), captures.get(1).unwrap().as_str().to_string())
|
||||
} else if let Some(captures) = MODULE_PATTERN.captures(name) {
|
||||
("ModuleScript".to_string(), captures.get(1).unwrap().as_str().to_string())
|
||||
} else {
|
||||
return TransformFileResult::Pass;
|
||||
}
|
||||
};
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
properties.insert("Source".to_string(), RbxValue::String {
|
||||
value: contents.clone(),
|
||||
});
|
||||
|
||||
TransformFileResult::Value(Some(RbxInstance {
|
||||
name: rbx_name,
|
||||
class_name: class_name,
|
||||
children: Vec::new(),
|
||||
properties,
|
||||
route: Some(vfs_item.route().to_vec()),
|
||||
}))
|
||||
},
|
||||
&VfsItem::Dir { ref children, .. } => {
|
||||
let init_item = {
|
||||
let maybe_item = children.get(SERVER_INIT)
|
||||
.or(children.get(CLIENT_INIT))
|
||||
.or(children.get(MODULE_INIT));
|
||||
|
||||
match maybe_item {
|
||||
Some(v) => v,
|
||||
None => return TransformFileResult::Pass,
|
||||
}
|
||||
};
|
||||
|
||||
let mut rbx_item = match self.transform_file(plugins, init_item) {
|
||||
TransformFileResult::Value(Some(item)) => item,
|
||||
_ => {
|
||||
eprintln!("Inconsistency detected in ScriptPlugin!");
|
||||
return TransformFileResult::Pass;
|
||||
},
|
||||
};
|
||||
|
||||
rbx_item.name.clear();
|
||||
rbx_item.name.push_str(vfs_item.name());
|
||||
|
||||
for (child_name, child_item) in children {
|
||||
if child_name == init_item.name() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match plugins.transform_file(child_item) {
|
||||
Some(child_rbx_item) => {
|
||||
rbx_item.children.push(child_rbx_item);
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
TransformFileResult::Value(Some(rbx_item))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_file_change(&self, route: &Route) -> FileChangeResult {
|
||||
let leaf = match route.last() {
|
||||
Some(v) => v,
|
||||
None => return FileChangeResult::Pass,
|
||||
};
|
||||
|
||||
let is_init = leaf == SERVER_INIT
|
||||
|| leaf == CLIENT_INIT
|
||||
|| leaf == MODULE_INIT;
|
||||
|
||||
if is_init {
|
||||
let mut changed = route.clone();
|
||||
changed.pop();
|
||||
|
||||
FileChangeResult::MarkChanged(Some(vec![changed]))
|
||||
} else {
|
||||
FileChangeResult::Pass
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_rbx_change(&self, _route: &Route, _rbx_item: &RbxInstance) -> RbxChangeResult {
|
||||
RbxChangeResult::Pass
|
||||
}
|
||||
}
|
||||
@@ -1,165 +1,400 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde_json;
|
||||
use maplit::hashmap;
|
||||
use failure::Fail;
|
||||
use rbx_tree::RbxValue;
|
||||
|
||||
pub static PROJECT_FILENAME: &'static str = "rojo.json";
|
||||
pub static PROJECT_FILENAME: &'static str = "roblox-project.json";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProjectLoadError {
|
||||
DidNotExist,
|
||||
FailedToOpen,
|
||||
FailedToRead,
|
||||
InvalidJson(serde_json::Error),
|
||||
// Serde is silly.
|
||||
const fn yeah() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProjectSaveError {
|
||||
FailedToCreate,
|
||||
const fn is_true(value: &bool) -> bool {
|
||||
*value
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[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,
|
||||
FailedToCreate,
|
||||
FailedToWrite,
|
||||
AlreadyExists(PathBuf),
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
SaveError(#[fail(cause)] ProjectSaveError),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectInitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
&ProjectInitError::AlreadyExists => {
|
||||
write!(f, "A project already exists at that location.")
|
||||
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,
|
||||
}
|
||||
},
|
||||
&ProjectInitError::FailedToCreate | &ProjectInitError::FailedToWrite => {
|
||||
write!(f, "Failed to write to the given location.")
|
||||
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(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectPartition {
|
||||
/// A slash-separated path to a file or folder, relative to the project's
|
||||
/// directory.
|
||||
pub path: String,
|
||||
|
||||
/// A dot-separated route to a Roblox instance, relative to game.
|
||||
pub target: String,
|
||||
pub struct InstanceProjectNode {
|
||||
pub class_name: String,
|
||||
pub children: HashMap<String, ProjectNode>,
|
||||
pub properties: HashMap<String, RbxValue>,
|
||||
pub metadata: InstanceProjectNodeMetadata,
|
||||
}
|
||||
|
||||
/// Represents a project configured by a user for use with Rojo. Holds anything
|
||||
/// that can be configured with `rojo.json`.
|
||||
///
|
||||
/// In the future, this object will hold dependency information and other handy
|
||||
/// configurables
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[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 serve_port: u64,
|
||||
pub partitions: HashMap<String, ProjectPartition>,
|
||||
pub tree: ProjectNode,
|
||||
pub serve_port: Option<u16>,
|
||||
pub serve_place_ids: Option<HashSet<u64>>,
|
||||
pub file_location: PathBuf,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Creates a new empty Project object with the given name.
|
||||
pub fn new<T: Into<String>>(name: T) -> Project {
|
||||
Project {
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes a new project inside the given folder path.
|
||||
pub fn init<T: AsRef<Path>>(location: T) -> Result<Project, ProjectInitError> {
|
||||
let location = location.as_ref();
|
||||
let package_path = location.join(PROJECT_FILENAME);
|
||||
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadFuzzyError> {
|
||||
let project_path = Self::locate(fuzzy_project_location)
|
||||
.ok_or(ProjectLoadFuzzyError::NotFound)?;
|
||||
|
||||
// We abort if the project file already exists.
|
||||
match fs::metadata(&package_path) {
|
||||
Ok(_) => return Err(ProjectInitError::AlreadyExists),
|
||||
Err(_) => {},
|
||||
}
|
||||
|
||||
let mut file = match File::create(&package_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Err(ProjectInitError::FailedToCreate),
|
||||
};
|
||||
|
||||
// Try to give the project a meaningful name.
|
||||
// If we can't, we'll just fall back to a default.
|
||||
let name = match location.file_name() {
|
||||
Some(v) => v.to_string_lossy().into_owned(),
|
||||
None => "new-project".to_string(),
|
||||
};
|
||||
|
||||
// Configure the project with all of the values we know so far.
|
||||
let project = Project::new(name);
|
||||
let serialized = serde_json::to_string_pretty(&project).unwrap();
|
||||
|
||||
match file.write(serialized.as_bytes()) {
|
||||
Ok(_) => {},
|
||||
Err(_) => return Err(ProjectInitError::FailedToWrite),
|
||||
}
|
||||
|
||||
Ok(project)
|
||||
Self::load_exact(&project_path).map_err(From::from)
|
||||
}
|
||||
|
||||
/// Attempts to load a project from the file named PROJECT_FILENAME from the
|
||||
/// given folder.
|
||||
pub fn load<T: AsRef<Path>>(location: T) -> Result<Project, ProjectLoadError> {
|
||||
let package_path = location.as_ref().join(Path::new(PROJECT_FILENAME));
|
||||
pub fn load_exact(project_file_location: &Path) -> Result<Project, ProjectLoadExactError> {
|
||||
let contents = fs::read_to_string(project_file_location)
|
||||
.map_err(ProjectLoadExactError::IoError)?;
|
||||
|
||||
match fs::metadata(&package_path) {
|
||||
Ok(_) => {},
|
||||
Err(_) => return Err(ProjectLoadError::DidNotExist),
|
||||
}
|
||||
let parsed: SourceProject = serde_json::from_str(&contents)
|
||||
.map_err(ProjectLoadExactError::JsonError)?;
|
||||
|
||||
let mut file = match File::open(&package_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Err(ProjectLoadError::FailedToOpen),
|
||||
};
|
||||
|
||||
let mut contents = String::new();
|
||||
|
||||
match file.read_to_string(&mut contents) {
|
||||
Ok(_) => {},
|
||||
Err(_) => return Err(ProjectLoadError::FailedToRead),
|
||||
}
|
||||
|
||||
match serde_json::from_str(&contents) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => return Err(ProjectLoadError::InvalidJson(e)),
|
||||
}
|
||||
Ok(parsed.into_project(project_file_location))
|
||||
}
|
||||
|
||||
/// Saves the given project file to the given folder with the appropriate name.
|
||||
pub fn save<T: AsRef<Path>>(&self, location: T) -> Result<(), ProjectSaveError> {
|
||||
let package_path = location.as_ref().join(Path::new(PROJECT_FILENAME));
|
||||
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)?;
|
||||
|
||||
let mut file = match File::create(&package_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Err(ProjectSaveError::FailedToCreate),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string_pretty(self).unwrap();
|
||||
|
||||
file.write(serialized.as_bytes()).unwrap();
|
||||
serde_json::to_writer_pretty(&mut file, &source_project)
|
||||
.map_err(ProjectSaveError::JsonError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Project {
|
||||
fn default() -> Project {
|
||||
Project {
|
||||
name: "new-project".to_string(),
|
||||
serve_port: 8000,
|
||||
partitions: HashMap::new(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Represents data about a Roblox instance
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct RbxInstance {
|
||||
pub name: String,
|
||||
pub class_name: String,
|
||||
pub children: Vec<RbxInstance>,
|
||||
pub properties: HashMap<String, RbxValue>,
|
||||
|
||||
/// The route that this instance was generated from, if there was one.
|
||||
pub route: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Any kind value that can be used by Roblox
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase", tag = "Type")]
|
||||
pub enum RbxValue {
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
String {
|
||||
value: String,
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
Bool {
|
||||
value: bool,
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
Number {
|
||||
value: f64,
|
||||
},
|
||||
|
||||
// TODO: Compound types like Vector3
|
||||
}
|
||||