Compare commits

...

24 Commits

Author SHA1 Message Date
Lucien Greathouse
69d1accf3f 0.5.0-alpha.3 2019-02-01 17:19:00 -08:00
Lucien Greathouse
785bdb8ecb Implement new project file name, default.project.json (#120)
* Implement new project file name, default.project.json

* Rename all test projects to default.project.json

* Update CHANGELOG

* Fix warning message typo
2019-02-01 17:06:03 -08:00
Lucien Greathouse
78a1947cec Update CHANGELOG 2019-02-01 13:07:15 -08:00
Paul Doyle
0ff59ecb4e Fix issue w/ existing files not being updated in imfs (#119)
* Fix issue w/ existing files not being updated in imfs

* Add a test for updating files
2019-01-31 20:24:42 -08:00
Lucien Greathouse
b58fed16b4 Fix uses using failure::Error 2019-01-30 10:29:38 -08:00
Lucien Greathouse
6719be02c3 Fall back to showing GraphViz source when GraphViz is not installed 2019-01-29 18:10:14 -08:00
Lucien Greathouse
8757834e07 Improve error reporting for IO issues 2019-01-29 17:29:47 -08:00
Lucien Greathouse
aa243d1b8a Add sweet new live sync homepage 2019-01-28 18:30:42 -08:00
Lucien Greathouse
aeb18eb124 Refactor web code to make routing more clear 2019-01-28 18:23:57 -08:00
Lucien Greathouse
6c3e118ee3 Sort inputs in LiveSession 2019-01-28 17:50:47 -08:00
Lucien Greathouse
3c0fe4d684 Reduce number of threads needed for FsWatcher 2019-01-28 17:11:01 -08:00
Lucien Greathouse
12fd9aa1ef Tack on Cargo.lock, missing from previous commit 2019-01-28 16:03:12 -08:00
Lucien Greathouse
821122a33d 0.5.0-alpha.2 2019-01-28 15:45:52 -08:00
Lucien Greathouse
0d9406d991 Update docs links in README 2019-01-28 15:40:54 -08:00
Lucien Greathouse
350eec3bc7 Update docs and generator to be even smarter 2019-01-28 15:39:11 -08:00
Lucien Greathouse
e700b3105a New, less-kludgy doc generator 2019-01-28 15:31:20 -08:00
Lucien Greathouse
dd2a730b4a Update documentation 2019-01-28 15:16:42 -08:00
Lucien Greathouse
c6766bbe77 Fix timeout issue for real this time 2019-01-28 14:55:56 -08:00
Lucien Greathouse
e5d3204b6c Implement .model.json files
Closes #97.
2019-01-28 14:37:35 -08:00
Lucien Greathouse
4767cbd12b Fix composing-models XML to only contain stuff implemented so far 2019-01-28 14:36:17 -08:00
Lucien Greathouse
deb4118c5d Fix long-polling mixup
Fixes #110.
2019-01-28 14:00:22 -08:00
Lucien Greathouse
4516df5aac Fix message in rbx_session 2019-01-28 13:58:24 -08:00
Lucien Greathouse
663df7bdc2 Remove redundant debug assertions in imfs 2019-01-28 11:23:19 -08:00
Lucien Greathouse
e81f0a4a95 Improve IMFS robustness with out-of-order events
Fixes #111.
2019-01-28 11:03:52 -08:00
48 changed files with 921 additions and 287 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
/site
/target
/server/scratch
/scratch-project
**/*.rs.bk
/generate-docs.run

View File

@@ -2,7 +2,22 @@
## [Unreleased]
## [0.5.0 Alpha 1](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.1) (January 14, 2019)
## [0.5.0 Alpha 3](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.3) (February 1, 2019)
* Changed default project file name from `roblox-project.json` to `default.project.json` ([#120](https://github.com/LPGhatguy/rojo/pull/120))
* The old file name will still be supported until 0.5.0 is fully released.
* Added warning when loading project files that don't end in `.project.json`
* This new extension enables Rojo to distinguish project files from random JSON files, which is necessary to support nested projects.
* Added new (empty) diagnostic page served from the server
* Added better error messages for when a file is missing that's referenced by a Rojo project
* Added support for visualization endpoints returning GraphViz source when Dot is not available
* Fixed an in-memory filesystem regression introduced recently ([#119](https://github.com/LPGhatguy/rojo/pull/119))
## [0.5.0 Alpha 2](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.2) (January 28, 2019)
* Added support for `.model.json` files, compatible with 0.4.x
* Fixed in-memory filesystem not handling out-of-order filesystem change events
* Fixed long-polling error caused by a promise mixup ([#110](https://github.com/LPGhatguy/rojo/issues/110))
## [0.5.0 Alpha 1](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.1) (January 25, 2019)
* Changed plugin UI to be way prettier
* Thanks to [Reselim](https://github.com/Reselim) for the design!
* Changed plugin error messages to be a little more useful

8
Cargo.lock generated
View File

@@ -345,7 +345,7 @@ dependencies = [
[[package]]
name = "env_logger"
version = "0.5.13"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"atty 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1248,11 +1248,11 @@ dependencies = [
[[package]]
name = "rojo"
version = "0.5.0-alpha.1"
version = "0.5.0-alpha.3"
dependencies = [
"clap 2.32.0 (registry+https://github.com/rust-lang/crates.io-index)",
"csv 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger 0.5.13 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
"failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1929,7 +1929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
"checksum deflate 0.7.19 (registry+https://github.com/rust-lang/crates.io-index)" = "8a6abb26e16e8d419b5c78662aa9f82857c2386a073da266840e474d5055ec86"
"checksum dtoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6d301140eb411af13d3115f9a562c85cc6b541ade9dfa314132244aaee7489dd"
"checksum encoding_rs 0.8.14 (registry+https://github.com/rust-lang/crates.io-index)" = "a69d152eaa438a291636c1971b0a370212165ca8a75759eb66818c5ce9b538f7"
"checksum env_logger 0.5.13 (registry+https://github.com/rust-lang/crates.io-index)" = "15b0a4d2e39f8420210be8b27eeda28029729e2fd4291019455016c348240c38"
"checksum env_logger 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "afb070faf94c85d17d50ca44f6ad076bce18ae92f0037d350947240a36e9d42e"
"checksum error-chain 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)" = "07e791d3be96241c77c43846b665ef1384606da2cd2a48730abe606a12906e02"
"checksum failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "795bd83d3abeb9220f257e597aa0080a508b27533824adf336529648f6abf7e2"
"checksum failure_derive 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "ea1063915fd7ef4309e222a5a07cf9c319fb9c7836b1f89b85458672dbb127e1"

View File

@@ -40,7 +40,7 @@ Soon, Rojo will be able to:
* Sync instances from Roblox Studio to the filesystem
* Compile MoonScript and other custom things for your project
## [Documentation](https://lpghatguy.github.io/rojo/0.4.x)
## [Documentation](https://lpghatguy.github.io/rojo)
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.
## Inspiration and Alternatives

3
docs/extra.css Normal file
View File

@@ -0,0 +1,3 @@
.md-typeset__table {
width: 100%;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,17 @@
digraph "Sync Files" {
graph [
ranksep = "0.7",
nodesep = "0.5",
];
node [
fontname = "monospace",
shape = "record",
];
my_model [label = "MyModel"]
init_server [label = "init.server.lua"]
foo [label = "foo.lua"]
my_model -> init_server
my_model -> foo
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
-->
<!-- Title: Sync Files Pages: 1 -->
<svg width="258pt" height="132pt"
viewBox="0.00 0.00 258.00 132.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 128)">
<title>Sync Files</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-128 254,-128 254,4 -4,4"/>
<!-- my_model -->
<g id="node1" class="node"><title>my_model</title>
<polygon fill="none" stroke="black" points="104,-87.5 104,-123.5 178,-123.5 178,-87.5 104,-87.5"/>
<text text-anchor="middle" x="141" y="-101.8" font-family="monospace" font-size="14.00">MyModel</text>
</g>
<!-- init_server -->
<g id="node2" class="node"><title>init_server</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 140,-36.5 140,-0.5 0,-0.5"/>
<text text-anchor="middle" x="70" y="-14.8" font-family="monospace" font-size="14.00">init.server.lua</text>
</g>
<!-- my_model&#45;&gt;init_server -->
<g id="edge1" class="edge"><title>my_model&#45;&gt;init_server</title>
<path fill="none" stroke="black" d="M126.632,-87.299C116.335,-74.9713 102.308,-58.1787 90.7907,-44.3902"/>
<polygon fill="black" stroke="black" points="93.4435,-42.1065 84.3465,-36.6754 88.0711,-46.594 93.4435,-42.1065"/>
</g>
<!-- foo -->
<g id="node3" class="node"><title>foo</title>
<polygon fill="none" stroke="black" points="176,-0.5 176,-36.5 250,-36.5 250,-0.5 176,-0.5"/>
<text text-anchor="middle" x="213" y="-14.8" font-family="monospace" font-size="14.00">foo.lua</text>
</g>
<!-- my_model&#45;&gt;foo -->
<g id="edge2" class="edge"><title>my_model&#45;&gt;foo</title>
<path fill="none" stroke="black" d="M155.57,-87.299C166.013,-74.9713 180.237,-58.1787 191.917,-44.3902"/>
<polygon fill="black" stroke="black" points="194.659,-46.5681 198.451,-36.6754 189.317,-42.0437 194.659,-46.5681"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,15 @@
digraph "Sync Files" {
graph [
ranksep = "0.7",
nodesep = "0.5",
];
node [
fontname = "monospace",
shape = "record",
];
my_model [label = "MyModel (Script)"]
foo [label = "foo (ModuleScript)"]
my_model -> foo
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
-->
<!-- Title: Sync Files Pages: 1 -->
<svg width="173pt" height="132pt"
viewBox="0.00 0.00 173.00 132.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 128)">
<title>Sync Files</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-128 169,-128 169,4 -4,4"/>
<!-- my_model -->
<g id="node1" class="node"><title>my_model</title>
<polygon fill="none" stroke="black" points="8,-87.5 8,-123.5 157,-123.5 157,-87.5 8,-87.5"/>
<text text-anchor="middle" x="82.5" y="-101.8" font-family="monospace" font-size="14.00">MyModel (Script)</text>
</g>
<!-- foo -->
<g id="node2" class="node"><title>foo</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 165,-36.5 165,-0.5 0,-0.5"/>
<text text-anchor="middle" x="82.5" y="-14.8" font-family="monospace" font-size="14.00">foo (ModuleScript)</text>
</g>
<!-- my_model&#45;&gt;foo -->
<g id="edge1" class="edge"><title>my_model&#45;&gt;foo</title>
<path fill="none" stroke="black" d="M82.5,-87.299C82.5,-75.6626 82.5,-60.0479 82.5,-46.7368"/>
<polygon fill="black" stroke="black" points="86.0001,-46.6754 82.5,-36.6754 79.0001,-46.6755 86.0001,-46.6754"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,17 @@
digraph "Sync Files" {
graph [
ranksep = "0.7",
nodesep = "0.5",
];
node [
fontname = "monospace",
shape = "record",
];
model [label = "My Cool Model (Folder)"]
root_part [label = "RootPart (Part)"]
send_money [label = "SendMoney (RemoteEvent)"]
model -> root_part
model -> send_money
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
-->
<!-- Title: Sync Files Pages: 1 -->
<svg width="390pt" height="132pt"
viewBox="0.00 0.00 390.00 132.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 128)">
<title>Sync Files</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-128 386,-128 386,4 -4,4"/>
<!-- model -->
<g id="node1" class="node"><title>model</title>
<polygon fill="none" stroke="black" points="75,-87.5 75,-123.5 273,-123.5 273,-87.5 75,-87.5"/>
<text text-anchor="middle" x="174" y="-101.8" font-family="monospace" font-size="14.00">My Cool Model (Folder)</text>
</g>
<!-- root_part -->
<g id="node2" class="node"><title>root_part</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 140,-36.5 140,-0.5 0,-0.5"/>
<text text-anchor="middle" x="70" y="-14.8" font-family="monospace" font-size="14.00">RootPart (Part)</text>
</g>
<!-- model&#45;&gt;root_part -->
<g id="edge1" class="edge"><title>model&#45;&gt;root_part</title>
<path fill="none" stroke="black" d="M152.954,-87.299C137.448,-74.6257 116.168,-57.2335 99.0438,-43.2377"/>
<polygon fill="black" stroke="black" points="100.972,-40.2938 91.0147,-36.6754 96.5426,-45.7138 100.972,-40.2938"/>
</g>
<!-- send_money -->
<g id="node3" class="node"><title>send_money</title>
<polygon fill="none" stroke="black" points="176,-0.5 176,-36.5 382,-36.5 382,-0.5 176,-0.5"/>
<text text-anchor="middle" x="279" y="-14.8" font-family="monospace" font-size="14.00">SendMoney (RemoteEvent)</text>
</g>
<!-- model&#45;&gt;send_money -->
<g id="edge2" class="edge"><title>model&#45;&gt;send_money</title>
<path fill="none" stroke="black" d="M195.248,-87.299C210.904,-74.6257 232.388,-57.2335 249.677,-43.2377"/>
<polygon fill="black" stroke="black" points="252.213,-45.6878 257.783,-36.6754 247.809,-40.2471 252.213,-45.6878"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -2,9 +2,9 @@ This is the documentation home for Rojo.
Available versions of these docs:
* [Latest version (currently 0.5.x)](https://lpghatguy.github.io/rojo)
* [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.

View File

@@ -50,9 +50,6 @@ Metadata begins with a dollar sign (`$`), like `$className`. This is so that chi
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.

View File

@@ -9,6 +9,20 @@ This page aims to describe how Rojo turns files on the filesystem into Roblox ob
| `*.lua` | `ModuleScript` |
| `*.csv` | `LocalizationTable` |
| `*.txt` | `StringValue` |
| `*.model.json` | Any |
| `*.rbxm` | Any |
| `*.rbxmx` | Any |
## Limitations
Not all property types can be synced by Rojo in real-time due to limitations of the Roblox Studio plugin API. In these cases, you can usually generate a place file and open it when you start working on a project.
Some common cases you might hit are:
* Binary data (Terrain, CSG, CollectionService tags)
* `MeshPart.MeshId`
* `HttpService.HttpEnabled`
For a list of all property types that Rojo can reason about, both when live-syncing and when building place files, look at [rbx_tree's type coverage documentation](https://github.com/LPGhatguy/rbx-tree/tree/master/rbx_tree#coverage).
## Folders
Any directory on the filesystem will turn into a `Folder` instance unless it contains an 'init' script, described below.
@@ -20,16 +34,68 @@ If a directory contains a file named `init.server.lua`, `init.client.lua`, or `i
For example, these files:
* my-game
* init.client.lua
* foo.lua
<div align="center">
<a href="../images/sync-example-files.svg">
<img src="../images/sync-example-files.svg" alt="Tree of files on disk" />
</a>
</div>
Will turn into these instances in Roblox:
![Example of Roblox instances](/images/sync-example.png)
<div align="center">
<a href="../images/sync-example-instances.svg">
<img src="../images/sync-example-instances.svg" alt="Tree of instances in Roblox" />
</a>
</div>
## 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.
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.
## JSON Models
Files ending in `.model.json` can be used to describe simple models. They're designed to be hand-written and are useful for instances like `RemoteEvent`.
A JSON model describing a folder containing a `Part` and a `RemoteEvent` could be described as:
```json
{
"Name": "My Cool Model",
"ClassName": "Folder",
"Children": [
{
"Name": "RootPart",
"ClassName": "Part",
"Properties": {
"Size": {
"Type": "Vector3",
"Value": [4, 4, 4]
}
}
},
{
"Name": "SendMoney",
"ClassName": "RemoteEvent"
}
]
}
```
It would turn into instances in this shape:
<div align="center">
<a href="../images/sync-example-json-model.svg">
<img src="../images/sync-example-json-model.svg" alt="Tree of instances in Roblox" />
</a>
</div>
## Binary and XML Models
Rojo supports both binary (`.rbxm`) and XML (`.rbxmx`) models generated by Roblox Studio or another tool.
Not all property types are supported!
For a rundown of supported types, see:
* [rbxm Type Coverage](https://github.com/LPGhatguy/rbx-tree/tree/master/rbx_binary#coverage)
* [rbxmx Type Coverage](https://github.com/LPGhatguy/rbx-tree/tree/master/rbx_xml#coverage)

View File

@@ -3,23 +3,33 @@
# 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)
REMOTE=$(git remote get-url origin)
CHECKOUT="$(mktemp -d)"
OUTPUT="$(pwd)/site"
echo "Building 0.4.x"
git checkout v0.4.x
git pull
mkdocs build --site-dir site/0.4.x
if [ -d site ]
then
cd site
git pull
else
git clone "$REMOTE" site
cd site
git checkout gh-pages
fi
git clone "$REMOTE" "$CHECKOUT"
cd "$CHECKOUT"
echo "Building master"
git checkout master
mkdocs build --site-dir site/master
mkdocs build --site-dir "$OUTPUT"
echo "Building 0.5.x"
mkdocs build --site-dir site/0.5.x
mkdocs build --site-dir "$OUTPUT/0.5.x"
git checkout "$CURRENT_BRANCH"
echo "Building 0.4.x"
git checkout v0.4.x
mkdocs build --site-dir "$OUTPUT/0.4.x"

View File

@@ -17,6 +17,9 @@ nav:
- Sync Details: sync-details.md
- Migrating from 0.4.x to 0.5.x: migrating-to-epiphany.md
extra_css:
- extra.css
markdown_extensions:
- attr_list
- admonition

View File

@@ -140,14 +140,18 @@ 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
local function sendRequest()
return Http.get(url)
:catch(function(err)
if err.type == HttpError.Error.Timeout then
return sendRequest()
end
return Promise.reject(err)
end)
return Promise.reject(err)
end)
end
return sendRequest()
:andThen(rejectFailedRequests)
:andThen(function(response)
local body = response:json()

View File

@@ -1,6 +1,6 @@
return {
codename = "Epiphany",
version = {0, 5, 0, "-alpha.1"},
version = {0, 5, 0, "-alpha.3"},
expectedServerVersionString = "0.5.0 or newer",
protocolVersion = 2,
defaultHost = "localhost",

View File

@@ -1,6 +1,6 @@
[package]
name = "rojo"
version = "0.5.0-alpha.1"
version = "0.5.0-alpha.3"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "A tool to create robust Roblox projects"
license = "MIT"
@@ -22,7 +22,7 @@ bundle-plugin = []
[dependencies]
clap = "2.27"
csv = "1.0"
env_logger = "0.5"
env_logger = "0.6"
failure = "0.1.3"
log = "0.4"
maplit = "1.0.1"

54
server/assets/index.html Normal file
View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<title>Rojo</title>
<style>
* {
margin: 0;
padding: 0;
font: inherit;
}
html {
font-family: sans-serif;
height: 100%;
}
body {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.main {
padding: 1rem;
text-align: center;
margin: 0 auto;
width: 100%;
max-width: 60rem;
background-color: #efefef;
border: 1px solid #666;
border-radius: 4px;
}
.title {
font-size: 2rem;
font-weight: bold;
}
.docs {
font-size: 1.5rem;
font-weight: bold;
}
</style>
</head>
<body>
<div class="main">
<h1 class="title">Rojo Live Sync is up and running!</h1>
<a class="docs" href="https://lpghatguy.github.io/rojo">Rojo Documentation</a>
</div>
</body>
</html>

View File

@@ -20,9 +20,14 @@ fn make_path_absolute(value: &Path) -> PathBuf {
}
fn main() {
env_logger::Builder::from_default_env()
.default_format_timestamp(false)
.init();
{
let log_env = env_logger::Env::default()
.default_filter_or("warn");
env_logger::Builder::from_env(log_env)
.default_format_timestamp(false)
.init();
}
let app = clap_app!(Rojo =>
(version: env!("CARGO_PKG_VERSION"))

View File

@@ -10,7 +10,7 @@ use failure::Fail;
use crate::{
rbx_session::construct_oneoff_tree,
project::{Project, ProjectLoadFuzzyError},
imfs::Imfs,
imfs::{Imfs, FsError},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -55,14 +55,18 @@ pub enum BuildError {
XmlModelEncodeError(rbx_xml::EncodeError),
#[fail(display = "Binary model file error")]
BinaryModelEncodeError(rbx_binary::EncodeError)
BinaryModelEncodeError(rbx_binary::EncodeError),
#[fail(display = "{}", _0)]
FsError(#[fail(cause)] FsError),
}
impl_from!(BuildError {
ProjectLoadFuzzyError => ProjectLoadError,
io::Error => IoError,
rbx_xml::EncodeError => XmlModelEncodeError,
rbx_binary::EncodeError => BinaryModelEncodeError
rbx_binary::EncodeError => BinaryModelEncodeError,
FsError => FsError,
});
pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
@@ -75,6 +79,7 @@ pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
info!("Looking for project at {}", options.fuzzy_project_path.display());
let project = Project::load_fuzzy(&options.fuzzy_project_path)?;
project.check_compatibility();
info!("Found project at {}", project.file_location.display());
info!("Using project {:#?}", project);

View File

@@ -9,6 +9,7 @@ use failure::Fail;
use crate::{
project::{Project, ProjectLoadFuzzyError},
web::Server,
imfs::FsError,
live_session::LiveSession,
};
@@ -24,21 +25,26 @@ pub struct ServeOptions {
pub enum ServeError {
#[fail(display = "Project load error: {}", _0)]
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
#[fail(display = "{}", _0)]
FsError(#[fail(cause)] FsError),
}
impl_from!(ServeError {
ProjectLoadFuzzyError => ProjectLoadError,
FsError => FsError,
});
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)?);
project.check_compatibility();
info!("Found project at {}", project.file_location.display());
info!("Using project {:#?}", project);
let live_session = Arc::new(LiveSession::new(Arc::clone(&project)).unwrap());
let live_session = Arc::new(LiveSession::new(Arc::clone(&project))?);
let server = Server::new(Arc::clone(&live_session));
let port = options.port

View File

@@ -11,7 +11,7 @@ use reqwest::header::{ACCEPT, USER_AGENT, CONTENT_TYPE, COOKIE};
use crate::{
rbx_session::construct_oneoff_tree,
project::{Project, ProjectLoadFuzzyError},
imfs::Imfs,
imfs::{Imfs, FsError},
};
#[derive(Debug, Fail)]
@@ -33,6 +33,9 @@ pub enum UploadError {
#[fail(display = "XML model file error")]
XmlModelEncodeError(rbx_xml::EncodeError),
#[fail(display = "{}", _0)]
FsError(#[fail(cause)] FsError),
}
impl_from!(UploadError {
@@ -40,6 +43,7 @@ impl_from!(UploadError {
io::Error => IoError,
reqwest::Error => HttpError,
rbx_xml::EncodeError => XmlModelEncodeError,
FsError => FsError,
});
#[derive(Debug)]
@@ -56,6 +60,7 @@ pub fn upload(options: &UploadOptions) -> Result<(), UploadError> {
info!("Looking for project at {}", options.fuzzy_project_path.display());
let project = Project::load_fuzzy(&options.fuzzy_project_path)?;
project.check_compatibility();
info!("Found project at {}", project.file_location.display());
info!("Using project {:#?}", project);

View File

@@ -1,10 +1,12 @@
use std::{
sync::{mpsc, Arc, Mutex},
time::Duration,
path::Path,
ops::Deref,
thread,
};
use log::info;
use log::{warn, trace};
use notify::{
self,
DebouncedEvent,
@@ -20,97 +22,121 @@ use crate::{
const WATCH_TIMEOUT: Duration = Duration::from_millis(100);
fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: DebouncedEvent) {
/// Watches for changes on the filesystem and links together the in-memory
/// filesystem and in-memory Roblox tree.
pub struct FsWatcher {
watcher: RecommendedWatcher,
}
impl FsWatcher {
/// Start a new FS watcher, watching all of the roots currently attached to
/// the given Imfs.
///
/// `rbx_session` is optional to make testing easier. If it isn't `None`,
/// events will be passed to it after they're given to the Imfs.
pub fn start(imfs: Arc<Mutex<Imfs>>, rbx_session: Option<Arc<Mutex<RbxSession>>>) -> FsWatcher {
let (watch_tx, watch_rx) = mpsc::channel();
let mut watcher = notify::watcher(watch_tx, WATCH_TIMEOUT)
.expect("Could not create filesystem watcher");
{
let imfs = imfs.lock().unwrap();
for root_path in imfs.get_roots() {
watcher.watch(root_path, RecursiveMode::Recursive)
.expect("Could not watch directory");
}
}
{
let imfs = Arc::clone(&imfs);
let rbx_session = rbx_session.as_ref().map(Arc::clone);
thread::spawn(move || {
trace!("Watcher thread started");
while let Ok(event) = watch_rx.recv() {
// handle_fs_event expects an Option<&Mutex<T>>, but we have
// an Option<Arc<Mutex<T>>>, so we coerce with Deref.
let session_ref = rbx_session.as_ref().map(Deref::deref);
handle_fs_event(&imfs, session_ref, event);
}
trace!("Watcher thread stopped");
});
}
FsWatcher {
watcher,
}
}
pub fn stop_watching_path(&mut self, path: &Path) {
match self.watcher.unwatch(path) {
Ok(_) => {},
Err(e) => {
warn!("Could not unwatch path {}: {}", path.display(), e);
},
}
}
}
fn handle_fs_event(imfs: &Mutex<Imfs>, rbx_session: Option<&Mutex<RbxSession>>, event: DebouncedEvent) {
match event {
DebouncedEvent::Create(path) => {
trace!("Path created: {}", path.display());
{
let mut imfs = imfs.lock().unwrap();
imfs.path_created(&path).unwrap();
}
{
if let Some(rbx_session) = rbx_session {
let mut rbx_session = rbx_session.lock().unwrap();
rbx_session.path_created(&path);
}
},
DebouncedEvent::Write(path) => {
trace!("Path created: {}", path.display());
{
let mut imfs = imfs.lock().unwrap();
imfs.path_updated(&path).unwrap();
}
{
if let Some(rbx_session) = rbx_session {
let mut rbx_session = rbx_session.lock().unwrap();
rbx_session.path_updated(&path);
}
},
DebouncedEvent::Remove(path) => {
trace!("Path removed: {}", path.display());
{
let mut imfs = imfs.lock().unwrap();
imfs.path_removed(&path).unwrap();
}
{
if let Some(rbx_session) = rbx_session {
let mut rbx_session = rbx_session.lock().unwrap();
rbx_session.path_removed(&path);
}
},
DebouncedEvent::Rename(from_path, to_path) => {
trace!("Path renamed: {} to {}", from_path.display(), to_path.display());
{
let mut imfs = imfs.lock().unwrap();
imfs.path_moved(&from_path, &to_path).unwrap();
}
{
if let Some(rbx_session) = rbx_session {
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,
}
other => {
trace!("Unhandled FS event: {:?}", other);
},
}
}

View File

@@ -1,15 +1,41 @@
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
path::{self, Path, PathBuf},
fmt,
fs,
io,
};
use failure::Fail;
use serde_derive::{Serialize, Deserialize};
use crate::project::{Project, ProjectNode};
fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> io::Result<()> {
/// A wrapper around io::Error that also attaches the path associated with the
/// error.
#[derive(Debug, Fail)]
pub struct FsError {
#[fail(cause)]
inner: io::Error,
path: PathBuf,
}
impl FsError {
fn new<P: Into<PathBuf>>(inner: io::Error, path: P) -> FsError {
FsError {
inner,
path: path.into(),
}
}
}
impl fmt::Display for FsError {
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
write!(output, "{}: {}", self.path.display(), self.inner)
}
}
fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> Result<(), FsError> {
match project_node {
ProjectNode::Instance(node) => {
for child in node.children.values() {
@@ -24,9 +50,12 @@ fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> io::Result<()
Ok(())
}
/// The in-memory filesystem keeps a mirror of all files being watcher by Rojo
/// The in-memory filesystem keeps a mirror of all files being watched by Rojo
/// in order to deduplicate file changes in the case of bidirectional syncing
/// from Roblox Studio.
///
/// It also enables Rojo to quickly generate React-like snapshots to make
/// reasoning about instances and how they relate to files easier.
#[derive(Debug, Clone)]
pub struct Imfs {
items: HashMap<PathBuf, ImfsItem>,
@@ -41,7 +70,7 @@ impl Imfs {
}
}
pub fn add_roots_from_project(&mut self, project: &Project) -> io::Result<()> {
pub fn add_roots_from_project(&mut self, project: &Project) -> Result<(), FsError> {
add_sync_points(self, &project.tree)
}
@@ -60,30 +89,42 @@ impl Imfs {
self.items.get(path)
}
pub fn add_root(&mut self, path: &Path) -> io::Result<()> {
pub fn add_root(&mut self, path: &Path) -> Result<(), FsError> {
debug_assert!(path.is_absolute());
debug_assert!(!self.is_within_roots(path));
self.roots.insert(path.to_path_buf());
self.read_from_disk(path)
self.descend_and_read_from_disk(path)
}
pub fn path_created(&mut self, path: &Path) -> io::Result<()> {
pub fn remove_root(&mut self, path: &Path) {
debug_assert!(path.is_absolute());
if self.roots.get(path).is_some() {
self.remove_item(path);
if let Some(parent_path) = path.parent() {
self.unlink_child(parent_path, path);
}
}
}
pub fn path_created(&mut self, path: &Path) -> Result<(), FsError> {
debug_assert!(path.is_absolute());
debug_assert!(self.is_within_roots(path));
self.read_from_disk(path)
self.descend_and_read_from_disk(path)
}
pub fn path_updated(&mut self, path: &Path) -> io::Result<()> {
pub fn path_updated(&mut self, path: &Path) -> Result<(), FsError> {
debug_assert!(path.is_absolute());
debug_assert!(self.is_within_roots(path));
self.read_from_disk(path)
self.descend_and_read_from_disk(path)
}
pub fn path_removed(&mut self, path: &Path) -> io::Result<()> {
pub fn path_removed(&mut self, path: &Path) -> Result<(), FsError> {
debug_assert!(path.is_absolute());
debug_assert!(self.is_within_roots(path));
@@ -96,12 +137,7 @@ impl Imfs {
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));
pub fn path_moved(&mut self, from_path: &Path, to_path: &Path) -> Result<(), FsError> {
self.path_removed(from_path)?;
self.path_created(to_path)?;
Ok(())
@@ -132,9 +168,7 @@ impl Imfs {
Some(ImfsItem::Directory(directory)) => {
directory.children.remove(child);
},
_ => {
panic!("Tried to unlink child of path that wasn't a directory!");
},
_ => {},
}
}
@@ -153,11 +187,44 @@ impl Imfs {
}
}
fn read_from_disk(&mut self, path: &Path) -> io::Result<()> {
let metadata = fs::metadata(path)?;
fn descend_and_read_from_disk(&mut self, path: &Path) -> Result<(), FsError> {
let root_path = self.get_root_path(path)
.expect("Tried to descent and read for path that wasn't within roots!");
// If this path is a root, we should read the entire thing.
if root_path == path {
self.read_from_disk(path)?;
return Ok(());
}
let relative_path = path.strip_prefix(root_path).unwrap();
let mut current_path = root_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.items.contains_key(&next_path) {
current_path = next_path;
} else {
break;
}
},
_ => unreachable!(),
}
}
self.read_from_disk(&current_path)
}
fn read_from_disk(&mut self, path: &Path) -> Result<(), FsError> {
let metadata = fs::metadata(path)
.map_err(|e| FsError::new(e, path))?;
if metadata.is_file() {
let contents = fs::read(path)?;
let contents = fs::read(path)
.map_err(|e| FsError::new(e, path))?;
let item = ImfsItem::File(ImfsFile {
path: path.to_path_buf(),
contents,
@@ -178,8 +245,13 @@ impl Imfs {
self.items.insert(path.to_path_buf(), item);
for entry in fs::read_dir(path)? {
let entry = entry?;
let dir_children = fs::read_dir(path)
.map_err(|e| FsError::new(e, path))?;
for entry in dir_children {
let entry = entry
.map_err(|e| FsError::new(e, path))?;
let child_path = entry.path();
self.read_from_disk(&child_path)?;
@@ -195,6 +267,16 @@ impl Imfs {
}
}
fn get_root_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 is_within_roots(&self, path: &Path) -> bool {
for root_path in &self.roots {
if path.starts_with(root_path) {

View File

@@ -1,16 +1,15 @@
use std::{
sync::{Arc, Mutex},
io,
};
use crate::{
fs_watcher::FsWatcher,
imfs::{Imfs, FsError},
message_queue::MessageQueue,
project::Project,
imfs::Imfs,
session_id::SessionId,
rbx_session::RbxSession,
session_id::SessionId,
snapshot_reconciler::InstanceChanges,
fs_watcher::FsWatcher,
};
/// Contains all of the state for a Rojo live-sync session.
@@ -24,7 +23,7 @@ pub struct LiveSession {
}
impl LiveSession {
pub fn new(project: Arc<Project>) -> io::Result<LiveSession> {
pub fn new(project: Arc<Project>) -> Result<LiveSession, FsError> {
let imfs = {
let mut imfs = Imfs::new();
imfs.add_roots_from_project(&project)?;
@@ -41,7 +40,7 @@ impl LiveSession {
let fs_watcher = FsWatcher::start(
Arc::clone(&imfs),
Arc::clone(&rbx_session),
Some(Arc::clone(&rbx_session)),
);
let session_id = SessionId::new();

View File

@@ -6,12 +6,14 @@ use std::{
path::{Path, PathBuf},
};
use log::warn;
use failure::Fail;
use maplit::hashmap;
use rbx_tree::RbxValue;
use serde_derive::{Serialize, Deserialize};
pub static PROJECT_FILENAME: &'static str = "roblox-project.json";
pub static PROJECT_FILENAME: &'static str = "default.project.json";
pub static COMPAT_PROJECT_FILENAME: &'static str = "roblox-project.json";
// Methods used for Serde's default value system, which doesn't support using
// value literals directly, only functions that return values.
@@ -362,11 +364,17 @@ impl Project {
} 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() {
if let Ok(file_metadata) = fs::metadata(&with_file) {
if file_metadata.is_file() {
return Some(with_file);
} else {
return None;
}
}
let with_compat_file = start_location.join(COMPAT_PROJECT_FILENAME);
if let Ok(file_metadata) = fs::metadata(&with_compat_file) {
if file_metadata.is_file() {
return Some(with_compat_file);
}
}
}
@@ -405,6 +413,25 @@ impl Project {
Ok(())
}
/// Checks if there are any compatibility issues with this project file and
/// warns the user if there are any.
pub fn check_compatibility(&self) {
let file_name = self.file_location
.file_name().unwrap()
.to_str().expect("Project file path was not valid Unicode!");
if file_name == COMPAT_PROJECT_FILENAME {
warn!("Rojo's default project file name changed in 0.5.0-alpha3.");
warn!("Support for the old project file name will be dropped before 0.5.0 releases.");
warn!("Your project file is named {}", COMPAT_PROJECT_FILENAME);
warn!("Rename your project file to {}", PROJECT_FILENAME);
} else if !file_name.ends_with(".project.json") {
warn!("Starting in Rojo 0.5.0-alpha3, it's recommended to give all project files the");
warn!(".project.json extension. This helps Rojo differentiate project files from");
warn!("other JSON files!");
}
}
fn to_source_project(&self) -> SourceProject {
SourceProject {
name: self.name.clone(),

View File

@@ -153,10 +153,14 @@ impl RbxSession {
// If the path doesn't exist or is a directory, we don't care if it
// updated
match imfs.get(path) {
Some(ImfsItem::Directory(_)) | None => {
Some(ImfsItem::Directory(_)) => {
trace!("Updated path was a directory, ignoring.");
return;
},
None => {
trace!("Updated path did not exist in IMFS, ignoring.");
return;
},
Some(ImfsItem::File(_)) => {},
}
}

View File

@@ -6,10 +6,11 @@ use std::{
str,
};
use serde_derive::{Serialize, Deserialize};
use failure::Fail;
use log::info;
use maplit::hashmap;
use rbx_tree::{RbxTree, RbxValue, RbxInstanceProperties};
use failure::Fail;
use serde_derive::{Serialize, Deserialize};
use crate::{
imfs::{
@@ -53,6 +54,11 @@ pub enum SnapshotError {
path: PathBuf,
},
JsonModelDecodeError {
inner: serde_json::Error,
path: PathBuf,
},
XmlModelDecodeError {
inner: rbx_xml::DecodeError,
path: PathBuf,
@@ -71,6 +77,9 @@ impl fmt::Display for SnapshotError {
SnapshotError::Utf8Error { inner, path } => {
write!(output, "Invalid UTF-8: {} in path {}", inner, path.display())
},
SnapshotError::JsonModelDecodeError { inner, path } => {
write!(output, "Malformed .model.json model: {:?} in path {}", inner, path.display())
},
SnapshotError::XmlModelDecodeError { inner, path } => {
write!(output, "Malformed rbxmx model: {:?} in path {}", inner, path.display())
},
@@ -248,7 +257,18 @@ fn snapshot_imfs_file<'source>(
Some("txt") => snapshot_txt_file(file)?,
Some("rbxmx") => snapshot_xml_model_file(file)?,
Some("rbxm") => snapshot_binary_model_file(file)?,
Some(_) | None => return Ok(None),
Some("json") => {
let file_stem = file.path
.file_stem().expect("Could not extract file stem")
.to_str().expect("Could not convert path to UTF-8");
if file_stem.ends_with(".model") {
snapshot_json_model_file(file)?
} else {
None
}
},
Some(_) | None => None,
};
if let Some(snapshot) = maybe_snapshot.as_mut() {
@@ -256,6 +276,8 @@ fn snapshot_imfs_file<'source>(
if let Some(snapshot_name) = instance_name {
snapshot.name = snapshot_name;
}
} else {
info!("File generated no snapshot: {}", file.path.display());
}
Ok(maybe_snapshot)
@@ -402,6 +424,57 @@ struct LocalizationEntryJson {
values: HashMap<String, String>,
}
fn snapshot_json_model_file<'source>(
file: &'source ImfsFile,
) -> SnapshotResult<'source> {
let contents = str::from_utf8(&file.contents)
.map_err(|inner| SnapshotError::Utf8Error {
inner,
path: file.path.to_owned(),
})?;
let json_instance: JsonModelInstance = serde_json::from_str(contents)
.map_err(|inner| SnapshotError::JsonModelDecodeError {
inner,
path: file.path.to_owned(),
})?;
let mut snapshot = json_instance.into_snapshot();
snapshot.metadata.source_path = Some(file.path.to_owned());
Ok(Some(snapshot))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JsonModelInstance {
name: String,
class_name: String,
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
children: Vec<JsonModelInstance>,
#[serde(default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")]
properties: HashMap<String, RbxValue>,
}
impl JsonModelInstance {
fn into_snapshot(mut self) -> RbxSnapshotInstance<'static> {
let children = self.children
.drain(..)
.map(JsonModelInstance::into_snapshot)
.collect();
RbxSnapshotInstance {
name: Cow::Owned(self.name),
class_name: Cow::Owned(self.class_name),
properties: self.properties,
children,
metadata: Default::default(),
}
}
}
fn snapshot_xml_model_file<'source>(
file: &'source ImfsFile,
) -> SnapshotResult<'source> {

View File

@@ -5,6 +5,7 @@ use std::{
process::{Command, Stdio},
};
use log::warn;
use rbx_tree::RbxId;
use crate::{
@@ -27,13 +28,21 @@ digraph RojoTree {
"#;
/// Compiles DOT source to SVG by invoking dot on the command line.
pub fn graphviz_to_svg(source: &str) -> String {
let mut child = Command::new("dot")
pub fn graphviz_to_svg(source: &str) -> Option<String> {
let command = Command::new("dot")
.arg("-Tsvg")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("Failed to spawn GraphViz process -- make sure it's installed in order to use /api/visualize");
.spawn();
let mut child = match command {
Ok(child) => child,
Err(_) => {
warn!("Failed to spawn GraphViz process to visualize current state.");
warn!("If you want pretty graphs, install GraphViz and make sure 'dot' is on your PATH!");
return None;
},
};
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
@@ -41,7 +50,7 @@ pub fn graphviz_to_svg(source: &str) -> String {
}
let output = child.wait_with_output().expect("Failed to read stdout");
String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8")
Some(String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8"))
}
/// A Display wrapper struct to visualize an RbxSession as SVG.

View File

@@ -22,6 +22,8 @@ use crate::{
rbx_session::{MetadataPerInstance},
};
static HOME_CONTENT: &str = include_str!("../assets/index.html");
/// Contains the instance metadata relevant to Rojo clients.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -96,136 +98,31 @@ impl Server {
router!(request,
(GET) (/) => {
Response::text("Rojo is up and running!")
self.handle_home()
},
(GET) (/api/rojo) => {
// Get a summary of information about the server.
let rbx_session = self.live_session.rbx_session.lock().unwrap();
let tree = rbx_session.get_tree();
Response::json(&ServerInfoResponse {
server_version: self.server_version,
protocol_version: 2,
session_id: self.live_session.session_id,
expected_place_ids: self.live_session.project.serve_place_ids.clone(),
root_instance_id: tree.get_root_id(),
})
self.handle_api_rojo()
},
(GET) (/api/subscribe/{ cursor: u32 }) => {
// Retrieve any messages past the given cursor index, and if
// there weren't any, subscribe to receive any new messages.
let message_queue = Arc::clone(&self.live_session.message_queue);
// Did the client miss any messages since the last subscribe?
{
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
if !new_messages.is_empty() {
return Response::json(&SubscribeResponse {
session_id: self.live_session.session_id,
messages: Cow::Borrowed(&new_messages),
message_cursor: new_cursor,
})
}
}
let (tx, rx) = mpsc::channel();
let sender_id = message_queue.subscribe(tx);
match rx.recv() {
Ok(_) => (),
Err(_) => return Response::text("error!").with_status_code(500),
}
message_queue.unsubscribe(sender_id);
{
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
return Response::json(&SubscribeResponse {
session_id: self.live_session.session_id,
messages: Cow::Owned(new_messages),
message_cursor: new_cursor,
})
}
self.handle_api_subscribe(cursor)
},
(GET) (/api/read/{ id_list: String }) => {
let message_queue = Arc::clone(&self.live_session.message_queue);
let requested_ids: Option<Vec<RbxId>> = id_list
.split(',')
.map(RbxId::parse_str)
.collect();
let requested_ids = match requested_ids {
Some(id) => id,
None => return rouille::Response::text("Malformed ID list").with_status_code(400),
};
let rbx_session = self.live_session.rbx_session.lock().unwrap();
let tree = rbx_session.get_tree();
let message_cursor = message_queue.get_message_cursor();
let mut instances = HashMap::new();
for &requested_id in &requested_ids {
if let Some(instance) = tree.get_instance(requested_id) {
let metadata = rbx_session.get_instance_metadata(requested_id)
.map(InstanceMetadata::from_session_metadata);
instances.insert(instance.get_id(), InstanceWithMetadata {
instance: Cow::Borrowed(instance),
metadata,
});
for descendant in tree.descendants(requested_id) {
let descendant_meta = rbx_session.get_instance_metadata(descendant.get_id())
.map(InstanceMetadata::from_session_metadata);
instances.insert(descendant.get_id(), InstanceWithMetadata {
instance: Cow::Borrowed(descendant),
metadata: descendant_meta,
});
}
}
}
Response::json(&ReadResponse {
session_id: self.live_session.session_id,
message_cursor,
instances,
})
self.handle_api_read(requested_ids)
},
(GET) (/visualize/rbx) => {
let rbx_session = self.live_session.rbx_session.lock().unwrap();
let dot_source = format!("{}", VisualizeRbxSession(&rbx_session));
Response::svg(graphviz_to_svg(&dot_source))
self.handle_visualize_rbx()
},
(GET) (/visualize/imfs) => {
let imfs = self.live_session.imfs.lock().unwrap();
let dot_source = format!("{}", VisualizeImfs(&imfs));
Response::svg(graphviz_to_svg(&dot_source))
self.handle_visualize_imfs()
},
(GET) (/visualize/path_metadata) => {
let rbx_session = self.live_session.rbx_session.lock().unwrap();
Response::json(&rbx_session.debug_get_metadata_per_path())
self.handle_visualize_path_metadata()
},
_ => Response::empty_404()
)
}
@@ -235,4 +132,131 @@ impl Server {
rouille::start_server(address, move |request| self.handle_request(request));
}
fn handle_home(&self) -> Response {
Response::html(HOME_CONTENT)
}
/// Get a summary of information about the server
fn handle_api_rojo(&self) -> Response {
let rbx_session = self.live_session.rbx_session.lock().unwrap();
let tree = rbx_session.get_tree();
Response::json(&ServerInfoResponse {
server_version: self.server_version,
protocol_version: 2,
session_id: self.live_session.session_id,
expected_place_ids: self.live_session.project.serve_place_ids.clone(),
root_instance_id: tree.get_root_id(),
})
}
/// Retrieve any messages past the given cursor index, and if
/// there weren't any, subscribe to receive any new messages.
fn handle_api_subscribe(&self, cursor: u32) -> Response {
let message_queue = Arc::clone(&self.live_session.message_queue);
// Did the client miss any messages since the last subscribe?
{
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
if !new_messages.is_empty() {
return Response::json(&SubscribeResponse {
session_id: self.live_session.session_id,
messages: Cow::Borrowed(&new_messages),
message_cursor: new_cursor,
})
}
}
let (tx, rx) = mpsc::channel();
let sender_id = message_queue.subscribe(tx);
match rx.recv() {
Ok(_) => (),
Err(_) => return Response::text("error!").with_status_code(500),
}
message_queue.unsubscribe(sender_id);
{
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
return Response::json(&SubscribeResponse {
session_id: self.live_session.session_id,
messages: Cow::Owned(new_messages),
message_cursor: new_cursor,
})
}
}
fn handle_api_read(&self, requested_ids: Option<Vec<RbxId>>) -> Response {
let message_queue = Arc::clone(&self.live_session.message_queue);
let requested_ids = match requested_ids {
Some(id) => id,
None => return rouille::Response::text("Malformed ID list").with_status_code(400),
};
let rbx_session = self.live_session.rbx_session.lock().unwrap();
let tree = rbx_session.get_tree();
let message_cursor = message_queue.get_message_cursor();
let mut instances = HashMap::new();
for &requested_id in &requested_ids {
if let Some(instance) = tree.get_instance(requested_id) {
let metadata = rbx_session.get_instance_metadata(requested_id)
.map(InstanceMetadata::from_session_metadata);
instances.insert(instance.get_id(), InstanceWithMetadata {
instance: Cow::Borrowed(instance),
metadata,
});
for descendant in tree.descendants(requested_id) {
let descendant_meta = rbx_session.get_instance_metadata(descendant.get_id())
.map(InstanceMetadata::from_session_metadata);
instances.insert(descendant.get_id(), InstanceWithMetadata {
instance: Cow::Borrowed(descendant),
metadata: descendant_meta,
});
}
}
}
Response::json(&ReadResponse {
session_id: self.live_session.session_id,
message_cursor,
instances,
})
}
fn handle_visualize_rbx(&self) -> Response {
let rbx_session = self.live_session.rbx_session.lock().unwrap();
let dot_source = format!("{}", VisualizeRbxSession(&rbx_session));
match graphviz_to_svg(&dot_source) {
Some(svg) => Response::svg(svg),
None => Response::text(dot_source),
}
}
fn handle_visualize_imfs(&self) -> Response {
let imfs = self.live_session.imfs.lock().unwrap();
let dot_source = format!("{}", VisualizeImfs(&imfs));
match graphviz_to_svg(&dot_source) {
Some(svg) => Response::svg(svg),
None => Response::text(dot_source),
}
}
fn handle_visualize_path_metadata(&self) -> Response {
let rbx_session = self.live_session.rbx_session.lock().unwrap();
Response::json(&rbx_session.debug_get_metadata_per_path())
}
}

View File

@@ -1,18 +0,0 @@
#!/bin/sh
set -e
if [ ! -d "../test-projects/$1" ]
then
echo "Pick a project that exists!"
exit 1
fi
if [ -d "scratch" ]
then
rm -rf scratch
fi
mkdir -p scratch
cp -r "../test-projects/$1" scratch
cargo run -- serve "scratch/$1"

View File

@@ -1,10 +1,10 @@
use std::{
collections::{HashMap, HashSet},
io,
fs,
path::PathBuf,
};
use failure::Error;
use tempfile::{TempDir, tempdir};
use librojo::{
@@ -19,7 +19,7 @@ enum FsEvent {
Moved(PathBuf, PathBuf),
}
fn send_events(imfs: &mut Imfs, events: &[FsEvent]) -> io::Result<()> {
fn send_events(imfs: &mut Imfs, events: &[FsEvent]) -> Result<(), Error> {
for event in events {
match event {
FsEvent::Created(path) => imfs.path_created(path)?,
@@ -56,7 +56,7 @@ fn check_expected(real: &Imfs, expected: &ExpectedImfs) {
}
}
fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
fn base_tree() -> Result<(TempDir, Imfs, ExpectedImfs, TestResources), Error> {
let root = tempdir()?;
let foo_path = root.path().join("foo");
@@ -125,7 +125,7 @@ fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
}
#[test]
fn initial_read() -> io::Result<()> {
fn initial_read() -> Result<(), Error> {
let (_root, imfs, expected_imfs, _resources) = base_tree()?;
check_expected(&imfs, &expected_imfs);
@@ -134,7 +134,7 @@ fn initial_read() -> io::Result<()> {
}
#[test]
fn adding_files() -> io::Result<()> {
fn adding_files() -> Result<(), Error> {
let (root, mut imfs, mut expected_imfs, resources) = base_tree()?;
check_expected(&imfs, &expected_imfs);
@@ -178,7 +178,7 @@ fn adding_files() -> io::Result<()> {
}
#[test]
fn adding_folder() -> io::Result<()> {
fn adding_folder() -> Result<(), Error> {
let (root, imfs, mut expected_imfs, _resources) = base_tree()?;
check_expected(&imfs, &expected_imfs);
@@ -232,6 +232,16 @@ fn adding_folder() -> io::Result<()> {
FsEvent::Created(file1_path.clone()),
FsEvent::Created(file2_path.clone()),
],
vec![
FsEvent::Created(file1_path.clone()),
FsEvent::Created(file2_path.clone()),
FsEvent::Created(folder_path.clone()),
],
vec![
FsEvent::Created(file1_path.clone()),
FsEvent::Created(folder_path.clone()),
FsEvent::Created(file2_path.clone()),
],
];
for events in &possible_event_sequences {
@@ -245,7 +255,36 @@ fn adding_folder() -> io::Result<()> {
}
#[test]
fn removing_file() -> io::Result<()> {
fn updating_files() -> Result<(), Error> {
let (_root, mut imfs, mut expected_imfs, resources) = base_tree()?;
check_expected(&imfs, &expected_imfs);
fs::write(&resources.bar_path, b"bar updated")?;
fs::write(&resources.baz_path, b"baz updated")?;
imfs.path_updated(&resources.bar_path)?;
imfs.path_updated(&resources.baz_path)?;
let bar_updated_item = ImfsItem::File(ImfsFile {
path: resources.bar_path.clone(),
contents: b"bar updated".to_vec(),
});
let baz_updated_item = ImfsItem::File(ImfsFile {
path: resources.baz_path.clone(),
contents: b"baz updated".to_vec(),
});
expected_imfs.items.insert(resources.bar_path.clone(), bar_updated_item);
expected_imfs.items.insert(resources.baz_path.clone(), baz_updated_item);
check_expected(&imfs, &expected_imfs);
Ok(())
}
#[test]
fn removing_file() -> Result<(), Error> {
let (root, mut imfs, mut expected_imfs, resources) = base_tree()?;
check_expected(&imfs, &expected_imfs);
@@ -269,7 +308,7 @@ fn removing_file() -> io::Result<()> {
}
#[test]
fn removing_folder() -> io::Result<()> {
fn removing_folder() -> Result<(), Error> {
let (root, imfs, mut expected_imfs, resources) = base_tree()?;
check_expected(&imfs, &expected_imfs);
@@ -294,6 +333,10 @@ fn removing_folder() -> io::Result<()> {
FsEvent::Removed(resources.baz_path.clone()),
FsEvent::Removed(resources.foo_path.clone()),
],
vec![
FsEvent::Removed(resources.foo_path.clone()),
FsEvent::Removed(resources.baz_path.clone()),
],
];
for events in &possible_event_sequences {

View File

@@ -21,7 +21,7 @@ lazy_static! {
#[test]
fn empty() {
let project_file_location = TEST_PROJECTS_ROOT.join("empty/roblox-project.json");
let project_file_location = TEST_PROJECTS_ROOT.join("empty/default.project.json");
let project = Project::load_exact(&project_file_location).unwrap();
assert_eq!(project.name, "empty");
@@ -29,7 +29,7 @@ fn empty() {
#[test]
fn empty_fuzzy_file() {
let project_file_location = TEST_PROJECTS_ROOT.join("empty/roblox-project.json");
let project_file_location = TEST_PROJECTS_ROOT.join("empty/default.project.json");
let project = Project::load_fuzzy(&project_file_location).unwrap();
assert_eq!(project.name, "empty");
@@ -45,7 +45,7 @@ fn empty_fuzzy_folder() {
#[test]
fn single_sync_point() {
let project_file_location = TEST_PROJECTS_ROOT.join("single-sync-point/roblox-project.json");
let project_file_location = TEST_PROJECTS_ROOT.join("single-sync-point/default.project.json");
let project = Project::load_exact(&project_file_location).unwrap();
let expected_project = {
@@ -100,7 +100,7 @@ fn single_sync_point() {
#[test]
fn test_model() {
let project_file_location = TEST_PROJECTS_ROOT.join("test-model/roblox-project.json");
let project_file_location = TEST_PROJECTS_ROOT.join("test-model/default.project.json");
let project = Project::load_exact(&project_file_location).unwrap();
assert_eq!(project.name, "test-model");

View File

@@ -0,0 +1,14 @@
{
"Name": "All my Remote Events",
"ClassName": "Folder",
"Children": [
{
"Name": "SendMoney",
"ClassName": "RemoteEvent"
},
{
"Name": "SendItems",
"ClassName": "RemoteEvent"
}
]
}

View File

@@ -5,12 +5,10 @@
<Item class="Script" referent="RBX634A9A9988354E4B9D971B2A4DEBD26E">
<Properties>
<bool name="Disabled">false</bool>
<Content name="LinkedSource"><null></null></Content>
<string name="Name">Lone Script</string>
<string name="ScriptGuid">{C62CD9FB-FF28-4FD9-9712-AD28A1E92C84}</string>
<ProtectedString name="Source"><![CDATA[print("Hello world!")
]]></ProtectedString>
<BinaryString name="Tags"></BinaryString>
<string name="Source"><![CDATA[print("Hello world!")
]]></string>
</Properties>
</Item>
</roblox>

View File

@@ -0,0 +1,6 @@
{
"name": "missing-files",
"tree": {
"$path": "does-not-exist"
}
}

21
test-scratch-project Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Copies a project from 'test-projects' into a folder that can be messed with
# without accidentally checking the results into version control.
set -e
if [ ! -d "test-projects/$1" ]
then
echo "Pick a project that exists!"
exit 1
fi
if [ -d "scratch-project/$1" ]
then
rm -rf "scratch-project/$1"
fi
mkdir -p scratch-project
cp -r "test-projects/$1" scratch-project
cargo run -- serve "scratch-project/$1"