Compare commits

..

15 Commits

Author SHA1 Message Date
Lucien Greathouse
78d97e162c Release v0.5.2 2019-10-14 17:33:43 -07:00
Lucien Greathouse
5d0aa1193f Fix LocalizationTable output order by switching to a BTreeMap.
ACTUALLY closes #173.
2019-10-14 17:29:34 -07:00
Lucien Greathouse
126040a87b Add build tests for init.meta.json 2019-10-12 23:59:54 -07:00
Lucien Greathouse
2c408f4047 Fix errors in 'Sync Details' page 2019-10-08 16:17:13 -07:00
Lucien Greathouse
b53cda787a Add end-to-end build test for Script.Disabled via .meta.json 2019-10-08 16:09:27 -07:00
Lucien Greathouse
7b4455ed51 Release v0.5.1 2019-10-04 12:51:14 -07:00
Lucien Greathouse
5b57025b0b plugin: Only move message cursor in response to retrieveMessages 2019-10-04 11:13:10 -07:00
Lucien Greathouse
ece454e6dd Update dependencies 2019-10-04 10:54:53 -07:00
boyned//Kampfkarren
afa480b07d Fix broken link to sync details (#248) 2019-09-22 17:38:40 -07:00
Lucien Greathouse
c9b695d533 Fix guide to point to release versions instead of alphas 2019-09-20 11:06:01 -07:00
Lucien Greathouse
71c77a09a6 Update docs link to rojo.space 2019-09-19 14:02:26 -07:00
Lucien Greathouse
d309a1359c Update changelog 2019-09-13 17:16:05 -07:00
Lucien Greathouse
b0bb486d9a Improve diagnostics for failed instance creation 2019-09-13 16:00:08 -07:00
Lucien Greathouse
2c7c3348cf Add help page to direct people to Discord, GitHub, and Twitter 2019-09-11 11:36:02 -07:00
Lucien Greathouse
4caac5e6cb Update docs home for 0.5.x 2019-08-27 14:37:26 -07:00
382 changed files with 9963 additions and 56642 deletions

View File

@@ -3,24 +3,16 @@ root = true
[*] [*]
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = false insert_final_newline = false
trim_trailing_whitespace = true
[*.{json,js,css}] [*.{json,js,css}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.md] [*.{md,rs}]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[*.{rs,toml}]
indent_style = space
indent_size = 4
insert_final_newline = true
[*.snap]
insert_final_newline = true
[*.lua] [*.lua]
indent_style = tab indent_style = tab

View File

@@ -1,36 +0,0 @@
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
rust_version: [stable, "1.40.0"]
steps:
- uses: actions/checkout@v1
- name: Setup Rust toolchain
run: rustup default ${{ matrix.rust_version }}
- name: Build
run: cargo build --locked --verbose
- name: Run tests
run: cargo test --locked --verbose
- name: Rustfmt and Clippy
run: |
cargo fmt -- --check
cargo clippy
if: matrix.rust_version == 'stable'
- name: Build (All Features)
run: cargo build --locked --verbose --all-features
- name: Run tests (All Features)
run: cargo test --locked --verbose --all-features

View File

@@ -1,60 +0,0 @@
name: Release
on:
push:
tags: ["*"]
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v1
- name: Build release binary
run: cargo build --verbose --locked --release
- name: Upload artifacts
uses: actions/upload-artifact@v1
with:
name: rojo-win64
path: target/release/rojo.exe
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v1
- name: Install Rust
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
- name: Build release binary
run: |
source $HOME/.cargo/env
cargo build --verbose --locked --release
env:
OPENSSL_STATIC: 1
- name: Upload artifacts
uses: actions/upload-artifact@v1
with:
name: rojo-macos
path: target/release/rojo
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Build
run: cargo build --locked --verbose --release
env:
OPENSSL_STATIC: 1
- name: Upload artifacts
uses: actions/upload-artifact@v1
with:
name: rojo-linux
path: target/release/rojo

15
.gitignore vendored
View File

@@ -1,18 +1,9 @@
# Rust output directory
/target /target
/scratch-project
# Headers for clibrojo /server/failed-snapshots/
/include **/*.rs.bk
# Roblox model and place files in the root, used for debugging
/*.rbxm /*.rbxm
/*.rbxmx /*.rbxmx
/*.rbxl /*.rbxl
/*.rbxlx /*.rbxlx
# Roblox Studio holds 'lock' files on places
*.rbxl.lock
*.rbxlx.lock
# Snapshot files from the 'insta' Rust crate
**/*.snap.new **/*.snap.new

3
.gitmodules vendored
View File

@@ -4,6 +4,9 @@
[submodule "plugin/modules/testez"] [submodule "plugin/modules/testez"]
path = plugin/modules/testez path = plugin/modules/testez
url = https://github.com/Roblox/testez.git url = https://github.com/Roblox/testez.git
[submodule "plugin/modules/lemur"]
path = plugin/modules/lemur
url = https://github.com/LPGhatguy/lemur.git
[submodule "plugin/modules/promise"] [submodule "plugin/modules/promise"]
path = plugin/modules/promise path = plugin/modules/promise
url = https://github.com/LPGhatguy/roblox-lua-promise.git url = https://github.com/LPGhatguy/roblox-lua-promise.git

44
.travis.yml Normal file
View File

@@ -0,0 +1,44 @@
matrix:
include:
# Lua tests are currently disabled because of holes in Lemur that are pretty
# tedious to fix. It should be fixed by either adding missing features to
# Lemur or by migrating to a CI system based on real Roblox instead.
# - language: python
# env:
# - LUA="lua=5.1"
# before_install:
# - pip install hererocks
# - hererocks lua_install -r^ --$LUA
# - export PATH=$PATH:$PWD/lua_install/bin
# install:
# - luarocks install luafilesystem
# - luarocks install busted
# - luarocks install luacov
# - luarocks install luacov-coveralls
# - luarocks install luacheck
# script:
# - cd plugin
# - luacheck src
# - lua -lluacov spec.lua
# after_success:
# - cd plugin
# - luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
- language: rust
rust: 1.34.0
cache: cargo
script:
- cargo test --verbose
- language: rust
rust: stable
cache: cargo
script:
- cargo test --verbose

View File

@@ -1,43 +1,6 @@
# Rojo Changelog # Rojo Changelog
## Unreleased Changes for 0.6.x ## Unreleased Changes
## [0.6.0 Alpha 2](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.2) (March 6, 2020)
* Fixed `rojo upload` command always uploading models.
* Removed `--kind` parameter to `rojo upload`; Rojo now automatically uploads the correct kind of asset based on your project file.
## [0.5.4](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.4) (February 26, 2020)
This is a general maintenance release for the Rojo 0.5.x release series.
* Updated reflection database and other dependencies.
* First stable release with binaries for macOS and Linux.
## [0.6.0 Alpha 1](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.1) (January 22, 2020)
### General
* Added support for nested project files. ([#95](https://github.com/rojo-rbx/rojo/issues/95))
* Added project file hot-reloading. ([#10](https://github.com/rojo-rbx/rojo/issues/10)])
* Fixed Rojo dropping Ref properties ([#142](https://github.com/rojo-rbx/rojo/issues/142))
* This means that properties like `PrimaryPart` now work!
* Improved live sync protocol to reduce round-trips and improve syncing consistency.
* Improved support for binary model files and places.
### Command Line
* Added `--verbose`/`-v` flag, which can be specified multiple times to increase verbosity.
* Added support for automatically finding Roblox Studio's auth cookie for `rojo upload` on Windows.
* Added support for building, serving and uploading sources that aren't Rojo projects.
* Improved feedback from `rojo serve`.
* Removed support for legacy `roblox-project.json` projects, deprecated in an early Rojo 0.5.0 alpha.
* Rojo no longer traverses directories upwards looking for project files.
* Though undocumented, Rojo 0.5.x will search for a project file contained in any ancestor folders. This feature was removed to better support other 0.6.x features.
### Roblox Studio Plugin
* Added "connecting" state to improve experience when live syncing.
* Added "error" state to show errors in a place that isn't the output panel.
* Improved diagnostics for when the Rojo plugin cannot create an instance.
## [0.5.3](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.3) (October 15, 2019)
* Fixed an issue where Rojo would throw an error when encountering recently-added instance classes.
## [0.5.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.2) (October 14, 2019) ## [0.5.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.2) (October 14, 2019)
* Fixed an issue where `LocalizationTable` instances would have their column order randomized. ([#173](https://github.com/rojo-rbx/rojo/issues/173)) * Fixed an issue where `LocalizationTable` instances would have their column order randomized. ([#173](https://github.com/rojo-rbx/rojo/issues/173))

View File

@@ -11,13 +11,6 @@ Some of the repositories covered are:
## Code ## Code
Code contributions are welcome for features and bugs that have been reported in the project's bug tracker. We want to make sure that no one wastes their time, so be sure to talk with maintainers about what changes would be accepted before doing any work! Code contributions are welcome for features and bugs that have been reported in the project's bug tracker. We want to make sure that no one wastes their time, so be sure to talk with maintainers about what changes would be accepted before doing any work!
You'll want these tools to work on Rojo:
* Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* Latest stable [Remodel](https://github.com/rojo-rbx/remodel)
* Latest stable [run-in-roblox](https://github.com/rojo-rbx/run-in-roblox)
## Documentation ## Documentation
Documentation impacts way more people than the individual lines of code we write. Documentation impacts way more people than the individual lines of code we write.

1592
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +1,8 @@
[package]
name = "rojo"
version = "0.6.0-alpha.2"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
homepage = "https://rojo.space"
documentation = "https://rojo.space/docs"
repository = "https://github.com/rojo-rbx/rojo"
readme = "README.md"
edition = "2018"
exclude = [
"/plugin/**",
"/test-projects/**",
]
[features]
default = []
# Turn on support for specifying glob ignore path rules in the project format.
unstable_glob_ignore_paths = []
# Turn on the server half of Rojo's unstable two-way sync feature.
unstable_two_way_sync = []
# Enable this feature to live-reload assets from the web UI.
dev_live_assets = []
[workspace] [workspace]
members = [ members = [
"rojo-test", "server",
"rojo-insta-ext", "rojo-test",
"clibrojo",
"vfs",
] ]
default-members = [ [profile.dev]
".", opt-level = 1
"rojo-test",
"rojo-insta-ext",
"vfs",
]
[lib]
name = "librojo"
path = "src/lib.rs"
[[bin]]
name = "rojo"
path = "src/bin.rs"
[[bench]]
name = "build"
harness = false
[dependencies]
crossbeam-channel = "0.4.0"
csv = "1.1.1"
env_logger = "0.7.1"
futures = "0.1.29"
globset = "0.4.4"
humantime = "1.3.0"
hyper = "0.12.35"
jod-thread = "0.1.0"
lazy_static = "1.4.0"
log = "0.4.8"
maplit = "1.0.1"
notify = "4.0.14"
rbx_binary = "0.5.0"
rbx_dom_weak = "1.10.1"
rbx_reflection = "3.3.408"
rbx_xml = "0.11.3"
regex = "1.3.1"
reqwest = "0.9.20"
ritz = "0.1.0"
rlua = "0.17.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
snafu = "0.6.0"
structopt = "0.3.5"
termcolor = "1.0.5"
uuid = { version = "0.8.1", features = ["v4", "serde"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.6.2"
[dev-dependencies]
rojo-insta-ext = { path = "rojo-insta-ext" }
criterion = "0.3"
insta = { version = "0.13.1", features = ["redactions"] }
lazy_static = "1.2"
paste = "0.1"
pretty_assertions = "0.6.1"
serde_yaml = "0.8.9"
tempfile = "3.0"
tokio = "0.1.22"
walkdir = "2.1"

View File

@@ -7,11 +7,14 @@
<div>&nbsp;</div> <div>&nbsp;</div>
<div align="center"> <div align="center">
<a href="https://github.com/rojo-rbx/rojo/actions"> <a href="https://travis-ci.org/rojo-rbx/rojo">
<img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /> <img src="https://api.travis-ci.org/rojo-rbx/rojo.svg?branch=master" alt="Travis-CI Build Status" />
</a> </a>
<a href="https://crates.io/crates/rojo"> <a href="https://crates.io/crates/rojo">
<img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /> <img src="https://img.shields.io/crates/v/rojo.svg?label=version" alt="Latest server version" />
</a>
<a href="https://rojo.space/docs/0.4.x">
<img src="https://img.shields.io/badge/docs-0.4.x-brightgreen.svg" alt="Rojo 0.4.x Documentation" />
</a> </a>
<a href="https://rojo.space/docs/0.5.x"> <a href="https://rojo.space/docs/0.5.x">
<img src="https://img.shields.io/badge/docs-0.5.x-brightgreen.svg" alt="Rojo 0.5.x Documentation" /> <img src="https://img.shields.io/badge/docs-0.5.x-brightgreen.svg" alt="Rojo 0.5.x Documentation" />
@@ -41,15 +44,15 @@ Soon, Rojo will be able to:
* Automatically manage your assets on Roblox.com, like images and sounds * Automatically manage your assets on Roblox.com, like images and sounds
* Import custom instances like MoonScript code * Import custom instances like MoonScript code
## [Documentation](https://rojo.space/docs) ## [Documentation](https://rojo.space/docs/latest)
Documentation is hosted in the [rojo.space repository](https://github.com/rojo-rbx/rojo.space). If you find any mistakes, feel free to make changes in the [docs](https://github.com/rojo-rbx/rojo/tree/master/docs) folder of this repository and submit a pull request!
## Contributing ## Contributing
Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions for helping work on Rojo! Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions for helping work on Rojo!
Pull requests are welcome! Pull requests are welcome!
Rojo supports Rust 1.40.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. Rojo supports Rust 1.34.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

View File

@@ -1,181 +0,0 @@
* {
margin: 0;
padding: 0;
border: none;
text-decoration: inherit;
color: inherit;
font: inherit;
box-sizing: inherit;
line-height: inherit;
}
html {
box-sizing: border-box;
font-family: sans-serif;
font-size: 18px;
text-decoration: none;
line-height: 1.4;
}
img {
max-width:100%;
max-height:100%;
height: auto;
}
.path-list > li {
margin-left: 1.2em;
font-family: monospace;
}
.root {
display: flex;
flex-direction: column;
margin: 0.5rem auto;
width: 100%;
max-width: 50rem;
background-color: #efefef;
border: 1px solid #666;
border-radius: 4px;
}
.header {
flex: 0 0;
display: flex;
flex-wrap: wrap;
align-items: center;
border-bottom: 1px solid #666;
}
.main {
padding: 1rem;
}
.main-logo {
flex: 0 0 10rem;
margin: 1rem;
}
.stats {
flex: 0 0 20rem;
margin: 1rem;
}
.stat {
display: block;
}
.stat-name {
display: inline;
font-weight: bold;
}
.main-section:not(:last-of-type) {
margin-bottom: 1rem;
}
.section-title {
font-size: 1.8rem;
}
.button-list {
flex: 0 0;
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: -1rem;
}
.button {
display: inline-block;
border: 1px solid #666;
padding: 0.3em 1em;
margin: 1rem;
}
.instance {
margin-bottom: 0.5rem;
}
.instance-title {
font-size: 1.2rem;
padding: 0.5rem;
}
.expandable-section {
margin: 0.25rem 0.5rem;
}
.expandable-items {
padding: 0.5rem 1rem;
}
.expandable-input {
display: none;
}
.expandable-label > label {
cursor: pointer;
display: flex;
align-items: center;
align-content: center;
}
.expandable-input ~ .expandable-label .expandable-visualizer {
font-family: monospace;
display: inline-flex;
align-items: center;
align-content: center;
text-align: center;
width: 1rem;
height: 1rem;
font-size: 2rem;
margin: 0 0.5rem;
transition: transform 100ms ease-in-out;
transform-origin: 60% 60%;
}
.expandable-visualizer::before {
content: "";
font-weight: bold;
}
.expandable-input:checked ~ .expandable-label {
border-bottom: 1px solid #bbb;
}
.expandable-input:checked ~ .expandable-label .expandable-visualizer {
transform: rotate(90deg);
}
.expandable-input:not(:checked) ~ .expandable-items {
display: none;
}
.vfs-entry {
}
.vfs-entry-name {
position: relative;
font-family: monospace;
}
.vfs-entry-children .vfs-entry-name::before {
content: "";
width: 0.6em;
height: 1px;
background-color: #999;
position: absolute;
top: 50%;
left: -0.8em;
}
.vfs-entry-note {
font-style: italic;
}
.vfs-entry-children {
padding-left: 0.8em;
margin-left: 0.2em;
border-left: 1px solid #999;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View 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>

Binary file not shown.

View File

@@ -1,42 +0,0 @@
use std::path::Path;
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use tempfile::{tempdir, TempDir};
use librojo::cli::{build, BuildCommand};
pub fn benchmark_small_place(c: &mut Criterion) {
bench_build_place(c, "Small Place", "test-projects/benchmark_small_place")
}
criterion_group!(benches, benchmark_small_place);
criterion_main!(benches);
fn bench_build_place(c: &mut Criterion, name: &str, path: &str) {
let mut group = c.benchmark_group(name);
// 'rojo build' generally takes a fair bit of time to execute.
group.sample_size(10);
group.bench_function("build", |b| {
b.iter_batched(
|| place_setup(path),
|(_dir, options)| build(options).unwrap(),
BatchSize::SmallInput,
)
});
group.finish();
}
fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
let dir = tempdir().unwrap();
let input = input_path.as_ref().to_path_buf();
let output = dir.path().join("output.rbxlx");
let options = BuildCommand {
project: input,
output,
};
(dir, options)
}

View File

@@ -1,5 +0,0 @@
#!/bin/sh
set -e
watchexec -c -w plugin "sh -c './bin/install-dev-plugin.sh'"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
set -e
DIR="$( mktemp -d )"
PLUGIN_FILE="$DIR/Rojo.rbxm"
TESTEZ_FILE="$DIR/TestEZ.rbxm"
rojo build plugin -o "$PLUGIN_FILE"
rojo build plugin/testez.project.json -o "$TESTEZ_FILE"
remodel bin/mark-plugin-as-dev.lua "$PLUGIN_FILE" "$TESTEZ_FILE" 2>/dev/null
cp "$PLUGIN_FILE" "$LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm"

View File

@@ -1,5 +0,0 @@
#!/bin/sh
set -e
rojo build plugin -o "$LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm"

View File

@@ -1,12 +0,0 @@
local pluginPath, testezPath = ...
local plugin = remodel.readModelFile(pluginPath)[1]
local testez = remodel.readModelFile(testezPath)[1]
local marker = Instance.new("Folder")
marker.Name = "ROJO_DEV_BUILD"
marker.Parent = plugin
testez.Parent = plugin
remodel.writeModelFile(plugin, pluginPath)

View File

@@ -1,8 +0,0 @@
local pluginPath, placePath = ...
local plugin = remodel.readModelFile(pluginPath)[1]
local place = remodel.readPlaceFile(placePath)
plugin.Parent = place:GetService("ReplicatedStorage")
remodel.writePlaceFile(place, placePath)

View File

@@ -1,6 +0,0 @@
#!/bin/sh
set -e
./bin/run-cli-tests.sh
./bin/run-plugin-tests.sh

View File

@@ -1,9 +0,0 @@
#!/bin/sh
set -e
cargo test --all --locked
cargo fmt -- --check
touch src/lib.rs # Nudge Rust source to make Clippy actually check things
cargo clippy

View File

@@ -1,16 +0,0 @@
#!/bin/sh
set -e
DIR="$( mktemp -d )"
PLUGIN_FILE="$DIR/Rojo.rbxmx"
PLACE_FILE="$DIR/RojoTestPlace.rbxlx"
rojo build plugin -o "$PLUGIN_FILE"
rojo build plugin/place.project.json -o "$PLACE_FILE"
remodel bin/put-plugin-in-test-place.lua "$PLUGIN_FILE" "$PLACE_FILE"
run-in-roblox -s plugin/testBootstrap.server.lua "$PLACE_FILE"
luacheck plugin/src plugin/log plugin/http

21
bin/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"

View File

@@ -1,13 +0,0 @@
[package]
name = "clibrojo"
version = "0.1.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rojo = { path = ".." }

View File

@@ -1,19 +0,0 @@
# Rojo as a C Library
This is an experiment to expose a C API for Rojo that would be suitable for embedding it into an existing C/C++ application.
I'm hoping to expand it to drop the HTTP layer and communicate through a channel, which could make it feasible to embed into an existing Roblox IDE with minimal changes or additional code.
## Building
This project is currently not built by default and could break/disappear at any time.
```bash
cargo build -p clibrojo
```
On Windows, Cargo will generate a `clibrojo.dll` and associated `.lib` file. Link these into your project.
To generate the associated C header file to include in the project, use [cbindgen](https://github.com/eqrion/cbindgen):
```bash
cbindgen --crate clibrojo --output include/rojo.h
```

View File

@@ -1,14 +0,0 @@
use std::{ffi::CStr, os::raw::c_char, path::PathBuf};
use librojo::commands::{serve, ServeOptions};
#[no_mangle]
pub extern "C" fn rojo_serve(path: *const c_char) {
let path = unsafe { PathBuf::from(CStr::from_ptr(path).to_str().unwrap()) };
serve(&ServeOptions {
fuzzy_project_path: path,
port: None,
})
.unwrap();
}

View File

@@ -1,30 +0,0 @@
digraph Rojo {
concentrate = true;
node [fontname = "sans-serif"];
plugin [label="Roblox Studio Plugin"]
session [label="Session"]
rbx_tree [label="Instance Tree"]
imfs [label="In-Memory Filesystem"]
fs_impl [label="Filesystem Implementation\n(stubbed in tests)"]
fs [label="Real Filesystem"]
snapshot_subsystem [label="Snapshot Subsystem\n(reconciler)"]
snapshot_generator [label="Snapshot Generator"]
user_middleware [label="User Middleware\n(MoonScript, etc.)"]
builtin_middleware [label="Built-in Middleware\n(.lua, .rbxm, etc.)"]
api [label="Web API"]
file_watcher [label="File Watcher"]
session -> imfs
session -> rbx_tree
session -> snapshot_subsystem
session -> snapshot_generator
session -> file_watcher [dir="both"]
file_watcher -> imfs
snapshot_generator -> user_middleware
snapshot_generator -> builtin_middleware
plugin -> api [style="dotted"; dir="both"; minlen=2]
api -> session
imfs -> fs_impl
fs_impl -> fs
}

13
docs/extra.css Normal file
View File

@@ -0,0 +1,13 @@
.md-typeset__table {
width: 100%;
}
.feature-image img {
border: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
}
.codehilite {
border: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
}

View File

@@ -0,0 +1,11 @@
**This page is under construction!**
## Summary
* Tools to port existing games are in progress!
* [rbxlx-to-rojo](https://github.com/rojo-rbx/rbxlx-to-rojo)
* `rojo export` ([issue #208](https://github.com/rojo-rbx/rojo/issues/208))
* Can port as much or as little of your game as you like
* Rojo can manage just a slice of your game!
* Some Roblox idioms aren't very well supported
* Redundant copies of scripts don't work well with files
* Having only a couple places with scripts simplifies your project dramatically!

View File

@@ -0,0 +1,48 @@
This is this installation guide for Rojo **0.5.x**.
[TOC]
## Overview
Rojo has two components:
* The command line interface (CLI)
* The Roblox Studio plugin
!!! info
It's important that your installed version of the plugin and CLI are compatible.
The plugin will show errors in the Roblox Studio output window if there is a version mismatch.
## Visual Studio Code Extension
If you use Visual Studio Code, you can install [the Rojo VS Code 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 sync files and start/stop the Rojo server!
## Installing the CLI
### Installing from GitHub
If you're on Windows, there are pre-built binaries available from Rojo's [GitHub Releases page](https://github.com/LPGhatguy/rojo/releases).
The Rojo CLI must be run from the command line, like Terminal.app on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo CLI executable on your `PATH` to make this easier.
### Installing from Cargo
If you have Rust installed, the easiest way to get Rojo is with Cargo!
To install the latest 0.5.x release, use:
```sh
cargo install rojo
```
If you're upgrading from a previous version of Rojo, you may need to pass `--force` to tell Cargo to overwrite your existing version.
## Installing the Plugin
### Installing from GitHub
The Rojo Roblox Studio plugin is available from Rojo's [GitHub Releases page](https://github.com/LPGhatguy/rojo/releases).
Download the attached `rbxm` file and put it into your Roblox Studio plugins folder. You can find that folder by pressing **Plugins Folder** from your Plugins toolbar in Roblox Studio:
!['Plugins Folder' button in Roblox Studio](../images/plugins-folder-in-studio.png)
{: align="center" }
### Installing from Roblox.com
Visit [Rojo's Roblox.com Plugin page](https://www.roblox.com/library/1997686364) in Roblox Studio and press **Install**.

View File

@@ -0,0 +1,63 @@
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.
[TOC]
## 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 `default.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 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.
## Migrating `init.model.json` files
In Rojo 0.4.x, it's possible to create a file named `init.model.json` that lets you describe a model that becomes the container for all of the other files in the folder, just like `init.lua`.
In Rojo 0.5.x, this feature has been replaced with `init.meta.json` files. See [Sync Details](../../reference/sync-details) for more information about these new files.

90
docs/guide/new-game.md Normal file
View File

@@ -0,0 +1,90 @@
[TOC]
## Creating the Rojo Project
To use Rojo to build a game, you'll need to create a new project file, which tells Rojo how to turn your files into a Roblox place.
First, create a new folder to contain the files for your game and open up a new terminal inside of it, like cmd.exe or Bash.
It's convenient to make the folder from the command line:
```sh
mkdir my-new-project
cd my-new-project
```
Inside the folder, initialize a new Rojo project:
```sh
rojo init
```
Rojo will make a small project file in your directory, named `default.project.json`. It matches the "Baseplate" template from Roblox Studio, except that it'll take any files you put in a folder called `src` and put it into `ReplicatedStorage.Source`.
Speaking of files, make sure to create a directory named `src` in this folder, or Rojo will be upset about missing files!
```sh
mkdir src
```
Let's also add a Lua file, `hello.lua` to the `src` folder, so that we can make this project our own.
```sh
echo 'return "Hello, Rojo!"' > 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.rbxlx
```
If you open `MyNewProject.rbxlx` in Roblox Studio now, you should see a `Folder` named "Source" containing a `ModuleScript` under `ReplicatedStorage`.
!!! info
To generate a binary place file instead, use `rbxl`. Note that support for binary model/place files (`rbxm` and `rbxl`) is very limited in Rojo presently.
## Live-Syncing into Studio
Building a place file is great for starting to work on a game, but for active iteration, you'll want something faster.
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 start a new **live sync session** from the command line:
```sh
rojo serve
```
You should see output like this in your terminal:
```sh
$ rojo serve
Rojo server listening on port 34872
```
Switch into Roblox Studio and press the **Connect** button on the Rojo plugin toolbar. A dialog should appear:
![Rojo plugin connection dialog](../images/connection-dialog.png)
{: class="feature-image" align="center" }
If the port number doesn't match the output from the command line, change it, and then press **Connect**.
If all 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 game on Roblox.com as well as the `.ROBLOSECURITY` cookie of an account that has write access to that game.
!!! warning
It's recommended that you set up a Roblox account dedicated to deploying your game instead of your personal account in case your security cookie is compromised.
Generating and publishing your game is as simple as:
```sh
rojo upload --asset_id [PLACE ID] --cookie "[SECURITY COOKIE]"
```
An example project is available on GitHub that deploys to Roblox.com from GitHub and Travis-CI automatically: [https://github.com/LPGhatguy/roads](https://github.com/LPGhatguy/roads)

7
docs/help.md Normal file
View File

@@ -0,0 +1,7 @@
Rojo is a fairly complex tool to adopt, but there's a community willing to help!
The [Roblox Open Source Community Discord](https://discord.gg/wH5ncNS) currently hosts a Rojo support channel, **#rojo**, that is a great place to get help as problems come up.
If you find anything that looks like a bug or have ideas for how to improve Rojo, feel free to file an issue on [Rojo's GitHub issue tracker](https://github.com/rojo-rbx/rojo/issues).
Rojo's primary maintainer is also available on Twitter, [@LPGhatguy](https://twitter.com/LPGhatguy).

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 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

11
docs/index.md Normal file
View File

@@ -0,0 +1,11 @@
This is the documentation home for **Rojo 0.5.x**.
Available versions of these docs:
* [Latest version from `master` branch](https://rojo.space/docs/latest)
* [0.5.x](https://rojo.space/docs/0.5.x)
* [0.4.x](https://rojo.space/docs/0.4.x)
**Rojo** is a tool designed to enable Roblox developers to use professional-grade software engineering tools.
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/rojo-rbx/rojo/issues)!

View File

@@ -0,0 +1,45 @@
This document aims to give a general overview of how Rojo works. It's intended for people who want to contribute to the project as well as anyone who's just curious how the tool works!
[TOC]
## CLI
### RbxTree
Rojo uses a library named [`rbx_tree`](https://github.com/LPGhatguy/rbx-tree) as its implementation of the Roblox DOM. It serves as a common format for serialization to all the formats Rojo supports!
Rojo uses two related libraries to deserialize instances from Roblox's file formats, `rbx_xml` and `rbx_binary`.
### In-Memory Filesystem (IMFS)
Relevant source files:
* [`server/src/imfs.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/imfs.rs)
* [`server/src/fs_watcher.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/fs_watcher.rs)
Rojo keeps an in-memory copy of all files that it needs reasons about. This enables taking fast, stateless, tear-tree snapshots of files to turn them into instances.
Keeping an in-memory copy of file contents will also enable Rojo to debounce changes that are caused by Rojo itself. This'll happen when two-way sync finally happens.
### Snapshot Reconciler
Relevant source files:
* [`server/src/snapshot_reconciler.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/snapshot_reconciler.rs)
* [`server/src/rbx_snapshot.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/rbx_snapshot.rs)
* [`server/src/rbx_session.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/rbx_session.rs)
To simplify incremental updates of instances, Rojo generates lightweight snapshots describing how files map to instances. This means that Rojo can treat file change events similarly to damage painting as opposed to trying to surgically update the correct instances.
This approach reduces the number of desynchronization bugs, reduces the complexity of important pieces of the codebase, and makes writing plugins a lot easier.
### HTTP API
Relevant source files:
* [`server/src/web.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/web.rs)
The Rojo live-sync server and Roblox Studio plugin communicate via HTTP.
Requests sent from the plugin to the server are regular HTTP requests.
Messages sent from the server to the plugin are delivered via HTTP long-polling. This is an approach that uses long-lived HTTP requests that restart on timeout. It's largely been replaced by WebSockets, but Roblox doesn't have support for them.
## Roblox Studio Plugin
TODO

View File

@@ -0,0 +1,37 @@
Rojo is designed to be adopted incrementally. How much of your project Rojo manages is up to you!
There are two primary categories of ways to use Rojo: *Fully Managed*, where everything is managed by Rojo, and *Partially Managed*, where Rojo only manages a slice of your project.
## Fully Managed
In a fully managed game project, Rojo controls every instance. A fully managed Rojo project can be built from scratch using `rojo build`.
Fully managed projects are most practical for libraries, plugins, and simple games.
Rojo's goal is to make it practical and easy for _every_ project to be fully managed, but we're not quite there yet!
### Pros
* Fully reproducible builds from scratch
* Everything checked into version control
### Cons
* Without two-way sync, models have to be saved manually
* This can be done with the 'Save to File...' menu in Roblox Studio
* This will be solved by Two-Way Sync ([issue #164](https://github.com/LPGhatguy/rojo/issues/164))
* Rojo can't manage everything yet
* Refs are currently broken ([issue #142](https://github.com/LPGhatguy/rojo/issues/142))
## Partially Managed
In a partially managed project, Rojo only handles a slice of the game. This could be as small as a couple scripts, or as large as everything except `Workspace`!
The rest of the place's content can be versioned using Team Create or checked into source control.
Partially managed projects are most practical for complicated games, or games that are migrating to use Rojo.
### Pros
* Easier to adopt gradually
* Integrates with Team Create
### Cons
* Not everything is in version control, which makes merges tougher
* Rojo can't live-sync instances like Terrain, MeshPart, or CSG operations yet
* Will be fixed with plugin escalation ([issue #169](https://github.com/LPGhatguy/rojo/issues/169))

View File

@@ -0,0 +1,151 @@
[TOC]
## Project File
Rojo projects are JSON files that have the `.project.json` extension. They have the following fields:
* `name`: A string indicating the name of the project. This name is used when building the project into a model or place file.
* **Required**
* `tree`: An [Instance Description](#instance-description) describing the root instance of the project.
* **Required**
* `servePort`: The port that `rojo serve` should listen on. Passing `--port` will override this setting.
* **Optional**
* Default is `34872`
* `servePlaceIds`: A list of place IDs that this project may be live-synced to. This feature can help prevent overwriting the wrong game with source from Rojo.
* **Optional**
* Default is `null`
## Instance Description
Instance Descriptions correspond one-to-one with the actual Roblox Instances in the project.
* `$className`: The ClassName of the Instance being described.
* **Optional if `$path` is specified.**
* `$path`: The path on the filesystem to pull files from into the project.
* **Optional if `$className` is specified.**
* Paths are relative to the folder containing the project file.
* `$properties`: Properties to apply to the instance. Values should be [Instance Property Values](#instance-property-value).
* **Optional**
* `$ignoreUnknownInstances`: Whether instances that Rojo doesn't know about should be deleted.
* **Optional**
* Default is `false` if `$path` is specified, otherwise `true`.
All other fields in an Instance Description are turned into instances whose name is the key. These values should also be Instance Descriptions!
Instance Descriptions are fairly verbose and strict. In the future, it'll be possible for Rojo to [infer class names for known services like `Workspace`](https://github.com/LPGhatguy/rojo/issues/179).
## Instance Property Value
There are two kinds of property values on instances, **implicit** and **explicit**.
In the vast majority of cases, you should be able to use **implicit** property values. To use them, just use a value that's the same shape as the type that the property has:
```json
"MyPart": {
"$className": "Part",
"$properties": {
"Size": [3, 5, 3],
"Color": [0.5, 0, 0.5],
"Anchored": true,
"Material": "Granite"
}
}
```
`Vector3` and `Color3` properties can just be arrays of numbers, as can types like `Vector2`, `CFrame`, and more!
Enums can be set to a string containing the enum variant. Rojo will raise an error if the string isn't a valid variant for the enum.
There are some cases where this syntax for assigning properties _doesn't_ work. In these cases, Rojo requires you to use the **explicit** property syntax.
Some reasons why you might need to use an **explicit** property:
* Using exotic property types like `BinaryString`
* Using properties added to Roblox recently that Rojo doesn't know about yet
The shape of explicit property values is defined by the [rbx-dom](https://github.com/LPGhatguy/rbx-dom) library, so it uses slightly different conventions than the rest of Rojo.
Each value should be an object with the following required fields:
* `Type`: The type of property to represent.
* [Supported types can be found here](https://github.com/LPGhatguy/rbx-tree#property-type-coverage).
* `Value`: The value of the property.
* The shape of this field depends on which property type is being used. `Vector3` and `Color3` values are both represented as a list of numbers, while `BinaryString` expects a base64-encoded string, for example.
Here's the same object, but with explicit properties:
```json
"MyPart": {
"$className": "Part",
"$properties": {
"Size": {
"Type": "Vector3",
"Value": [3, 5, 3]
},
"Color": {
"Type": "Color3",
"Value": [0.5, 0, 0.5]
},
"Anchored": {
"Type": "Bool",
"Value": true
},
"Material": {
"Type": "Enum",
"Value": 832
}
}
}
```
## Example Projects
This project bundles up everything in the `src` directory. It'd be suitable for making a plugin or model:
```json
{
"name": "AwesomeLibrary",
"tree": {
"$path": "src"
}
}
```
This project describes the layout you might use if you were making the next hit simulator game, *Sisyphus Simulator*:
```json
{
"name": "Sisyphus Simulator",
"tree": {
"$className": "DataModel",
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
},
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"$path": "src/ReplicatedStorage"
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"$path": "src/StarterPlayerScripts"
}
},
"Workspace": {
"$className": "Workspace",
"$properties": {
"Gravity": 67.3
},
"Terrain": {
"$path": "Terrain.rbxm"
}
}
}
}
```

View File

@@ -0,0 +1,157 @@
This page aims to describe how Rojo turns files on the filesystem into Roblox objects.
[TOC]
## Overview
| File Name | Instance Type |
| -------------- | ------------------------- |
| any directory | `Folder` |
| `*.server.lua` | `Script` |
| `*.client.lua` | `LocalScript` |
| `*.lua` | `ModuleScript` |
| `*.csv` | `LocalizationTable` |
| `*.txt` | `StringValue` |
| `*.model.json` | Any |
| `*.rbxm` | Any |
| `*.rbxmx` | Any |
| `*.meta.json` | Modifies another instance |
## 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-dom's type coverage chart](https://github.com/rojo-rbx/rbx-dom#property-type-coverage).
This limitation may be solved by [issue #205](https://github.com/rojo-rbx/rojo/issues/205) in the future.
## 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:
![Tree of files on disk](../images/sync-example-files.svg)
{: align="center" }
Will turn into these instances in Roblox:
![Tree of instances in Roblox](../images/sync-example-instances.svg)
{: align="center" }
## 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.
## 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:
![Tree of instances in Roblox](../images/sync-example-json-model.svg)
{: align="center" }
!!! warning
Starting in Rojo 0.5.0 (stable), the `Name` field is no longer required. The name of the top-level instance in a JSON model is now based on its file name, and the `Name` field is now ignored.
Rojo will emit a warning if the `Name` field is specified and does not match the file's name.
## Binary and XML Models
Rojo supports both binary (`.rbxm`) and XML (`.rbxmx`) models generated by Roblox Studio or another tool.
Support for the `rbxmx` is very good, while support for `rbxm` is still very early, buggy, and lacking features.
For a rundown of supported types, check out [rbx-dom's type coverage chart](https://github.com/rojo-rbx/rbx-dom#property-type-coverage).
## Meta Files
New in Rojo 0.5.0-alpha.12 are meta files, named `.meta.json`.
Meta files allow attaching extra Rojo data to models defined in other formats, like Roblox's `rbxm` and `rbxmx` model formats, or even Lua scripts.
This can be used to set Rojo-specific settings like `ignoreUnknownInstances`, or can be used to set properties like `Disabled` on a script.
Meta files can contain:
* `className`: Changes the `className` of a containing `Folder` into something else.
* Usable only in `init.meta.json` files
* `properties`: A map of properties to set on the instance, just like projects
* Usable on anything except `.rbxmx`, `.rbxm`, and `.model.json` files, which already have properties
* `ignoreUnknownInstances`: Works just like `$ignoreUnknownInstances` in project files
### Meta Files to set Rojo metadata
Sometimes it's useful to apply properties like `ignoreUnknownInstances` on instances that are defined on the filesystem instead of within the project itself.
If your project has `hello.txt` and there are instances underneath it that you want Rojo to ignore when live-syncing, you could create `hello.meta.json` with:
```json
{
"ignoreUnknownInstances": true
}
```
### Meta Files for Disabled Scripts
Meta files can be used to set properties on `Script` instances, like `Disabled`.
If your project has `foo.server.lua` and you want to make sure it would be disabled, you could create a `foo.meta.json` next to it with:
```json
{
"properties": {
"Disabled": true
}
}
```
### Meta Files for Tools
If you wanted to represent a tool containing a script and a model for its handle, create a directory with an `init.meta.json` file in it:
```json
{
"className": "Tool",
"properties": {
"Grip": [
0, 0, 0,
1, 0, 0,
0, 1, 0,
0, 0, 1
]
}
}
```
Instead of a `Folder` instance, you'll end up with a `Tool` instance with the `Grip` property set!

3
docs/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
mkdocs
mkdocs-material
pymdown-extensions

23
docs/rojo-alternatives.md Normal file
View File

@@ -0,0 +1,23 @@
There are a number of existing plugins for Roblox that move code from the filesystem into Roblox.
Besides Rojo, you might consider:
* [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)
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 this 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.

44
docs/why-rojo.md Normal file
View File

@@ -0,0 +1,44 @@
Adding a tool like Rojo to your Roblox workflow can be daunting, but it comes with some key advantages.
[TOC]
## Rojo at RDC 2019
Nathan Riemer (Kampfkarren) gave a talk at RDC 2019 talking about some of the benefits of using a tool like Rojo.
<iframe style="margin: 0 auto; max-width: 100%" width="560" height="315" src="https://www.youtube-nocookie.com/embed/czlvzEyhaBc" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## External Text Editors
Rojo opens the door to use the absolute best text editors in the world and their rich plugin ecosystems.
Some very popular editors include [Visual Studio Code](https://code.visualstudio.com) and [Sublime Text](https://www.sublimetext.com).
These advanced text editors have features like multi-cursor editing, goto symbol, multi-file regex find and replace, bookmarks and much more.
Many Rojo VS Code users also use extensions like:
* [vscode-rbxlua](https://marketplace.visualstudio.com/items?itemName=AmaranthineCodices.vscode-rbxlua)
* [Roblox Lua Autocompletes](https://marketplace.visualstudio.com/items?itemName=Kampfkarren.roblox-lua-autofills)
* [TabNine](https://tabnine.com)
## Version Control
By building your game (or just the scripts) as individual files on the filesystem, it becomes easy to start using professional-grade version control tools like [Git](https://git-scm.com) and [GitHub](https://github.com).
Hundreds of thousands of companies and individual developers use Git to version their software projects. With Rojo, Roblox developers can take advantage of the best collaboration tool around.
Using a repository hosting service like GitHub or GitLab brings powerful features to Roblox developers like code reviews and issue tracking that professional engineers can't live without.
## TypeScript
TypeScript enables static type safety, which helps prevent typos and adds unparalleled autocompletion. It also brings features like arrow functions, object destructuring, functional programming methods, and more!
With Rojo, you can use [roblox-ts](https://roblox-ts.github.io) to compile TypeScript to Lua and take advantage of a huge ecosystem of TypeScript tooling.
It's also possible to use other languages that compile to Lua like [MoonScript](https://moonscript.org) and [Haxe](https://haxe.org).
## Other Tools
There are decades of excellent tools available that operate on files. With Rojo, it's possible to take advantage of any of them!
Popular tools include:
* [luacheck](https://github.com/mpeterv/luacheck), a static analysis tool to help you write better Lua
* [ripgrep](https://github.com/BurntSushi/ripgrep), an extremely fast code search tool
* [Tokei](https://github.com/XAMPPRocky/tokei), a tool for statistics like lines of code

37
mkdocs.yml Normal file
View File

@@ -0,0 +1,37 @@
site_name: Rojo Documentation
repo_name: rojo-rbx/rojo
repo_url: https://github.com/rojo-rbx/rojo
theme:
name: material
palette:
primary: 'Red'
accent: 'Red'
nav:
- Home: index.md
- Why Rojo?: why-rojo.md
- Get Help with Rojo: help.md
- Guide:
- Installation: guide/installation.md
- Creating a Game with Rojo: guide/new-game.md
- Porting an Existing Game to Rojo: guide/existing-game.md
- Migrating from 0.4.x to 0.5.x: guide/migrating-to-epiphany.md
- Reference:
- Fully vs Partially Managed Rojo: reference/full-vs-partial.md
- Project Format: reference/project-format.md
- Sync Details: reference/sync-details.md
- Rojo Alternatives: rojo-alternatives.md
- Rojo Internals:
- Internals Overview: internals/overview.md
extra_css:
- extra.css
markdown_extensions:
- attr_list
- admonition
- codehilite:
guess_lang: false
- toc:
permalink: true

1
plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/luacov.*

View File

@@ -20,7 +20,6 @@ stds.roblox = {
"CFrame", "CFrame",
"Enum", "Enum",
"Instance", "Instance",
"DockWidgetPluginGuiInfo",
} }
} }

8
plugin/.luacov Normal file
View File

@@ -0,0 +1,8 @@
return {
include = {
"^src",
},
exclude = {
"%.spec$",
},
}

View File

@@ -5,15 +5,6 @@
"Plugin": { "Plugin": {
"$path": "src" "$path": "src"
}, },
"Log": {
"$path": "log"
},
"Http": {
"$path": "http"
},
"Fmt": {
"$path": "fmt"
},
"Roact": { "Roact": {
"$path": "modules/roact/src" "$path": "modules/roact/src"
}, },

View File

@@ -1,245 +0,0 @@
--[[
This library describes a formatting mechanism akin to Rust's std::fmt.
It has a couple building blocks:
* A new syntax for formatting strings, taken verbatim from Rust. It'd also
be possible to use printf-style formatting specifiers to integrate with
the existing string.format utility.
* An equivalent to Rust's `Display` trait. We're mapping the semantics of
tostring and the __tostring metamethod onto this trait. A lot of types
should already have __tostring implementations, too!
* An equivalent to Rust's `Debug` trait. This library Lua-ifies that idea by
inventing a new metamethod, `__fmtDebug`. We pass along the "extended
form" attribute which is the equivalent of the "alternate mode" in Rust's
Debug trait since it's the author's opinion that treating it as a
verbosity flag is semantically accurate.
]]
--[[
The default implementation of __fmtDebug for tables when the extended option
is not set.
]]
local function defaultTableDebug(buffer, input)
buffer:writeRaw("{")
for key, value in pairs(input) do
buffer:write("[{:?}] = {:?}", key, value)
if next(input, key) ~= nil then
buffer:writeRaw(", ")
end
end
buffer:writeRaw("}")
end
--[[
The default implementation of __fmtDebug for tables with the extended option
set.
]]
local function defaultTableDebugExtended(buffer, input)
-- Special case for empty tables.
if next(input) == nil then
buffer:writeRaw("{}")
return
end
buffer:writeLineRaw("{")
buffer:indent()
for key, value in pairs(input) do
buffer:writeLine("[{:?}] = {:#?},", key, value)
end
buffer:unindent()
buffer:writeRaw("}")
end
--[[
The default debug representation for all types.
]]
local function debugImpl(buffer, value, extendedForm)
local valueType = typeof(value)
if valueType == "string" then
local formatted = string.format("%q", value)
buffer:writeRaw(formatted)
elseif valueType == "table" then
local valueMeta = getmetatable(value)
if valueMeta ~= nil and valueMeta.__fmtDebug ~= nil then
-- This type implement's the metamethod we made up to line up with
-- Rust's 'Debug' trait.
valueMeta.__fmtDebug(value, buffer, extendedForm)
else
if extendedForm then
defaultTableDebugExtended(buffer, value)
else
defaultTableDebug(buffer, value)
end
end
elseif valueType == "Instance" then
buffer:writeRaw(value:GetFullName())
else
buffer:writeRaw(tostring(value))
end
end
--[[
Defines and implements the library's template syntax.
]]
local function writeFmt(buffer, template, ...)
local currentArg = 0
local i = 1
local len = #template
while i <= len do
local openBrace = template:find("{", i)
if openBrace == nil then
-- There are no remaining open braces in this string, so we can
-- write the rest of the string to the buffer.
buffer:writeRaw(template:sub(i))
break
else
-- We found an open brace! This could be:
-- - A literal '{', written as '{{'
-- - The beginning of an interpolation, like '{}'
-- - An error, if there's no matching '}'
local charAfterBrace = template:sub(openBrace + 1, openBrace + 1)
if charAfterBrace == "{" then
-- This is a literal brace, so we'll write everything up to this
-- point (including the first brace), and then skip over the
-- second brace.
buffer:writeRaw(template:sub(i, openBrace))
i = openBrace + 2
else
-- This SHOULD be an interpolation. We'll find our matching
-- brace and treat the contents as the formatting specifier.
-- If there were any unwritten characters before this
-- interpolation, write them to the buffer.
if openBrace - i > 0 then
buffer:writeRaw(template:sub(i, openBrace - 1))
end
local closeBrace = template:find("}", openBrace + 1)
assert(closeBrace ~= nil, "Unclosed formatting specifier. Use '{{' to write an open brace.")
local formatSpecifier = template:sub(openBrace + 1, closeBrace - 1)
currentArg = currentArg + 1
local arg = select(currentArg, ...)
if formatSpecifier == "" then
-- This should use the equivalent of Rust's 'Display', ie
-- tostring and the __tostring metamethod.
buffer:writeRaw(tostring(arg))
elseif formatSpecifier == ":?" then
-- This should use the equivalent of Rust's 'Debug',
-- invented for this library as __fmtDebug.
debugImpl(buffer, arg, false)
elseif formatSpecifier == ":#?" then
-- This should use the equivlant of Rust's 'Debug' with the
-- 'alternate' (ie expanded) flag set.
debugImpl(buffer, arg, true)
else
error("unsupported format specifier " .. formatSpecifier, 2)
end
i = closeBrace + 1
end
end
end
end
local function debugOutputBuffer()
local buffer = {}
local startOfLine = true
local indentLevel = 0
local indentation = ""
function buffer:writeLine(template, ...)
writeFmt(self, template, ...)
self:nextLine()
end
function buffer:writeLineRaw(value)
self:writeRaw(value)
self:nextLine()
end
function buffer:write(template, ...)
return writeFmt(self, template, ...)
end
function buffer:writeRaw(value)
if #value > 0 then
if startOfLine and #indentation > 0 then
startOfLine = false
table.insert(self, indentation)
end
table.insert(self, value)
startOfLine = false
end
end
function buffer:nextLine()
table.insert(self, "\n")
startOfLine = true
end
function buffer:indent()
indentLevel = indentLevel + 1
indentation = string.rep(" ", indentLevel)
end
function buffer:unindent()
indentLevel = math.max(0, indentLevel - 1)
indentation = string.rep(" ", indentLevel)
end
function buffer:finish()
return table.concat(self, "")
end
return buffer
end
local function fmt(template, ...)
local buffer = debugOutputBuffer()
writeFmt(buffer, template, ...)
return buffer:finish()
end
--[[
Wrap the given object in a type that implements the given function as its
Debug implementation, and forwards __tostring to the type's underlying
tostring implementation.
]]
local function debugify(object, fmtFunc)
return setmetatable({}, {
__fmtDebug = function(_, ...)
return fmtFunc(object, ...)
end,
__tostring = function()
return tostring(object)
end,
})
end
return {
debugOutputBuffer = debugOutputBuffer,
fmt = fmt,
debugify = debugify,
}

View File

@@ -1,66 +0,0 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Promise)
local Log = require(script.Parent.Log)
local HttpError = require(script.Error)
local HttpResponse = require(script.Response)
local lastRequestId = 0
local Http = {}
Http.Error = HttpError
Http.Response = HttpResponse
local function performRequest(requestParams)
local requestId = lastRequestId + 1
lastRequestId = requestId
Log.trace("HTTP {}({}) {}", requestParams.Method, requestId, requestParams.Url)
if requestParams.Body ~= nil then
Log.trace("{}", requestParams.Body)
end
return Promise.new(function(resolve, reject)
coroutine.wrap(function()
local success, response = pcall(function()
return HttpService:RequestAsync(requestParams)
end)
if success then
Log.trace("Request {} success, status code {}", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
else
Log.trace("Request {} failure: {:?}", requestId, response)
reject(HttpError.fromRobloxErrorString(response))
end
end)()
end)
end
function Http.get(url)
return performRequest({
Url = url,
Method = "GET",
})
end
function Http.post(url, body)
return performRequest({
Url = url,
Method = "POST",
Body = body,
})
end
function Http.jsonEncode(object)
return HttpService:JSONEncode(object)
end
function Http.jsonDecode(source)
return HttpService:JSONDecode(source)
end
return Http

View File

@@ -1,5 +0,0 @@
return function()
it("should load", function()
require(script.Parent)
end)
end

View 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

View File

@@ -1,5 +0,0 @@
return function()
it("should load", function()
require(script.Parent)
end)
end

1
plugin/modules/lemur Submodule

Submodule plugin/modules/lemur added at 96d4166a2d

48
plugin/place.project.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "rojo",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Rojo": {
"$className": "Folder",
"Plugin": {
"$path": "src"
},
"Roact": {
"$path": "modules/roact/src"
},
"Promise": {
"$path": "modules/promise/lib"
},
"t": {
"$path": "modules/t/lib"
}
},
"TestEZ": {
"$path": "modules/testez/lib"
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": {
"Type": "Bool",
"Value": true
}
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"TestBootstrap": {
"$path": "testBootstrap.server.lua"
}
}
}
}

15
plugin/runTest.lua Normal file
View File

@@ -0,0 +1,15 @@
local loadEnvironment = require("loadEnvironment")
local testPath = assert((...), "Please specify a path to a test file.")
local habitat = loadEnvironment()
local testModule = habitat:loadFromFs(testPath)
if testModule == nil then
error("Couldn't find test file at " .. testPath)
end
print("Starting test module.")
habitat:require(testModule)

17
plugin/spec.lua Normal file
View File

@@ -0,0 +1,17 @@
--[[
Loads our library and all of its dependencies, then runs tests using TestEZ.
]]
local loadEnvironment = require("loadEnvironment")
local habitat, modules = loadEnvironment()
-- Load TestEZ and run our tests
local TestEZ = habitat:require(modules.TestEZ)
local results = TestEZ.TestBootstrap:run({modules.Rojo}, TestEZ.Reporters.TextReporter)
-- Did something go wrong?
if results.failureCount > 0 then
os.exit(1)
end

View File

@@ -1,216 +1,152 @@
local Http = require(script.Parent.Parent.Http)
local Log = require(script.Parent.Parent.Log)
local Promise = require(script.Parent.Parent.Promise) local Promise = require(script.Parent.Parent.Promise)
local Config = require(script.Parent.Config) local Config = require(script.Parent.Config)
local Types = require(script.Parent.Types)
local Version = require(script.Parent.Version) local Version = require(script.Parent.Version)
local Http = require(script.Parent.Http)
local HttpError = require(script.Parent.HttpError)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse) local ApiContext = {}
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) ApiContext.__index = ApiContext
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
--[[ -- TODO: Audit cases of errors and create enum values for each of them.
Returns a promise that will never resolve nor reject. ApiContext.Error = {
]] ServerIdMismatch = "ServerIdMismatch",
local function hangingPromise()
return Promise.new(function() end) -- The server gave an unexpected 400-category error, which may be the
end -- client's fault.
ClientError = "ClientError",
-- The server gave an unexpected 500-category error, which may be the
-- server's fault.
ServerError = "ServerError",
}
setmetatable(ApiContext.Error, {
__index = function(_, key)
error("Invalid ApiContext.Error name " .. key, 2)
end
})
local function rejectFailedRequests(response) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) if response.code < 500 then
return Promise.reject(ApiContext.Error.ClientError)
return Promise.reject(message) else
return Promise.reject(ApiContext.Error.ServerError)
end
end end
return response return response
end end
local function rejectWrongProtocolVersion(infoResponseBody)
if infoResponseBody.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/rojo-rbx/rojo for more details."
):format(
Version.display(Config.version), Config.protocolVersion,
Config.expectedServerVersionString,
infoResponseBody.serverVersion, infoResponseBody.protocolVersion
)
return Promise.reject(message)
end
return Promise.resolve(infoResponseBody)
end
local function rejectWrongPlaceId(infoResponseBody)
if infoResponseBody.expectedPlaceIds ~= nil then
local foundId = false
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
if id == game.PlaceId then
foundId = true
break
end
end
if not foundId then
local idList = {}
for _, id in ipairs(infoResponseBody.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 your .project.json file."
):format(
tostring(game.PlaceId),
table.concat(idList, "\n")
)
return Promise.reject(message)
end
end
return Promise.resolve(infoResponseBody)
end
local ApiContext = {}
ApiContext.__index = ApiContext
function ApiContext.new(baseUrl) function ApiContext.new(baseUrl)
assert(type(baseUrl) == "string") assert(type(baseUrl) == "string")
local self = { local self = {
__baseUrl = baseUrl, baseUrl = baseUrl,
__sessionId = nil, serverId = nil,
__messageCursor = -1, rootInstanceId = nil,
__connected = true, messageCursor = -1,
partitionRoutes = nil,
} }
return setmetatable(self, ApiContext) setmetatable(self, ApiContext)
return self
end end
function ApiContext:__fmtDebug(output) function ApiContext:onMessage(callback)
output:writeLine("ApiContext {{") self.onMessageCallback = callback
output:indent()
output:writeLine("Connected: {}", self.__connected)
output:writeLine("Base URL: {}", self.__baseUrl)
output:writeLine("Session ID: {}", self.__sessionId)
output:writeLine("Message Cursor: {}", self.__messageCursor)
output:unindent()
output:write("}")
end
function ApiContext:disconnect()
self.__connected = false
end
function ApiContext:setMessageCursor(index)
self.__messageCursor = index
end end
function ApiContext:connect() function ApiContext:connect()
local url = ("%s/api/rojo"):format(self.__baseUrl) local url = ("%s/api/rojo"):format(self.baseUrl)
return Http.get(url) return Http.get(url)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(function(response)
:andThen(rejectWrongProtocolVersion) local body = response:json()
:andThen(function(body)
assert(validateApiInfo(body))
return body if body.protocolVersion ~= Config.protocolVersion then
end) local message = (
:andThen(rejectWrongPlaceId) "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
:andThen(function(body) "\nMake sure you have matching versions of both the Rojo plugin and server!" ..
self.__sessionId = body.sessionId "\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 body 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)
end end
function ApiContext:read(ids) function ApiContext:read(ids)
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/read/%s"):format(self.baseUrl, table.concat(ids, ","))
return Http.get(url) return Http.get(url)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(function(response)
:andThen(function(body) local body = response:json()
if body.sessionId ~= self.__sessionId then
if body.serverId ~= self.serverId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiRead(body))
return body return body
end) end)
end end
function ApiContext:write(patch) function ApiContext:retrieveMessages(initialCursor)
local url = ("%s/api/write"):format(self.__baseUrl) if initialCursor ~= nil then
self.messageCursor = initialCursor
local updated = {}
for _, update in ipairs(patch.updated) do
local fixedUpdate = {
id = update.id,
changedName = update.changedName,
}
if next(update.changedProperties) ~= nil then
fixedUpdate.changedProperties = update.changedProperties
end
table.insert(updated, fixedUpdate)
end end
-- Only add the 'added' field if the table is non-empty, or else Roblox's local url = ("%s/api/subscribe/%s"):format(self.baseUrl, self.messageCursor)
-- JSON implementation will turn the table into an array instead of an
-- object, causing API validation to fail.
local added
if next(patch.added) ~= nil then
added = patch.added
end
local body = {
sessionId = self.__sessionId,
removed = patch.removed,
updated = updated,
added = added,
}
body = Http.jsonEncode(body)
return Http.post(url, body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
Log.info("Write response: {:?}", body)
return body
end)
end
function ApiContext:retrieveMessages()
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
local function sendRequest() local function sendRequest()
return Http.get(url) return Http.get(url)
:catch(function(err) :catch(function(err)
if err.type == Http.Error.Kind.Timeout then if err.type == HttpError.Error.Timeout then
if self.__connected then return sendRequest()
return sendRequest()
else
return hangingPromise()
end
end end
return Promise.reject(err) return Promise.reject(err)
@@ -219,15 +155,14 @@ function ApiContext:retrieveMessages()
return sendRequest() return sendRequest()
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(function(response)
:andThen(function(body) local body = response:json()
if body.sessionId ~= self.__sessionId then
if body.serverId ~= self.serverId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiSubscribe(body)) self.messageCursor = body.messageCursor
self:setMessageCursor(body.messageCursor)
return body.messages return body.messages
end) end)

View File

@@ -1,7 +1,11 @@
local strict = require(script.Parent.strict)
local Assets = { local Assets = {
Sprites = {}, Sprites = {
WhiteCross = {
asset = "rbxassetid://2738712459",
offset = Vector2.new(190, 318),
size = Vector2.new(18, 18),
},
},
Slices = { Slices = {
RoundBox = { RoundBox = {
asset = "rbxassetid://2773204550", asset = "rbxassetid://2773204550",
@@ -20,7 +24,11 @@ local Assets = {
} }
local function guardForTypos(name, map) local function guardForTypos(name, map)
strict(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 for key, child in pairs(map) do
if type(child) == "table" then if type(child) == "table" then

View File

@@ -2,21 +2,17 @@ local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact) local Roact = require(Rojo.Roact)
local Log = require(Rojo.Log)
local ApiContext = require(Plugin.ApiContext)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local DevSettings = require(Plugin.DevSettings) local DevSettings = require(Plugin.DevSettings)
local ServeSession = require(Plugin.ServeSession) local Logging = require(Plugin.Logging)
local Session = require(Plugin.Session)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local strict = require(Plugin.strict)
local ConnectPanel = require(Plugin.Components.ConnectPanel) local ConnectPanel = require(Plugin.Components.ConnectPanel)
local ConnectingPanel = require(Plugin.Components.ConnectingPanel)
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel) local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
local ErrorPanel = require(Plugin.Components.ErrorPanel)
local e = Roact.createElement local e = Roact.createElement
@@ -30,7 +26,7 @@ local function showUpgradeMessage(lastVersion)
Version.display(Config.version), Config.expectedServerVersionString Version.display(Config.version), Config.expectedServerVersionString
) )
Log.info(message) Logging.info(message)
end end
--[[ --[[
@@ -56,23 +52,26 @@ local function checkUpgrade(plugin)
plugin:SetSetting("LastRojoVersion", Config.version) plugin:SetSetting("LastRojoVersion", Config.version)
end end
local AppStatus = strict("AppStatus", { local SessionStatus = {
NotStarted = "NotStarted", Disconnected = "Disconnected",
Connecting = "Connecting",
Connected = "Connected", Connected = "Connected",
Error = "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") local App = Roact.Component:extend("App")
function App:init() function App:init()
self:setState({ self:setState({
appStatus = AppStatus.NotStarted, sessionStatus = SessionStatus.Disconnected,
errorMessage = nil,
}) })
self.signals = {} self.signals = {}
self.serveSession = nil self.currentSession = nil
self.displayedVersion = DevSettings:isEnabled() self.displayedVersion = DevSettings:isEnabled()
and Config.codename and Config.codename
@@ -97,7 +96,7 @@ function App:init()
360, 190 -- Minimum size 360, 190 -- Minimum size
) )
self.dockWidget = self.props.plugin:CreateDockWidgetPluginGui("Rojo-" .. self.displayedVersion, widgetInfo) self.dockWidget = self.props.plugin:CreateDockWidgetPluginGui("Rojo-0.5.x", widgetInfo)
self.dockWidget.Name = "Rojo " .. self.displayedVersion self.dockWidget.Name = "Rojo " .. self.displayedVersion
self.dockWidget.Title = "Rojo " .. self.displayedVersion self.dockWidget.Title = "Rojo " .. self.displayedVersion
self.dockWidget.AutoLocalize = false self.dockWidget.AutoLocalize = false
@@ -108,91 +107,56 @@ function App:init()
end) end)
end end
function App:startSession(address, port)
Log.trace("Starting new session")
local baseUrl = ("http://%s:%s"):format(address, port)
self.serveSession = ServeSession.new({
apiContext = ApiContext.new(baseUrl),
})
self.serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
self:setState({
appStatus = AppStatus.Connecting,
})
elseif status == ServeSession.Status.Connected then
self:setState({
appStatus = AppStatus.Connected,
})
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
-- Details being present indicates that this
-- disconnection was from an error.
if details ~= nil then
Log.warn("Disconnected from an error: {}", details)
self:setState({
appStatus = AppStatus.Error,
errorMessage = tostring(details),
})
else
self:setState({
appStatus = AppStatus.NotStarted,
})
end
end
end)
self.serveSession:start()
end
function App:render() function App:render()
local children local children
if self.state.appStatus == AppStatus.NotStarted then if self.state.sessionStatus == SessionStatus.Connected then
children = {
ConnectPanel = e(ConnectPanel, {
startSession = function(address, port)
self:startSession(address, port)
end,
cancel = function()
Log.trace("Canceling session configuration")
self:setState({
appStatus = AppStatus.NotStarted,
})
end,
}),
}
elseif self.state.appStatus == AppStatus.Connecting then
children = {
ConnectingPanel = Roact.createElement(ConnectingPanel),
}
elseif self.state.appStatus == AppStatus.Connected then
children = { children = {
ConnectionActivePanel = e(ConnectionActivePanel, { ConnectionActivePanel = e(ConnectionActivePanel, {
stopSession = function() stopSession = function()
Log.trace("Disconnecting session") Logging.trace("Disconnecting session")
self.serveSession:stop() self.currentSession:disconnect()
self.serveSession = nil self.currentSession = nil
self:setState({ self:setState({
appStatus = AppStatus.NotStarted, sessionStatus = SessionStatus.Disconnected,
}) })
Log.trace("Session terminated by user") Logging.trace("Session terminated by user")
end, end,
}), }),
} }
elseif self.state.appStatus == AppStatus.Error then elseif self.state.sessionStatus == SessionStatus.Disconnected then
children = { children = {
ErrorPanel = Roact.createElement(ErrorPanel, { ConnectPanel = e(ConnectPanel, {
errorMessage = self.state.errorMessage, startSession = function(address, port)
onDismiss = function() Logging.trace("Starting new session")
local success, session = Session.new({
address = address,
port = port,
onError = function(message)
Logging.warn("Rojo session terminated because of an error:\n%s", tostring(message))
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({ self:setState({
appStatus = AppStatus.NotStarted, sessionStatus = SessionStatus.Disconnected,
}) })
end, end,
}), }),
@@ -205,16 +169,16 @@ function App:render()
end end
function App:didMount() function App:didMount()
Log.trace("Rojo {} initializing", self.displayedVersion) Logging.trace("Rojo %s initializing", self.displayedVersion)
checkUpgrade(self.props.plugin) checkUpgrade(self.props.plugin)
preloadAssets() preloadAssets()
end end
function App:willUnmount() function App:willUnmount()
if self.serveSession ~= nil then if self.currentSession ~= nil then
self.serveSession:stop() self.currentSession:disconnect()
self.serveSession = nil self.currentSession = nil
end end
for _, signal in pairs(self.signals) do for _, signal in pairs(self.signals) do

View File

@@ -1,34 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme)
local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText)
local e = Roact.createElement
local ConnectingPanel = Roact.Component:extend("ConnectingPanel")
function ConnectingPanel:render()
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 8),
}),
Text = e(FitText, {
Padding = Vector2.new(12, 6),
Font = Theme.ButtonFont,
TextSize = 18,
Text = "Connecting...",
TextColor3 = Theme.PrimaryColor,
BackgroundTransparency = 1,
}),
})
end
return ConnectingPanel

View File

@@ -1,69 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme)
local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText)
local FitScrollingFrame = require(Plugin.Components.FitScrollingFrame)
local FormButton = require(Plugin.Components.FormButton)
local e = Roact.createElement
local BUTTON_HEIGHT = 60
local HOR_PADDING = 8
local ErrorPanel = Roact.Component:extend("ErrorPanel")
function ErrorPanel:render()
local errorMessage = self.props.errorMessage
local onDismiss = self.props.onDismiss
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 8),
}),
ErrorContainer = e(FitScrollingFrame, {
containerProps = {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, -HOR_PADDING * 2, 1, -BUTTON_HEIGHT),
Position = UDim2.new(0, HOR_PADDING, 0, 0),
ScrollBarImageColor3 = Theme.PrimaryColor,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
ScrollingDirection = Enum.ScrollingDirection.Y,
},
}, {
Text = e(FitText, {
Size = UDim2.new(1, 0, 0, 0),
LayoutOrder = 1,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
FitAxis = "Y",
Font = Theme.ButtonFont,
TextSize = 18,
Text = errorMessage,
TextWrap = true,
TextColor3 = Theme.PrimaryColor,
BackgroundTransparency = 1,
}),
}),
DismissButton = e(FormButton, {
layoutOrder = 2,
text = "Dismiss",
secondary = true,
onClick = function()
onDismiss()
end,
}),
})
end
return ErrorPanel

View File

@@ -1,33 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Dictionary = require(script.Parent.Parent.Dictionary)
local e = Roact.createElement
local FitScrollingFrame = Roact.Component:extend("FitScrollingFrame")
function FitScrollingFrame:init()
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
end
function FitScrollingFrame:render()
local containerProps = self.props.containerProps
local layoutProps = self.props.layoutProps
local children = Dictionary.merge(self.props[Roact.Children], {
["$Layout"] = e("UIListLayout", Dictionary.merge({
SortOrder = Enum.SortOrder.LayoutOrder,
[Roact.Change.AbsoluteContentSize] = function(instance)
self.setSize(UDim2.new(0, 0, 0, instance.AbsoluteContentSize.Y))
end,
}, layoutProps)),
})
local fullContainerProps = Dictionary.merge(containerProps, {
CanvasSize = self.sizeBinding,
})
return e("ScrollingFrame", fullContainerProps, children)
end
return FitScrollingFrame

View File

@@ -9,7 +9,6 @@ local e = Roact.createElement
local FitText = Roact.Component:extend("FitText") local FitText = Roact.Component:extend("FitText")
function FitText:init() function FitText:init()
self.ref = Roact.createRef()
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new()) self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
end end
@@ -17,15 +16,10 @@ function FitText:render()
local kind = self.props.Kind or "TextLabel" local kind = self.props.Kind or "TextLabel"
local containerProps = Dictionary.merge(self.props, { local containerProps = Dictionary.merge(self.props, {
FitAxis = Dictionary.None,
Kind = Dictionary.None, Kind = Dictionary.None,
Padding = Dictionary.None, Padding = Dictionary.None,
MinSize = Dictionary.None, MinSize = Dictionary.None,
Size = self.sizeBinding, Size = self.sizeBinding
[Roact.Ref] = self.ref,
[Roact.Change.AbsoluteSize] = function()
self:updateTextMeasurements()
end
}) })
return e(kind, containerProps) return e(kind, containerProps)
@@ -42,45 +36,15 @@ end
function FitText:updateTextMeasurements() function FitText:updateTextMeasurements()
local minSize = self.props.MinSize or Vector2.new(0, 0) local minSize = self.props.MinSize or Vector2.new(0, 0)
local padding = self.props.Padding or Vector2.new(0, 0) local padding = self.props.Padding or Vector2.new(0, 0)
local fitAxis = self.props.FitAxis or "XY"
local baseSize = self.props.Size
local text = self.props.Text or "" local text = self.props.Text or ""
local font = self.props.Font or Enum.Font.Legacy local font = self.props.Font or Enum.Font.Legacy
local textSize = self.props.TextSize or 12 local textSize = self.props.TextSize or 12
local containerSize = self.ref.current.AbsoluteSize local measuredText = TextService:GetTextSize(text, textSize, font, Vector2.new(9e6, 9e6))
local totalSize = UDim2.new(
local textBounds 0, math.max(minSize.X, padding.X * 2 + measuredText.X),
0, math.max(minSize.Y, padding.Y * 2 + measuredText.Y))
if fitAxis == "XY" then
textBounds = Vector2.new(9e6, 9e6)
elseif fitAxis == "X" then
textBounds = Vector2.new(9e6, containerSize.Y - padding.Y * 2)
elseif fitAxis == "Y" then
textBounds = Vector2.new(containerSize.X - padding.X * 2, 9e6)
end
local measuredText = TextService:GetTextSize(text, textSize, font, textBounds)
local computedX = math.max(minSize.X, padding.X * 2 + measuredText.X)
local computedY = math.max(minSize.Y, padding.Y * 2 + measuredText.Y)
local totalSize
if fitAxis == "XY" then
totalSize = UDim2.new(
0, computedX,
0, computedY)
elseif fitAxis == "X" then
totalSize = UDim2.new(
0, computedX,
baseSize.Y.Scale, baseSize.Y.Offset)
elseif fitAxis == "Y" then
totalSize = UDim2.new(
baseSize.X.Scale, baseSize.X.Offset,
0, computedY)
end
self.setSize(totalSize) self.setSize(totalSize)
end end

View File

@@ -8,6 +8,8 @@ local Version = require(Plugin.Version)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Theme)
local FitText = require(Plugin.Components.FitText)
local e = Roact.createElement local e = Roact.createElement
local RojoFooter = Roact.Component:extend("RojoFooter") local RojoFooter = Roact.Component:extend("RojoFooter")

View File

@@ -1,13 +1,8 @@
local strict = require(script.Parent.strict) return {
local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
return strict("Config", {
isDevBuild = isDevBuild,
codename = "Epiphany", codename = "Epiphany",
version = {0, 6, 0, "-alpha.2"}, version = {0, 5, 2},
expectedServerVersionString = "0.6.0 or newer", expectedServerVersionString = "0.5.0 or newer",
protocolVersion = 3, protocolVersion = 2,
defaultHost = "localhost", defaultHost = "localhost",
defaultPort = 34872, defaultPort = 34872,
}) }

View File

@@ -6,15 +6,13 @@ local Environment = {
Test = "Test", Test = "Test",
} }
local DEFAULT_ENVIRONMENT = Config.isDevBuild and Environment.Dev or Environment.User
local VALUES = { local VALUES = {
LogLevel = { LogLevel = {
type = "IntValue", type = "IntValue",
values = { values = {
[Environment.User] = 2, [Environment.User] = 2,
[Environment.Dev] = 4, [Environment.Dev] = 3,
[Environment.Test] = 4, [Environment.Test] = 3,
}, },
}, },
TypecheckingEnabled = { TypecheckingEnabled = {
@@ -25,14 +23,6 @@ local VALUES = {
[Environment.Test] = true, [Environment.Test] = true,
}, },
}, },
UnstableTwoWaySync = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = false,
[Environment.Test] = false,
},
},
} }
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
@@ -43,16 +33,6 @@ end
local valueContainer = getValueContainer() local valueContainer = getValueContainer()
game.ChildAdded:Connect(function(child)
local success, name = pcall(function()
return child.Name
end)
if success and name == CONTAINER_NAME then
valueContainer = child
end
end)
local function getStoredValue(name) local function getStoredValue(name)
if valueContainer == nil then if valueContainer == nil then
return nil return nil
@@ -104,7 +84,7 @@ local function getValue(name)
return stored return stored
end end
return VALUES[name].values[DEFAULT_ENVIRONMENT] return VALUES[name].values[Environment.User]
end end
local DevSettings = {} local DevSettings = {}
@@ -140,10 +120,6 @@ function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled") return getValue("TypecheckingEnabled")
end end
function DevSettings:twoWaySyncEnabled()
return getValue("UnstableTwoWaySync")
end
function _G.ROJO_DEV_CREATE() function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings() DevSettings:createDevSettings()
end end

75
plugin/src/Http.lua Normal file
View File

@@ -0,0 +1,75 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Parent.Promise)
local Logging = require(script.Parent.Logging)
local HttpError = require(script.Parent.HttpError)
local HttpResponse = require(script.Parent.HttpResponse)
local lastRequestId = 0
-- TODO: Factor out into separate library, especially error handling
local Http = {}
function Http.get(url)
local requestId = lastRequestId + 1
lastRequestId = requestId
Logging.trace("GET(%d) %s", requestId, url)
return Promise.new(function(resolve, reject)
coroutine.wrap(function()
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = url,
Method = "GET",
})
end)
if success then
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
else
Logging.trace("Request %d failure: %s", requestId, response)
reject(HttpError.fromErrorString(response))
end
end)()
end)
end
function Http.post(url, body)
local requestId = lastRequestId + 1
lastRequestId = requestId
Logging.trace("POST(%d) %s\n%s", requestId, url, body)
return Promise.new(function(resolve, reject)
coroutine.wrap(function()
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = url,
Method = "POST",
Body = body,
})
end)
if success then
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
else
Logging.trace("Request %d failure: %s", requestId, response)
reject(HttpError.fromErrorString(response))
end
end)()
end)
end
function Http.jsonEncode(object)
return HttpService:JSONEncode(object)
end
function Http.jsonDecode(source)
return HttpService:JSONDecode(source)
end
return Http

View File

@@ -1,7 +1,9 @@
local Error = {} local Logging = require(script.Parent.Logging)
Error.__index = Error
Error.Kind = { local HttpError = {}
HttpError.__index = HttpError
HttpError.Error = {
HttpNotEnabled = { HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" .. message = "Rojo requires HTTP access, which is not enabled.\n" ..
"Check your game settings, located in the 'Home' tab of Studio.", "Check your game settings, located in the 'Home' tab of Studio.",
@@ -11,20 +13,20 @@ Error.Kind = {
"Make sure the server is running -- use 'rojo serve' to run it!", "Make sure the server is running -- use 'rojo serve' to run it!",
}, },
Timeout = { Timeout = {
message = "HTTP request timed out.", message = "Request timed out.",
}, },
Unknown = { Unknown = {
message = "Unknown HTTP error: {{message}}", message = "Unknown error: {{message}}",
}, },
} }
setmetatable(Error.Kind, { setmetatable(HttpError.Error, {
__index = function(_, key) __index = function(_, key)
error(("%q is not a valid member of Http.Error.Kind"):format(tostring(key)), 2) error(("%q is not a valid member of HttpError.Error"):format(tostring(key)), 2)
end, end,
}) })
function Error.new(type, extraMessage) function HttpError.new(type, extraMessage)
extraMessage = extraMessage or "" extraMessage = extraMessage or ""
local message = type.message:gsub("{{message}}", extraMessage) local message = type.message:gsub("{{message}}", extraMessage)
@@ -33,34 +35,38 @@ function Error.new(type, extraMessage)
message = message, message = message,
} }
setmetatable(err, Error) setmetatable(err, HttpError)
return err return err
end end
function Error:__tostring() function HttpError:__tostring()
return self.message return self.message
end end
--[[ --[[
This method shouldn't have to exist. Ugh. This method shouldn't have to exist. Ugh.
]] ]]
function Error.fromRobloxErrorString(message) function HttpError.fromErrorString(message)
local lower = message:lower() local lower = message:lower()
if lower:find("^http requests are not enabled") then if lower:find("^http requests are not enabled") then
return Error.new(Error.Kind.HttpNotEnabled) return HttpError.new(HttpError.Error.HttpNotEnabled)
end end
if lower:find("^httperror: timedout") then if lower:find("^httperror: timedout") then
return Error.new(Error.Kind.Timeout) return HttpError.new(HttpError.Error.Timeout)
end end
if lower:find("^httperror: connectfail") then if lower:find("^httperror: connectfail") then
return Error.new(Error.Kind.ConnectFailed) return HttpError.new(HttpError.Error.ConnectFailed)
end end
return Error.new(Error.Kind.Unknown, message) return HttpError.new(HttpError.Error.Unknown, message)
end end
return Error function HttpError:report()
Logging.warn(self.message)
end
return HttpError

View File

@@ -1,34 +1,34 @@
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local stringTemplate = [[ local stringTemplate = [[
Http.Response { HttpResponse {
code: %d code: %d
body: %s body: %s
}]] }]]
local Response = {} local HttpResponse = {}
Response.__index = Response HttpResponse.__index = HttpResponse
function Response:__tostring() function HttpResponse:__tostring()
return stringTemplate:format(self.code, self.body) return stringTemplate:format(self.code, self.body)
end end
function Response.fromRobloxResponse(response) function HttpResponse.fromRobloxResponse(response)
local self = { local self = {
body = response.Body, body = response.Body,
code = response.StatusCode, code = response.StatusCode,
headers = response.Headers, headers = response.Headers,
} }
return setmetatable(self, Response) return setmetatable(self, HttpResponse)
end end
function Response:isSuccess() function HttpResponse:isSuccess()
return self.code >= 200 and self.code < 300 return self.code >= 200 and self.code < 300
end end
function Response:json() function HttpResponse:json()
return HttpService:JSONDecode(self.body) return HttpService:JSONDecode(self.body)
end end
return Response return HttpResponse

View File

@@ -1,4 +1,4 @@
local Log = require(script.Parent.Parent.Log) local Logging = require(script.Parent.Logging)
--[[ --[[
A bidirectional map between instance IDs and Roblox instances. It lets us A bidirectional map between instance IDs and Roblox instances. It lets us
@@ -9,79 +9,39 @@ local Log = require(script.Parent.Parent.Log)
local InstanceMap = {} local InstanceMap = {}
InstanceMap.__index = InstanceMap InstanceMap.__index = InstanceMap
function InstanceMap.new(onInstanceChanged) function InstanceMap.new()
local self = { local self = {
fromIds = {}, fromIds = {},
fromInstances = {}, fromInstances = {},
instancesToSignal = {},
onInstanceChanged = onInstanceChanged,
} }
return setmetatable(self, InstanceMap) return setmetatable(self, InstanceMap)
end end
--[[
Disconnect all connections and release all instance references.
]]
function InstanceMap:stop()
-- I think this is safe.
for instance in pairs(self.fromInstances) do
self:removeInstance(instance)
end
end
function InstanceMap:__fmtDebug(output)
output:writeLine("InstanceMap {{")
output:indent()
-- Collect all of the entries in the InstanceMap and sort them by their
-- label, which helps make our output deterministic.
local entries = {}
for id, instance in pairs(self.fromIds) do
local label = string.format("%s (%s)", instance:GetFullName(), instance.ClassName)
table.insert(entries, {id, label})
end
table.sort(entries, function(a, b)
return a[2] < b[2]
end)
for _, entry in ipairs(entries) do
output:writeLine("{}: {}", entry[1], entry[2])
end
output:unindent()
output:write("}")
end
function InstanceMap:insert(id, instance) function InstanceMap:insert(id, instance)
self.fromIds[id] = instance self.fromIds[id] = instance
self.fromInstances[instance] = id self.fromInstances[instance] = id
self:__connectSignals(instance)
end end
function InstanceMap:removeId(id) function InstanceMap:removeId(id)
local instance = self.fromIds[id] local instance = self.fromIds[id]
if instance ~= nil then if instance ~= nil then
self:__disconnectSignals(instance)
self.fromIds[id] = nil self.fromIds[id] = nil
self.fromInstances[instance] = nil self.fromInstances[instance] = nil
else else
Log.warn("Attempted to remove nonexistant ID {}", id) Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
end end
end end
function InstanceMap:removeInstance(instance) function InstanceMap:removeInstance(instance)
local id = self.fromInstances[instance] local id = self.fromInstances[instance]
self:__disconnectSignals(instance)
if id ~= nil then if id ~= nil then
self.fromInstances[instance] = nil self.fromInstances[instance] = nil
self.fromIds[id] = nil self.fromIds[id] = nil
else else
Log.warn("Attempted to remove nonexistant instance {}", instance) Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
end end
end end
@@ -91,7 +51,7 @@ function InstanceMap:destroyInstance(instance)
if id ~= nil then if id ~= nil then
self:destroyId(id) self:destroyId(id)
else else
Log.warn("Attempted to destroy untracked instance {}", instance) Logging.warn("Attempted to destroy untracked instance %s", tostring(instance))
end end
end end
@@ -114,63 +74,7 @@ function InstanceMap:destroyId(id)
instance:Destroy() instance:Destroy()
else else
Log.warn("Attempted to destroy nonexistant ID {}", id) Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
end
end
function InstanceMap:__connectSignals(instance)
-- ValueBase instances have an overriden version of the Changed signal that
-- only detects changes to their Value property.
--
-- We can instead connect listener to each individual property that we care
-- about on those objects (Name and Value) to emulate the same idea.
if instance:IsA("ValueBase") then
local signals = {
instance:GetPropertyChangedSignal("Name"):Connect(function()
self:__maybeFireInstanceChanged(instance, "Name")
end),
instance:GetPropertyChangedSignal("Value"):Connect(function()
self:__maybeFireInstanceChanged(instance, "Value")
end),
instance:GetPropertyChangedSignal("Parent"):Connect(function()
self:__maybeFireInstanceChanged(instance, "Parent")
end),
}
self.instancesToSignal[instance] = signals
else
self.instancesToSignal[instance] = instance.Changed:Connect(function(propertyName)
self:__maybeFireInstanceChanged(instance, propertyName)
end)
end
end
function InstanceMap:__maybeFireInstanceChanged(instance, propertyName)
Log.trace("{}.{} changed", instance:GetFullName(), propertyName)
if self.onInstanceChanged ~= nil then
self.onInstanceChanged(instance, propertyName)
end
end
function InstanceMap:__disconnectSignals(instance)
local signals = self.instancesToSignal[instance]
if signals ~= nil then
-- In most cases, we only have a single signal, so we avoid keeping
-- around the extra table. ValueBase objects force us to use multiple
-- signals to emulate the Instance.Changed event, however.
if typeof(signals) == "table" then
for _, signal in ipairs(signals) do
signal:Disconnect()
end
else
signals:Disconnect()
end
self.instancesToSignal[instance] = nil
end end
end end

View File

@@ -1,55 +1,49 @@
local Fmt = require(script.Parent.Fmt) local DevSettings = require(script.Parent.DevSettings)
local Level = { local Level = {
Error = 0, Error = 0,
Warning = 1, Warning = 1,
Info = 2, Info = 2,
Debug = 3, Trace = 3,
Trace = 4,
} }
local testLogLevel = nil
local function getLogLevel() local function getLogLevel()
return Level.Info if testLogLevel ~= nil then
return testLogLevel
end
return DevSettings:getLogLevel()
end end
local function addTags(tag, message) local function addTags(tag, message)
return tag .. message:gsub("\n", "\n" .. tag) return tag .. message:gsub("\n", "\n" .. tag)
end end
local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] "
local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] " local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] "
local DEBUG_TAG = (" "):rep(15) .. "[Rojo-Debug] " local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] "
local WARN_TAG = "[Rojo-Warn] " local WARN_TAG = "[Rojo-Warn] "
local Log = {} local Log = {}
Log.Level = Level Log.Level = Level
function Log.setLogLevelThunk(thunk)
getLogLevel = thunk
end
function Log.trace(template, ...) function Log.trace(template, ...)
if getLogLevel() >= Level.Trace then if getLogLevel() >= Level.Trace then
print(addTags(TRACE_TAG, Fmt.fmt(template, ...))) print(addTags(TRACE_TAG, string.format(template, ...)))
end end
end end
function Log.info(template, ...) function Log.info(template, ...)
if getLogLevel() >= Level.Info then if getLogLevel() >= Level.Info then
print(addTags(INFO_TAG, Fmt.fmt(template, ...))) print(addTags(INFO_TAG, string.format(template, ...)))
end
end
function Log.debug(template, ...)
if getLogLevel() >= Level.Debug then
print(addTags(DEBUG_TAG, Fmt.fmt(template, ...)))
end end
end end
function Log.warn(template, ...) function Log.warn(template, ...)
if getLogLevel() >= Level.Warning then if getLogLevel() >= Level.Warning then
warn(addTags(WARN_TAG, Fmt.fmt(template, ...))) warn(addTags(WARN_TAG, string.format(template, ...)))
end end
end end

View File

@@ -1,401 +1,236 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
local RbxDom = require(script.Parent.Parent.RbxDom)
local t = require(script.Parent.Parent.t) local t = require(script.Parent.Parent.t)
local Log = require(script.Parent.Parent.Log)
local Types = require(script.Parent.Types) local InstanceMap = require(script.Parent.InstanceMap)
local invariant = require(script.Parent.invariant) local Logging = require(script.Parent.Logging)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty) local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
local Types = require(script.Parent.Types)
--[[ local function setParent(instance, newParent)
This interface represents either a patch created by the hydrate method, or a
patch returned from the API.
This type should be a subset of Types.ApiInstanceUpdate.
]]
local IPatch = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[
Attempt to safely set the parent of an instance.
This function will always succeed, even if the actual set failed. This is
important for some types like services that will throw even if their current
parent is already set to the requested parent.
TODO: See if we can eliminate this by being more nuanced with property
assignment?
]]
local function safeSetParent(instance, newParent)
pcall(function() pcall(function()
instance.Parent = newParent instance.Parent = newParent
end) end)
end end
--[[
Similar to setting Parent, some instances really don't like being renamed.
TODO: Should we be throwing away these results or can we be more careful?
]]
local function safeSetName(instance, name)
pcall(function()
instance.Name = name
end)
end
local Reconciler = {} local Reconciler = {}
Reconciler.__index = Reconciler Reconciler.__index = Reconciler
function Reconciler.new(instanceMap) function Reconciler.new()
local self = { local self = {
-- Tracks all of the instances known by the reconciler by ID. instanceMap = InstanceMap.new(),
__instanceMap = instanceMap,
} }
return setmetatable(self, Reconciler) return setmetatable(self, Reconciler)
end end
--[[ function Reconciler:applyUpdate(requestedIds, virtualInstancesById)
See Reconciler:__hydrateInternal(). -- This function may eventually be asynchronous; it will require calls to
]] -- the server to resolve instances that don't exist yet.
function Reconciler:hydrate(apiInstances, id, instance) local visitedIds = {}
local hydratePatch = {
removed = {},
added = {},
updated = {},
}
self:__hydrateInternal(apiInstances, id, instance, hydratePatch) for _, id in ipairs(requestedIds) do
self:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
return hydratePatch
end
--[[
Applies a patch to the Roblox DOM using the reconciler's internal state.
TODO: This function might only apply some of the patch in the future and
require content negotiation with the Rojo server to handle types that aren't
editable by scripts.
]]
local applyPatchSchema = Types.ifEnabled(t.tuple(
IPatch
))
function Reconciler:applyPatch(patch)
assert(applyPatchSchema(patch))
for _, removedIdOrInstance in ipairs(patch.removed) do
local removedInstance
if Types.RbxId(removedIdOrInstance) then
-- If this value is an ID, it's assumed to be an instance that the
-- Rojo server knows about.
removedInstance = self.__instanceMap.fromIds[removedIdOrInstance]
self.__instanceMap:removeId(removedIdOrInstance)
end
-- If this entry was an ID that we didn't know about, removedInstance
-- will be nil, which we guard against in case of minor tree desync.
if removedInstance ~= nil then
-- Ensure that if any descendants are tracked by Rojo, that we
-- properly un-track them.
for _, descendantInstance in ipairs(removedInstance:GetDescendants()) do
self.__instanceMap:removeInstance(descendantInstance)
end
removedInstance:Destroy()
end
end
-- TODO: This loop assumes that apiInstance.ParentId is never nil. The Rojo
-- plugin can't create a new top-level DataModel anyways, so this should
-- only be violated in cases that are already erroneous.
for id, apiInstance in pairs(patch.added) do
if self.__instanceMap.fromIds[id] == nil then
-- Find the first ancestor of this instance that is marked for an
-- addition.
--
-- This helps us make sure we only reify each instance once, and we
-- start from the top.
while patch.added[apiInstance.Parent] ~= nil do
id = apiInstance.Parent
apiInstance = patch.added[id]
end
local parentInstance = self.__instanceMap.fromIds[apiInstance.Parent]
if parentInstance == nil then
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
apiInstance.Parent,
self.__instanceMap
)
end
self:__reifyInstance(patch.added, id, parentInstance)
end
end
for _, update in ipairs(patch.updated) do
local instance = self.__instanceMap.fromIds[update.id]
if instance == nil then
invariant(
"Cannot update an instance that does not exist in the reconciler's state.\nInstance {}\nState: {:#?}",
update.id,
self.__instanceMap
)
end
if update.changedClassName ~= nil then
error("TODO: Support changing class name by destroying + recreating instance.")
end
if update.changedName ~= nil then
instance.Name = update.changedName
end
if update.changedMetadata ~= nil then
print("TODO: Support changing metadata, if necessary.")
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
-- TODO: Gracefully handle this error instead?
assert(setCanonicalProperty(instance, propertyName, self:__decodeApiValue(propertyValue)))
end
end
end end
end end
--[[ local reconcileSchema = Types.ifEnabled(t.tuple(
Transforms a value into one that can be sent over the network back to the t.map(t.string, Types.VirtualInstance),
Rojo server. t.string,
This operation can fail, and so it returns bool, value.
]]
function Reconciler:encodeApiValue(value)
if typeof(value) == "string" then
return true, {
Type = "String",
Value = value,
}
end
return false
end
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
]]
function Reconciler:__decodeApiValue(apiValue)
assert(Types.ApiValue(apiValue))
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
if apiValue.Type == "Ref" then
-- TODO: This ref could be pointing at an instance we haven't created
-- yet!
return self.__instanceMap.fromIds[apiValue.Value]
end
local success, decodedValue = RbxDom.EncodedValue.decode(apiValue)
if not success then
error(decodedValue, 2)
end
return decodedValue
end
--[[
Constructs an instance from an ApiInstance without any of its children.
]]
local reifySingleInstanceSchema = Types.ifEnabled(t.tuple(
Types.ApiInstance
))
function Reconciler:__reifySingleInstance(apiInstance)
assert(reifySingleInstanceSchema(apiInstance))
-- Instance.new can fail if we're passing in something that can't be
-- created, like a service, something enabled with a feature flag, or
-- something that requires higher security than we have.
local ok, instance = pcall(Instance.new, apiInstance.ClassName)
if not ok then
return false, instance
end
-- TODO: When can setting Name fail here?
safeSetName(instance, apiInstance.Name)
for key, value in pairs(apiInstance.Properties) do
setCanonicalProperty(instance, key, self:__decodeApiValue(value))
end
return true, instance
end
--[[
Construct an instance and all of its descendants, parent it to the given
instance, and insert it into the reconciler's internal state.
]]
local reifyInstanceSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance t.Instance
)) ))
function Reconciler:__reifyInstance(apiInstances, id, parentInstance)
assert(reifyInstanceSchema(apiInstances, id, parentInstance))
local apiInstance = apiInstances[id]
local ok, instance = self:__reifySingleInstance(apiInstance)
-- TODO: Propagate this error upward to handle it elsewhere?
if not ok then
error(("Couldn't create an instance of type %q, a child of %s"):format(
apiInstance.ClassName,
parentInstance:GetFullName()
))
end
self.__instanceMap:insert(id, instance)
for _, childId in ipairs(apiInstance.Children) do
self:__reifyInstance(apiInstances, childId, instance)
end
safeSetParent(instance, parentInstance)
return instance
end
--[[ --[[
Populates the reconciler's internal state, maps IDs to instances that the Update an existing instance, including its properties and children, to match
Rojo plugin knows about, and generates a patch that would update the Roblox the given information.
tree to match Rojo's view of the tree.
]] ]]
local hydrateSchema = Types.ifEnabled(t.tuple( function Reconciler:reconcile(virtualInstancesById, id, instance)
t.map(Types.RbxId, Types.VirtualInstance), assert(reconcileSchema(virtualInstancesById, id, instance))
Types.RbxId,
t.Instance,
IPatch
))
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
assert(hydrateSchema(apiInstances, id, instance, hydratePatch))
self.__instanceMap:insert(id, instance) local virtualInstance = virtualInstancesById[id]
local apiInstance = apiInstances[id] -- If an instance changes ClassName, we assume it's very different. That's
-- not always the case!
if virtualInstance.ClassName ~= instance.ClassName then
Logging.trace("Switching to reify for %s because ClassName is different", instance:GetFullName())
local function markIdAdded(id) -- TODO: Preserve existing children instead?
local apiInstance = apiInstances[id] local parent = instance.Parent
hydratePatch.added[id] = apiInstance self.instanceMap:destroyId(id)
return self:__reify(virtualInstancesById, id, parent)
for _, childId in ipairs(apiInstance.Children) do
markIdAdded(childId)
end
end end
local changedName = nil self.instanceMap:insert(id, instance)
local changedProperties = {}
if apiInstance.Name ~= instance.Name then -- Some instances don't like being named, even if their name already matches
changedName = apiInstance.Name setCanonicalProperty(instance, "Name", virtualInstance.Name)
end
for propertyName, virtualValue in pairs(apiInstance.Properties) do for key, value in pairs(virtualInstance.Properties) do
local success, existingValue = getCanonicalProperty(instance, propertyName) setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
if success then
local decodedValue = self:__decodeApiValue(virtualValue)
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
end
end
-- If any properties differed from the virtual instance we read, add it to
-- the hydrate patch so that we can catch up.
if changedName ~= nil or next(changedProperties) ~= nil then
table.insert(hydratePatch.updated, {
id = id,
changedName = changedName,
changedClassName = nil,
changedProperties = changedProperties,
changedMetadata = nil,
})
end end
local existingChildren = instance:GetChildren() local existingChildren = instance:GetChildren()
-- For each existing child, we'll track whether it's been paired with an local unvisitedExistingChildren = {}
-- instance that the Rojo server knows about. for _, child in ipairs(existingChildren) do
local isExistingChildVisited = {} unvisitedExistingChildren[child] = true
for i = 1, #existingChildren do
isExistingChildVisited[i] = false
end end
for _, childId in ipairs(apiInstance.Children) do for _, childId in ipairs(virtualInstance.Children) do
local apiChild = apiInstances[childId] local childData = virtualInstancesById[childId]
local childInstance local existingChildInstance
for instance in pairs(unvisitedExistingChildren) do
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
for childIndex, instance in ipairs(existingChildren) do if ok then
if not isExistingChildVisited[childIndex] then if name == childData.Name and className == childData.ClassName then
-- We guard accessing Name and ClassName in order to avoid existingChildInstance = instance
-- tripping over children of DataModel that Rojo won't have
-- permissions to access at all.
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
-- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced.
if ok and name == apiChild.Name and className == apiChild.ClassName then
childInstance = instance
isExistingChildVisited[childIndex] = true
break break
end end
end end
end end
if childInstance ~= nil then if existingChildInstance ~= nil then
-- We found an instance that matches the instance from the API, yay! unvisitedExistingChildren[existingChildInstance] = nil
self:__hydrateInternal(apiInstances, childId, childInstance, hydratePatch) self:reconcile(virtualInstancesById, childId, existingChildInstance)
else else
markIdAdded(childId) Logging.trace(
"Switching to reify for %s.%s because it does not exist",
instance:GetFullName(),
virtualInstancesById[childId].Name
)
self:__reify(virtualInstancesById, childId, instance)
end end
end end
-- Any unvisited children at this point aren't known by Rojo and we can local shouldClearUnknown = self:__shouldClearUnknownChildren(virtualInstance)
-- destroy them unless the user has explicitly asked us to preserve children
-- of this instance. for existingChildInstance in pairs(unvisitedExistingChildren) do
local shouldClearUnknown = self:__shouldClearUnknownChildren(apiInstance) local childId = self.instanceMap.fromInstances[existingChildInstance]
if shouldClearUnknown then
for childIndex, visited in ipairs(isExistingChildVisited) do if childId == nil then
if not visited then if shouldClearUnknown then
table.insert(hydratePatch.removed, existingChildren[childIndex]) existingChildInstance:Destroy()
end end
else
self.instanceMap:destroyInstance(existingChildInstance)
end end
end end
-- The root instance of a project won't have a parent, like the DataModel,
-- so we need to be careful here.
if virtualInstance.Parent ~= nil then
local parent = self.instanceMap.fromIds[virtualInstance.Parent]
if parent == nil then
Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.")
end
-- Some instances, like services, don't like having their Parent
-- property poked, even if we're setting it to the same value.
setParent(instance, parent)
end
return instance
end end
function Reconciler:__shouldClearUnknownChildren(apiInstance) function Reconciler:__shouldClearUnknownChildren(virtualInstance)
if apiInstance.Metadata ~= nil then if virtualInstance.Metadata ~= nil then
return not apiInstance.Metadata.ignoreUnknownInstances return not virtualInstance.Metadata.ignoreUnknownInstances
else else
return true return true
end end
end end
local reifySchema = Types.ifEnabled(t.tuple(
t.map(t.string, Types.VirtualInstance),
t.string,
t.Instance
))
function Reconciler:__reify(virtualInstancesById, id, parent)
assert(reifySchema(virtualInstancesById, id, parent))
local virtualInstance = virtualInstancesById[id]
local ok, instance = pcall(function()
return Instance.new(virtualInstance.ClassName)
end)
if not ok then
error(("Couldn't create an Instance of type %q, a child of %s"):format(virtualInstance.ClassName, parent:GetFullName()))
end
for key, value in pairs(virtualInstance.Properties) do
setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
end
setCanonicalProperty(instance, "Name", virtualInstance.Name)
for _, childId in ipairs(virtualInstance.Children) do
self:__reify(virtualInstancesById, childId, instance)
end
setParent(instance, parent)
self.instanceMap:insert(id, instance)
return instance
end
local applyUpdatePieceSchema = Types.ifEnabled(t.tuple(
t.string,
t.map(t.string, t.boolean),
t.map(t.string, Types.VirtualInstance)
))
function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
assert(applyUpdatePieceSchema(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 return Reconciler

View File

@@ -0,0 +1,218 @@
local Reconciler = require(script.Parent.Reconciler)
return function()
it("should leave instances alone if there's nothing specified", function()
local instance = Instance.new("Folder")
instance.Name = "TestFolder"
local instanceId = "test-id"
local virtualInstancesById = {
[instanceId] = {
Name = "TestFolder",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, instanceId, instance)
end)
it("should assign names from virtual instances", function()
local instance = Instance.new("Folder")
instance.Name = "InitialName"
local instanceId = "test-id"
local virtualInstancesById = {
[instanceId] = {
Name = "NewName",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, instanceId, instance)
expect(instance.Name).to.equal("NewName")
end)
it("should assign properties from virtual instances", function()
local instance = Instance.new("IntValue")
instance.Name = "TestValue"
instance.Value = 5
local instanceId = "test-id"
local virtualInstancesById = {
[instanceId] = {
Name = "TestValue",
ClassName = "IntValue",
Children = {},
Properties = {
Value = {
Type = "Int32",
Value = 9
}
},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, instanceId, instance)
expect(instance.Value).to.equal(9)
end)
it("should wipe unknown children by default", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Name = "Child"
local parentId = "test-id"
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(#parent:GetChildren()).to.equal(0)
end)
it("should preserve unknown children if ignoreUnknownInstances is set", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Parent = parent
child.Name = "Child"
local parentId = "test-id"
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(child.Parent).to.equal(parent)
expect(#parent:GetChildren()).to.equal(1)
end)
it("should remove known removed children", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Parent = parent
child.Name = "Child"
local parentId = "parent-id"
local childId = "child-id"
local reconciler = Reconciler.new()
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {childId},
Properties = {},
},
[childId] = {
Name = "Child",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(child.Parent).to.equal(parent)
expect(#parent:GetChildren()).to.equal(1)
local newVirtualInstances = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
},
[childId] = nil,
}
reconciler:reconcile(newVirtualInstances, parentId, parent)
expect(child.Parent).to.equal(nil)
expect(#parent:GetChildren()).to.equal(0)
end)
it("should remove known removed children if ignoreUnknownInstances is set", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Parent = parent
child.Name = "Child"
local parentId = "parent-id"
local childId = "child-id"
local reconciler = Reconciler.new()
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {childId},
Properties = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
[childId] = {
Name = "Child",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(child.Parent).to.equal(parent)
expect(#parent:GetChildren()).to.equal(1)
local newVirtualInstances = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
[childId] = nil,
}
reconciler:reconcile(newVirtualInstances, parentId, parent)
expect(child.Parent).to.equal(nil)
expect(#parent:GetChildren()).to.equal(0)
end)
end

View File

@@ -1,213 +0,0 @@
local Log = require(script.Parent.Parent.Log)
local Fmt = require(script.Parent.Parent.Fmt)
local t = require(script.Parent.Parent.t)
local DevSettings = require(script.Parent.DevSettings)
local InstanceMap = require(script.Parent.InstanceMap)
local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict)
local Status = strict("Session.Status", {
NotStarted = "NotStarted",
Connecting = "Connecting",
Connected = "Connected",
Disconnected = "Disconnected",
})
local function debugPatch(patch)
return Fmt.debugify(patch, function(patch, output)
output:writeLine("Patch {{")
output:indent()
for removed in ipairs(patch.removed) do
output:writeLine("Remove ID {}", removed)
end
for id, added in pairs(patch.added) do
output:writeLine("Add ID {} {:#?}", id, added)
end
for _, updated in ipairs(patch.updated) do
output:writeLine("Update ID {} {:#?}", updated.id, updated)
end
output:unindent()
output:write("}")
end)
end
local ServeSession = {}
ServeSession.__index = ServeSession
ServeSession.Status = Status
local validateServeOptions = t.strictInterface({
apiContext = t.table,
})
function ServeSession.new(options)
assert(validateServeOptions(options))
-- Declare self ahead of time to capture it in a closure
local self
local function onInstanceChanged(instance, propertyName)
self:__onInstanceChanged(instance, propertyName)
end
local instanceMap = InstanceMap.new(onInstanceChanged)
local reconciler = Reconciler.new(instanceMap)
self = {
__status = Status.NotStarted,
__apiContext = options.apiContext,
__reconciler = reconciler,
__instanceMap = instanceMap,
__statusChangedCallback = nil,
}
setmetatable(self, ServeSession)
return self
end
function ServeSession:__fmtDebug(output)
output:writeLine("ServeSession {{")
output:indent()
output:writeLine("API Context: {:#?}", self.__apiContext)
output:writeLine("Instances: {:#?}", self.__instanceMap)
output:unindent()
output:write("}")
end
function ServeSession:onStatusChanged(callback)
self.__statusChangedCallback = callback
end
function ServeSession:start()
self:__setStatus(Status.Connecting)
self.__apiContext:connect()
:andThen(function(serverInfo)
self:__setStatus(Status.Connected)
local rootInstanceId = serverInfo.rootInstanceId
return self:__initialSync(rootInstanceId)
:andThen(function()
return self:__mainSyncLoop()
end)
end)
:catch(function(err)
self:__stopInternal(err)
end)
end
function ServeSession:stop()
self:__stopInternal()
end
function ServeSession:__onInstanceChanged(instance, propertyName)
if not DevSettings:twoWaySyncEnabled() then
return
end
local instanceId = self.__instanceMap.fromInstances[instance]
if instanceId == nil then
Log.warn("Ignoring change for instance {:?} as it is unknown to Rojo", instance)
return
end
local remove = nil
local update = {
id = instanceId,
changedProperties = {},
}
if propertyName == "Name" then
update.changedName = instance.Name
elseif propertyName == "Parent" then
if instance.Parent == nil then
update = nil
remove = instanceId
else
Log.warn("Cannot sync non-nil Parent property changes yet")
return
end
else
local success, encoded = self.__reconciler:encodeApiValue(instance[propertyName])
if not success then
Log.warn("Could not sync back property {:?}.{}", instance, propertyName)
return
end
update.changedProperties[propertyName] = encoded
end
local patch = {
removed = {remove},
added = {},
updated = {update},
}
self.__apiContext:write(patch)
end
function ServeSession:__initialSync(rootInstanceId)
return self.__apiContext:read({ rootInstanceId })
:andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response.
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
Log.trace("Computing changes that plugin needs to make to catch up to server...")
-- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like.
local hydratePatch = self.__reconciler:hydrate(
readResponseBody.instances,
rootInstanceId,
game
)
Log.trace("Computed hydration patch: {:#?}", debugPatch(hydratePatch))
-- TODO: Prompt user to notify them of this patch, since it's
-- effectively a conflict between the Rojo server and the client.
self.__reconciler:applyPatch(hydratePatch)
end)
end
function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages()
:andThen(function(messages)
for _, message in ipairs(messages) do
self.__reconciler:applyPatch(message)
end
if self.__status ~= Status.Disconnected then
return self:__mainSyncLoop()
end
end)
end
function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect()
self.__instanceMap:stop()
end
function ServeSession:__setStatus(status, detail)
self.__status = status
if self.__statusChangedCallback ~= nil then
self.__statusChangedCallback(status, detail)
end
end
return ServeSession

97
plugin/src/Session.lua Normal file
View 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
end
return api:read({api.rootInstanceId})
end)
:andThen(function(response)
if self.disconnected then
return
end
self.reconciler:reconcile(response.instances, api.rootInstanceId, game)
return self:__processMessages(response.messageCursor)
end)
:catch(function(message)
self.disconnected = true
self.onError(message)
end)
return not self.disconnected, setmetatable(self, Session)
end
function Session:__processMessages(initialCursor)
if self.disconnected then
return Promise.resolve()
end
return self.api:retrieveMessages(initialCursor)
: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

View File

@@ -1,6 +1,4 @@
local strict = require(script.Parent.strict) local Theme = {
return strict("Theme", {
ButtonFont = Enum.Font.GothamSemibold, ButtonFont = Enum.Font.GothamSemibold,
InputFont = Enum.Font.Code, InputFont = Enum.Font.Code,
TitleFont = Enum.Font.GothamBold, TitleFont = Enum.Font.GothamBold,
@@ -11,4 +9,12 @@ return strict("Theme", {
PrimaryColor = Color3.fromRGB(64, 64, 64), PrimaryColor = Color3.fromRGB(64, 64, 64),
SecondaryColor = Color3.fromRGB(235, 235, 235), SecondaryColor = Color3.fromRGB(235, 235, 235),
LightTextColor = Color3.fromRGB(160, 160, 160), LightTextColor = Color3.fromRGB(160, 160, 160),
}
setmetatable(Theme, {
__index = function(_, key)
error(("%s is not a valid member of Theme"):format(key), 2)
end
}) })
return Theme

View File

@@ -1,70 +1,21 @@
local t = require(script.Parent.Parent.t) local t = require(script.Parent.Parent.t)
local DevSettings = require(script.Parent.DevSettings) local DevSettings = require(script.Parent.DevSettings)
local strict = require(script.Parent.strict)
local RbxId = t.string local VirtualValue = t.interface({
local ApiValue = t.interface({
Type = t.string, Type = t.string,
Value = t.optional(t.any), Value = t.optional(t.any),
}) })
local ApiInstanceMetadata = t.interface({ local VirtualMetadata = t.interface({
ignoreUnknownInstances = t.optional(t.boolean), ignoreUnknownInstances = t.optional(t.boolean),
}) })
local ApiInstance = t.interface({ local VirtualInstance = t.interface({
Id = RbxId,
Parent = t.optional(RbxId),
Name = t.string, Name = t.string,
ClassName = t.string, ClassName = t.string,
Properties = t.map(t.string, ApiValue), Properties = t.map(t.string, VirtualValue),
Metadata = t.optional(ApiInstanceMetadata), Metadata = t.optional(VirtualMetadata)
Children = t.array(RbxId),
})
local ApiInstanceUpdate = t.interface({
id = RbxId,
changedName = t.optional(t.string),
changedClassName = t.optional(t.string),
changedProperties = t.map(t.string, ApiValue),
changedMetadata = t.optional(ApiInstanceMetadata),
})
local ApiSubscribeMessage = t.interface({
removed = t.array(RbxId),
added = t.map(RbxId, ApiInstance),
updated = t.array(ApiInstanceUpdate),
})
local ApiInfoResponse = t.interface({
sessionId = t.string,
serverVersion = t.string,
protocolVersion = t.number,
expectedPlaceIds = t.optional(t.array(t.number)),
rootInstanceId = RbxId,
})
local ApiReadResponse = t.interface({
sessionId = t.string,
messageCursor = t.number,
instances = t.map(RbxId, ApiInstance),
})
local ApiSubscribeResponse = t.interface({
sessionId = t.string,
messageCursor = t.number,
messages = t.array(ApiSubscribeMessage),
})
local ApiError = t.interface({
kind = t.union(
t.literal("NotFound"),
t.literal("BadRequest"),
t.literal("InternalError")
),
details = t.string,
}) })
local function ifEnabled(innerCheck) local function ifEnabled(innerCheck)
@@ -77,23 +28,9 @@ local function ifEnabled(innerCheck)
end end
end end
return strict("Types", { return {
ifEnabled = ifEnabled, ifEnabled = ifEnabled,
VirtualInstance = VirtualInstance,
ApiInfoResponse = ApiInfoResponse, VirtualMetadata = VirtualMetadata,
ApiReadResponse = ApiReadResponse, VirtualValue = VirtualValue,
ApiSubscribeResponse = ApiSubscribeResponse, }
ApiError = ApiError,
ApiInstance = ApiInstance,
ApiInstanceUpdate = ApiInstanceUpdate,
ApiInstanceMetadata = ApiInstanceMetadata,
ApiSubscribeMessage = ApiSubscribeMessage,
ApiValue = ApiValue,
RbxId = RbxId,
-- Deprecated aliases during transition
VirtualInstance = ApiInstance,
VirtualMetadata = ApiInstanceMetadata,
VirtualValue = ApiValue,
})

View File

@@ -1,39 +0,0 @@
local RbxDom = require(script.Parent.Parent.RbxDom)
--[[
Attempts to set a property on the given instance.
]]
local function getCanonincalProperty(instance, propertyName)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
--
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
return false, "unknown property"
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
return false, "unreadable property"
end
local success, valueOrErr = descriptor:read(instance)
if not success then
local err = valueOrErr
-- If we don't have permission to read a property, we can chalk that up
-- to our database being out of date and the engine being right.
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, "permission error"
end
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
error(message, 2)
end
return true, valueOrErr
end
return getCanonincalProperty

View File

@@ -2,17 +2,8 @@ if not plugin then
return return
end end
local Log = require(script.Parent.Log)
local DevSettings = require(script.DevSettings)
Log.setLogLevelThunk(function()
return DevSettings:getLogLevel()
end)
local Roact = require(script.Parent.Roact) local Roact = require(script.Parent.Roact)
local Config = require(script.Config)
local App = require(script.Components.App) local App = require(script.Components.App)
local app = Roact.createElement(App, { local app = Roact.createElement(App, {
@@ -24,9 +15,3 @@ local tree = Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
plugin.Unloading:Connect(function() plugin.Unloading:Connect(function()
Roact.unmount(tree) Roact.unmount(tree)
end) end)
if Config.isDevBuild then
local TestEZ = require(script.Parent.TestEZ)
require(script.runTests)(TestEZ)
end

View File

@@ -1,15 +0,0 @@
return function()
it("should load all submodules", function()
local function loadRecursive(container)
if container:IsA("ModuleScript") and not container.Name:find("%.spec$") then
require(container)
end
for _, child in ipairs(container:GetChildren()) do
loadRecursive(child)
end
end
loadRecursive(script.Parent)
end)
end

View File

@@ -1,29 +0,0 @@
local Fmt = require(script.Parent.Parent.Fmt)
local Config = require(script.Parent.Config)
local invariant
if Config.isDevBuild then
function invariant(message, ...)
message = Fmt.fmt(message, ...)
error("Invariant violation: " .. message, 2)
end
else
function invariant(message, ...)
message = Fmt.fmt(message, ...)
local fullMessage = string.format(
"Rojo detected an invariant violation within itself:\n" ..
"%s\n\n" ..
"This is a bug in Rojo. Please file an issue:\n" ..
"https://github.com/rojo-rbx/rojo/issues",
message
)
error(fullMessage, 2)
end
end
return invariant

View File

@@ -1,7 +1,6 @@
local ContentProvider = game:GetService("ContentProvider") local ContentProvider = game:GetService("ContentProvider")
local Log = require(script.Parent.Parent.Log) local Logging = require(script.Parent.Logging)
local Assets = require(script.Parent.Assets) local Assets = require(script.Parent.Assets)
local function preloadAssets() local function preloadAssets()
@@ -19,7 +18,7 @@ local function preloadAssets()
table.insert(contentUrls, url) table.insert(contentUrls, url)
end end
Log.trace("Preloading assets: {:?}", contentUrls) Logging.trace("Preloading assets: %s", table.concat(contentUrls, ", "))
coroutine.wrap(function() coroutine.wrap(function()
ContentProvider:PreloadAsync(contentUrls) ContentProvider:PreloadAsync(contentUrls)

View File

@@ -0,0 +1,19 @@
local RbxDom = require(script:FindFirstAncestor("Rojo").RbxDom)
local function rojoValueToRobloxValue(value)
-- TODO: Manually decode this value by looking up its GUID The Rojo server
-- doesn't give us valid ref values yet, so this isn't important yet.
if value.Type == "Ref" then
return nil
end
local success, decodedValue = RbxDom.EncodedValue.decode(value)
if not success then
error(decodedValue, 2)
end
return decodedValue
end
return rojoValueToRobloxValue

View File

@@ -0,0 +1,40 @@
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
return function()
it("should convert primitives", function()
local inputString = {
Type = "String",
Value = "Hello, world!",
}
local inputFloat32 = {
Type = "Float32",
Value = 12341.512,
}
expect(rojoValueToRobloxValue(inputString)).to.equal(inputString.Value)
expect(rojoValueToRobloxValue(inputFloat32)).to.equal(inputFloat32.Value)
end)
it("should convert properties with direct constructors", function()
local inputColor3 = {
Type = "Color3",
Value = {0, 1, 0.5},
}
local outputColor3 = Color3.new(0, 1, 0.5)
local inputCFrame = {
Type = "CFrame",
Value = {
1, 2, 3,
4, 5, 6,
7, 8, 9,
10, 11, 12,
},
}
local outputCFrame = CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
expect(rojoValueToRobloxValue(inputColor3)).to.equal(outputColor3)
expect(rojoValueToRobloxValue(inputCFrame)).to.equal(outputCFrame)
end)
end

View File

@@ -1,5 +1,4 @@
return function(TestEZ) return function()
local Rojo = script.Parent.Parent local TestEZ = require(script.Parent.Parent.TestEZ)
TestEZ.TestBootstrap:run(script.Parent)
TestEZ.TestBootstrap:run({ Rojo.Plugin, Rojo.Http, Rojo.Log })
end end

View File

@@ -1,4 +1,4 @@
local RbxDom = require(script.Parent.Parent.RbxDom) local RbxDom = require(script:FindFirstAncestor("Rojo").RbxDom)
--[[ --[[
Attempts to set a property on the given instance. Attempts to set a property on the given instance.

View File

@@ -1,24 +0,0 @@
local function strictInner(name, target)
assert(type(name) == "string", "Argument #1 to `strict` must be a string or the table to modify")
assert(type(target) == "table", "Argument #2 to `strict` must be nil or the table to modify")
setmetatable(target, {
__index = function(_, key)
error(("%q is not a valid member of strict table %q"):format(tostring(key), name), 2)
end,
__newindex = function()
error(("Strict table %q is read-only"):format(name), 2)
end,
})
return target
end
return function(nameOrTarget, target)
if type(nameOrTarget) == "string" then
return strictInner(nameOrTarget, target)
else
return strictInner("<unnamed table>", target)
end
end

View File

@@ -12,7 +12,7 @@ if setDevSettings then
DevSettings:createTestSettings() DevSettings:createTestSettings()
end end
require(Rojo.Plugin.runTests)(TestEZ) TestEZ.TestBootstrap:run({Rojo.Plugin})
if setDevSettings then if setDevSettings then
DevSettings:resetValues() DevSettings:resetValues()

View File

@@ -1,6 +0,0 @@
{
"name": "TestEZ",
"tree": {
"$path": "modules/testez/lib"
}
}

Some files were not shown because too many files have changed in this diff Show More