Compare commits

..

6 Commits

Author SHA1 Message Date
Lucien Greathouse
bd7ab593d5 Release 0.4.12 2018-06-21 11:21:42 -07:00
Lucien Greathouse
c93da3f6b2 Update CHANGES 2018-06-21 11:19:22 -07:00
Lucien Greathouse
8b90e98696 Added a plugin action for the sync in command (#80) 2018-06-21 11:17:26 -07:00
Lucien Greathouse
bc40ec8a5a Update CHANGES 2018-06-21 11:11:03 -07:00
Lucien Greathouse
f19cbccdd5 Fix assertion failure when renaming files.
Fixes #78.
2018-06-21 11:10:28 -07:00
Lucien Greathouse
f25ae914e4 Add TODO about preserving partition roots in reconciliation 2018-06-20 18:23:24 -07:00
471 changed files with 4239 additions and 62081 deletions

View File

@@ -1,2 +0,0 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]

View File

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

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

19
.gitignore vendored
View File

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

15
.gitmodules vendored
View File

@@ -1,15 +1,18 @@
[submodule "plugin/modules/roact"]
path = plugin/modules/roact
url = https://github.com/Roblox/roact.git
[submodule "plugin/modules/rodux"]
path = plugin/modules/rodux
url = https://github.com/Roblox/rodux.git
[submodule "plugin/modules/roact-rodux"]
path = plugin/modules/roact-rodux
url = https://github.com/Roblox/roact-rodux.git
[submodule "plugin/modules/testez"]
path = plugin/modules/testez
url = https://github.com/Roblox/testez.git
[submodule "plugin/modules/lemur"]
path = plugin/modules/lemur
url = https://github.com/LPGhatguy/lemur.git
[submodule "plugin/modules/promise"]
path = plugin/modules/promise
url = https://github.com/LPGhatguy/roblox-lua-promise.git
[submodule "plugin/modules/t"]
path = plugin/modules/t
url = https://github.com/osyrisrblx/t.git
[submodule "plugin/modules/rbx-dom"]
path = plugin/modules/rbx-dom
url = http://github.com/rojo-rbx/rbx-dom

39
.travis.yml Normal file
View File

@@ -0,0 +1,39 @@
matrix:
include:
- 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: stable
script:
- cd server
- cargo test --verbose
- language: rust
rust: beta
script:
- cd server
- cargo test --verbose

View File

@@ -1,296 +0,0 @@
# Rojo Changelog
## Unreleased Changes for 0.6.x
## [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)
* Fixed an issue where `LocalizationTable` instances would have their column order randomized. ([#173](https://github.com/rojo-rbx/rojo/issues/173))
## [0.5.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.1) (October 4, 2019)
* Fixed an issue where Rojo would drop changes if they happened too quickly ([#252](https://github.com/rojo-rbx/rojo/issues/252))
* Improved diagnostics for when the Rojo plugin cannot create an instance.
* Updated dependencies
* This brings Rojo's reflection database from client release 395 to client release 404.
## [0.5.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0) (August 27, 2019)
* Changed `.model.json` naming, which may require projects to migrate ambiguous cases:
* The file name now takes precedence over the `Name` field in the model, like Rojo 0.4.x.
* The `Name` field of the top-level instance is now optional. It's recommended that you remove it from your models.
* Rojo will emit a warning when `Name` is specified and does not match the name from the file.
* Fixed `Rect` values being set to `0, 0, 0, 0` when synced with the Rojo plugin. ([#201](https://github.com/rojo-rbx/rojo/issues/201))
* Fixed live-syncing of `PhysicalProperties`, `NumberSequence`, and `ColorSequence` values
## [0.5.0 Alpha 13](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.13) (August 2, 2019)
* Bumped minimum Rust version to 1.34.0.
* Fixed default port documentation in `rojo serve --help` ([#219](https://github.com/rojo-rbx/rojo/issues/219))
* Fixed BrickColor support by upgrading Roblox-related dependencies
## [0.5.0 Alpha 12](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.12) (July 2, 2019)
* Added `.meta.json` files
* `init.meta.json` files replace `init.model.json` files from Rojo 0.4.x ([#183](https://github.com/rojo-rbx/rojo/pull/183))
* Other `.meta.json` files allow attaching extra data to other files ([#189](https://github.com/rojo-rbx/rojo/pull/189))
* Added support for infinite and NaN values in types like `Vector2` when building models and places.
* These types aren't supported for live-syncing yet due to limitations around JSON encoding.
* Added support for using `SharedString` values when building XML models and places.
* Added support for live-syncing `CollectionService` tags.
* Added a warning when building binary place files, since they're still experimental and have bugs.
* Added a warning when trying to use Rojo 0.5.x with a Rojo 0.4.x-only project.
* Added a warning when a Rojo project contains keys that start with `$`, which are reserved names. ([#191](https://github.com/rojo-rbx/rojo/issues/191))
* Rojo now throws an error if unknown keys are found most files.
* Added an icon to the plugin's toolbar button
* Changed the plugin to use a docking widget for all UI.
* Changed the plugin to ignore unknown properties when live-syncing.
* Rojo's approach to this problem might change later, like with a strict model mode ([#190](https://github.com/rojo-rbx/rojo/issues/190)) or another approach.
* Upgraded to reflection database from client release 388.
* Updated Rojo's branding to shift the color palette to make it work better on dark backgrounds
## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019)
* Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154))
* `Content` propertyes can now be specified in projects and model files as regular string literals.
* Added support for `BrickColor` properties.
* Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`.
* Improved performance when working with XML models and places
* Fixed serializing empty `Content` properties as XML
* Fixed serializing infinite and NaN floating point properties in XML
* Improved compatibility with XML models
* Plugin should now be able to live-sync more properties, and ignore ones it can't, like `Lighting.Technology`.
## 0.5.0 Alpha 10
* This release was a dud due to [issue #176](https://github.com/rojo-rbx/rojo/issues/176) and was rolled back.
## [0.5.0 Alpha 9](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.9) (April 4, 2019)
* Changed `rojo build` to use buffered I/O, which can make it up to 2x faster in some cases.
* Building [*Road Not Taken*](https://github.com/rojo-rbx/roads) to an `rbxlx` file dropped from 150ms to 70ms on my machine
* Fixed `LocalizationTable` instances being made from `csv` files incorrectly interpreting empty rows and columns. ([#149](https://github.com/rojo-rbx/rojo/pull/149))
* Fixed CSV files with entries that parse as numbers causing Rojo to panic. ([#152](https://github.com/rojo-rbx/rojo/pull/152))
* Improved error messages when malformed CSV files are found in a Rojo project.
## [0.5.0 Alpha 8](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.8) (March 29, 2019)
* Added support for a bunch of new types when dealing with XML model/place files:
* `ColorSequence`
* `Float64`
* `Int64`
* `NumberRange`
* `NumberSequence`
* `PhysicalProperties`
* `Ray`
* `Rect`
* `Ref`
* Improved server instance ordering behavior when files are added during a live session ([#135](https://github.com/rojo-rbx/rojo/pull/135))
* Fixed error being thrown when trying to unload the Rojo plugin.
* Added partial fix for [issue #141](https://github.com/rojo-rbx/rojo/issues/141) for `Lighting.Technology`, which should restore live sync functionality for the default project file.
## [0.5.0 Alpha 6](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.6) (March 19, 2019)
* Fixed `rojo init` giving unexpected results by upgrading to `rbx_dom_weak` 1.1.0
* Fixed live server not responding when the Rojo plugin is connected ([#133](https://github.com/rojo-rbx/rojo/issues/133))
* Updated default place file:
* Improved default properties to be closer to Studio's built-in 'Baseplate' template
* Added a baseplate to the project file (Thanks, [@AmaranthineCodices](https://github.com/AmaranthineCodices/)!)
* Added more type support to Rojo plugin
* Fixed some cases where the Rojo plugin would leave around objects that it knows should be deleted
* Updated plugin to correctly listen to `Plugin.Unloading` when installing or uninstalling new plugins
## [0.5.0 Alpha 5](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.5) (March 1, 2019)
* Upgraded core dependencies, which improves compatibility for lots of instance types
* Upgraded from `rbx_tree` 0.2.0 to `rbx_dom_weak` 1.0.0
* Upgraded from `rbx_xml` 0.2.0 to `rbx_xml` 0.4.0
* Upgraded from `rbx_binary` 0.2.0 to `rbx_binary` 0.4.0
* Added support for non-primitive types in the Rojo plugin.
* Types like `Color3` and `CFrame` can now be updated live!
* Fixed plugin assets flashing in on first load ([#121](https://github.com/rojo-rbx/rojo/issues/121))
* Changed Rojo's HTTP server from Rouille to Hyper, which reduced the release size by around a megabyte.
* Added property type inference to projects, which makes specifying services a lot easier ([#130](https://github.com/rojo-rbx/rojo/pull/130))
* Made error messages from invalid and missing files more user-friendly
## [0.5.0 Alpha 4](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.4) (February 8, 2019)
* Added support for nested partitions ([#102](https://github.com/rojo-rbx/rojo/issues/102))
* Added support for 'transmuting' partitions ([#112](https://github.com/rojo-rbx/rojo/issues/112))
* Added support for aliasing filesystem paths ([#105](https://github.com/rojo-rbx/rojo/issues/105))
* Changed Windows builds to statically link the CRT ([#89](https://github.com/rojo-rbx/rojo/issues/89))
## [0.5.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.3) (February 1, 2019)
* Changed default project file name from `roblox-project.json` to `default.project.json` ([#120](https://github.com/rojo-rbx/rojo/pull/120))
* The old file name will still be supported until 0.5.0 is fully released.
* Added warning when loading project files that don't end in `.project.json`
* This new extension enables Rojo to distinguish project files from random JSON files, which is necessary to support nested projects.
* Added new (empty) diagnostic page served from the server
* Added better error messages for when a file is missing that's referenced by a Rojo project
* Added support for visualization endpoints returning GraphViz source when Dot is not available
* Fixed an in-memory filesystem regression introduced recently ([#119](https://github.com/rojo-rbx/rojo/pull/119))
## [0.5.0 Alpha 2](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.2) (January 28, 2019)
* Added support for `.model.json` files, compatible with 0.4.x
* Fixed in-memory filesystem not handling out-of-order filesystem change events
* Fixed long-polling error caused by a promise mixup ([#110](https://github.com/rojo-rbx/rojo/issues/110))
## [0.5.0 Alpha 1](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.1) (January 25, 2019)
* Changed plugin UI to be way prettier
* Thanks to [Reselim](https://github.com/Reselim) for the design!
* Changed plugin error messages to be a little more useful
* Removed unused 'Config' button in plugin UI
* Fixed bug where bad server responses could cause the plugin to be in a bad state
* Upgraded to rbx\_tree, rbx\_xml, and rbx\_binary 0.2.0, which dramatically expands the kinds of properties that Rojo can handle, especially in XML.
## [0.5.0 Alpha 0](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.0) (January 14, 2019)
* "Epiphany" rewrite, in progress since the beginning of time
* New live sync protocol
* Uses HTTP long polling to reduce request count and improve responsiveness
* New project format
* Hierarchical, preventing overlapping partitions
* Added `rojo build` command
* Generates `rbxm`, `rbxmx`, `rbxl`, or `rbxlx` files out of your project
* Usage: `rojo build <PROJECT> --output <OUTPUT>.rbxm`
* Added `rojo upload` command
* Generates and uploads a place or model to roblox.com out of your project
* Usage: `rojo upload <PROJECT> --cookie "<ROBLOSECURITY>" --asset_id <PLACE_ID>`
* New plugin
* Only one button now, "Connect"
* New UI to pick server address and port
* Better error reporting
* Added support for `.csv` files turning into `LocalizationTable` instances
* Added support for `.txt` files turning into `StringValue` instances
* Added debug visualization code to diagnose problems
* `/visualize/rbx` and `/visualize/imfs` show instance and file state respectively; they require GraphViz to be installed on your machine.
* Added optional place ID restrictions to project files
* This helps prevent syncing in content to the wrong place
* Multiple places can be specified, like when building a multi-place game
* Added support for specifying properties on services in project files
## [0.4.13](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.13) (November 12, 2018)
* When `rojo.json` points to a file or directory that does not exist, Rojo now issues a warning instead of throwing an error and exiting
## [0.4.12](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.12) (June 21, 2018)
* Fixed obscure assertion failure when renaming or deleting files ([#78](https://github.com/rojo-rbx/rojo/issues/78))
* Added a `PluginAction` for the sync in command, which should help with some automation scripts ([#80](https://github.com/rojo-rbx/rojo/pull/80))
## [0.4.11](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.11) (June 10, 2018)
* Defensively insert existing instances into RouteMap; should fix most duplication cases when syncing into existing trees.
* Fixed incorrect synchronization from `Plugin:_pull` that would cause polling to create issues
* Fixed incorrect file routes being assigned to `init.lua` and `init.model.json` files
* Untangled route handling-internals slightly
## [0.4.10](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.10) (June 2, 2018)
* Added support for `init.model.json` files, which enable versioning `Tool` instances (among other things) with Rojo. ([#66](https://github.com/rojo-rbx/rojo/issues/66))
* Fixed obscure error when syncing into an invalid service.
* Fixed multiple sync processes occurring when a server ID mismatch is detected.
## [0.4.9](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.9) (May 26, 2018)
* Fixed warning when renaming or removing files that would sometimes corrupt the instance cache ([#72](https://github.com/rojo-rbx/rojo/pull/72))
* JSON models are no longer as strict -- `Children` and `Properties` are now optional.
## [0.4.8](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.8) (May 26, 2018)
* Hotfix to prevent errors from being thrown when objects managed by Rojo are deleted
## [0.4.7](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.7) (May 25, 2018)
* Added icons to the Rojo plugin, made by [@Vorlias](https://github.com/Vorlias)! ([#70](https://github.com/rojo-rbx/rojo/pull/70))
* Server will now issue a warning if no partitions are specified in `rojo serve` ([#40](https://github.com/rojo-rbx/rojo/issues/40))
## [0.4.6](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.6) (May 21, 2018)
* Rojo handles being restarted by Roblox Studio more gracefully ([#67](https://github.com/rojo-rbx/rojo/issues/67))
* Folders should no longer get collapsed when syncing occurs.
* **Significant** robustness improvements with regards to caching.
* **This should catch all existing script duplication bugs.**
* If there are any bugs with script duplication or caching in the future, restarting the Rojo server process will fix them for that session.
* Fixed message in plugin not being prefixed with `Rojo: `.
## [0.4.5](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.5) (May 1, 2018)
* Rojo messages are now prefixed with `Rojo: ` to make them stand out in the output more.
* Fixed server to notice file changes *much* more quickly. (200ms vs 1000ms)
* Server now lists name of project when starting up.
* Rojo now throws an error if no project file is found. ([#63](https://github.com/rojo-rbx/rojo/issues/63))
* Fixed multiple sync operations occuring at the same time. ([#61](https://github.com/rojo-rbx/rojo/issues/61))
* Partitions targeting files directly now work as expected. ([#57](https://github.com/rojo-rbx/rojo/issues/57))
## [0.4.4](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.4) (April 7, 2018)
* Fix small regression introduced in 0.4.3
## [0.4.3](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.3) (April 7, 2018)
* Plugin now automatically selects `HttpService` if it determines that HTTP isn't enabled ([#58](https://github.com/rojo-rbx/rojo/pull/58))
* Plugin now has much more robust handling and will wipe all state when the server changes.
* This should fix issues that would otherwise be solved by restarting Roblox Studio.
## [0.4.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.2) (April 4, 2018)
* Fixed final case of duplicated instance insertion, caused by reconciled instances not being inserted into `RouteMap`.
* The reconciler is still not a perfect solution, especially if script instances get moved around without being destroyed. I don't think this can be fixed before a big refactor.
## [0.4.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.1) (April 1, 2018)
* Merged plugin repository into main Rojo repository for easier tracking.
* Improved `RouteMap` object tracking; this should fix some cases of duplicated instances being synced into the tree.
## [0.4.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.0) (March 27, 2018)
* Protocol version 1, which shifts more responsibility onto the server
* This is a **major breaking** change!
* The server now has a content of 'filter plugins', which transform data at various stages in the pipeline
* The server now exposes Roblox instance objects instead of file contents, which lines up with how `rojo pack` will work, and paves the way for more robust syncing.
* Added `*.model.json` files, which let you embed small Roblox objects into your Rojo tree.
* Improved error messages in some cases ([#46](https://github.com/rojo-rbx/rojo/issues/46))
## [0.3.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.3.2) (December 20, 2017)
* Fixed `rojo serve` failing to correctly construct an absolute root path when passed as an argument
* Fixed intense CPU usage when running `rojo serve`
## [0.3.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.3.1) (December 14, 2017)
* Improved error reporting when invalid JSON is found in a `rojo.json` project
* These messages are passed on from Serde
## [0.3.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.3.0) (December 12, 2017)
* Factored out the plugin into a separate repository
* Fixed server when using a file as a partition
* Previously, trailing slashes were put on the end of a partition even if the read request was an empty string. This broke file reading on Windows when a partition pointed to a file instead of a directory!
* Started running automatic tests on Travis CI (#9)
## [0.2.3](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.3) (December 4, 2017)
* Plugin only release
* Tightened `init` file rules to only match script files
* Previously, Rojo would sometimes pick up the wrong file when syncing
## [0.2.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.2) (December 1, 2017)
* Plugin only release
* Fixed broken reconciliation behavior with `init` files
## [0.2.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.1) (December 1, 2017)
* Plugin only release
* Changes default port to 8000
## [0.2.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.0) (December 1, 2017)
* Support for `init.lua` like rbxfs and rbxpacker
* More robust syncing with a new reconciler
## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017)
* Initial release, functionally very similar to [rbxfs](https://github.com/rojo-rbx/rbxfs)

104
CHANGES.md Normal file
View File

@@ -0,0 +1,104 @@
# Rojo Change Log
## Current master
* *No changes*
## 0.4.12 (June 21, 2018)
* Fixed obscure assertion failure when renaming or deleting files ([#78](https://github.com/LPGhatguy/rojo/issues/78))
* Added a `PluginAction` for the sync in command, which should help with some automation scripts ([#80](https://github.com/LPGhatguy/rojo/pull/80))
## 0.4.11 (June 10, 2018)
* Defensively insert existing instances into RouteMap; should fix most duplication cases when syncing into existing trees.
* Fixed incorrect synchronization from `Plugin:_pull` that would cause polling to create issues
* Fixed incorrect file routes being assigned to `init.lua` and `init.model.json` files
* Untangled route handling-internals slightly
## 0.4.10 (June 2, 2018)
* Added support for `init.model.json` files, which enable versioning `Tool` instances (among other things) with Rojo. ([#66](https://github.com/LPGhatguy/rojo/issues/66))
* Fixed obscure error when syncing into an invalid service.
* Fixed multiple sync processes occurring when a server ID mismatch is detected.
## 0.4.9 (May 26, 2018)
* Fixed warning when renaming or removing files that would sometimes corrupt the instance cache ([#72](https://github.com/LPGhatguy/rojo/pull/72))
* JSON models are no longer as strict -- `Children` and `Properties` are now optional.
## 0.4.8 (May 26, 2018)
* Hotfix to prevent errors from being thrown when objects managed by Rojo are deleted
## 0.4.7 (May 25, 2018)
* Added icons to the Rojo plugin, made by [@Vorlias](https://github.com/Vorlias)! ([#70](https://github.com/LPGhatguy/rojo/pull/70))
* Server will now issue a warning if no partitions are specified in `rojo serve` ([#40](https://github.com/LPGhatguy/rojo/issues/40))
## 0.4.6 (May 21, 2018)
* Rojo handles being restarted by Roblox Studio more gracefully ([#67](https://github.com/LPGhatguy/rojo/issues/67))
* Folders should no longer get collapsed when syncing occurs.
* **Significant** robustness improvements with regards to caching.
* **This should catch all existing script duplication bugs.**
* If there are any bugs with script duplication or caching in the future, restarting the Rojo server process will fix them for that session.
* Fixed message in plugin not being prefixed with `Rojo: `.
## 0.4.5 (May 1, 2018)
* Rojo messages are now prefixed with `Rojo: ` to make them stand out in the output more.
* Fixed server to notice file changes *much* more quickly. (200ms vs 1000ms)
* Server now lists name of project when starting up.
* Rojo now throws an error if no project file is found. ([#63](https://github.com/LPGhatguy/rojo/issues/63))
* Fixed multiple sync operations occuring at the same time. ([#61](https://github.com/LPGhatguy/rojo/issues/61))
* Partitions targeting files directly now work as expected. ([#57](https://github.com/LPGhatguy/rojo/issues/57))
## 0.4.4 (April 7, 2018)
* Fix small regression introduced in 0.4.3
## 0.4.3 (April 7, 2018)
* Plugin now automatically selects `HttpService` if it determines that HTTP isn't enabled ([#58](https://github.com/LPGhatguy/rojo/pull/58))
* Plugin now has much more robust handling and will wipe all state when the server changes.
* This should fix issues that would otherwise be solved by restarting Roblox Studio.
## 0.4.2 (April 4, 2018)
* Fixed final case of duplicated instance insertion, caused by reconciled instances not being inserted into `RouteMap`.
* The reconciler is still not a perfect solution, especially if script instances get moved around without being destroyed. I don't think this can be fixed before a big refactor.
## 0.4.1 (April 1, 2018)
* Merged plugin repository into main Rojo repository for easier tracking.
* Improved `RouteMap` object tracking; this should fix some cases of duplicated instances being synced into the tree.
## 0.4.0 (March 27, 2018)
* Protocol version 1, which shifts more responsibility onto the server
* This is a **major breaking** change!
* The server now has a content of 'filter plugins', which transform data at various stages in the pipeline
* The server now exposes Roblox instance objects instead of file contents, which lines up with how `rojo pack` will work, and paves the way for more robust syncing.
* Added `*.model.json` files, which let you embed small Roblox objects into your Rojo tree.
* Improved error messages in some cases ([#46](https://github.com/LPGhatguy/rojo/issues/46))
## 0.3.2 (December 20, 2017)
* Fixed `rojo serve` failing to correctly construct an absolute root path when passed as an argument
* Fixed intense CPU usage when running `rojo serve`
## 0.3.1 (December 14, 2017)
* Improved error reporting when invalid JSON is found in a `rojo.json` project
* These messages are passed on from Serde
## 0.3.0 (December 12, 2017)
* Factored out the plugin into a separate repository
* Fixed server when using a file as a partition
* Previously, trailing slashes were put on the end of a partition even if the read request was an empty string. This broke file reading on Windows when a partition pointed to a file instead of a directory!
* Started running automatic tests on Travis CI (#9)
## 0.2.3 (December 4, 2017)
* Plugin only release
* Tightened `init` file rules to only match script files
* Previously, Rojo would sometimes pick up the wrong file when syncing
## 0.2.2 (December 1, 2017)
* Plugin only release
* Fixed broken reconciliation behavior with `init` files
## 0.2.1 (December 1, 2017)
* Plugin only release
* Changes default port to 8000
## 0.2.0 (December 1, 2017)
* Support for `init.lua` like rbxfs and rbxpacker
* More robust syncing with a new reconciler
## 0.1.0 (November 29, 2017)
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)

View File

@@ -1,55 +0,0 @@
# Contributing to the Rojo Project
Rojo is a big project and can always use more help! This guide covers all repositories underneath the [rojo-rbx organization on GitHub](https://github.com/rojo-rbx).
Some of the repositories covered are:
* https://github.com/rojo-rbx/rojo
* https://github.com/rojo-rbx/rbx-dom
* https://github.com/rojo-rbx/vscode-rojo
* https://github.com/rojo-rbx/rbxlx-to-rojo
## 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!
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 impacts way more people than the individual lines of code we write.
If you find any problems in documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
## Bug Reports and Feature Requests
Most of the tools around Rojo try to be clear when an issue is a bug. Even if they aren't, sometimes things don't work quite right.
Sometimes there's something that Rojo doesn't do that it probably should.
Please file issues and we'll try to help figure out what the best way forward is.
## Pushing a Rojo Release
The Rojo release process is pretty manual right now. If you need to do it, here's how:
1. Bump server version in [`server/Cargo.toml`](server/Cargo.toml)
2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua)
3. Run `cargo test` to update `Cargo.lock` and double-check tests
4. Update [`CHANGELOG.md`](CHANGELOG.md)
5. Commit!
* `git add . && git commit -m "Release vX.Y.Z"`
6. Tag the commit with the version from `Cargo.toml` prepended with a v, like `v0.4.13`
7. Build Windows release build of CLI
* `cargo build --release`
7. Publish the CLI
* `cargo publish`
8. Build and upload the plugin
* `rojo build plugin -o Rojo.rbxm`
* Upload `Rojo.rbxm` to Roblox.com, keep it for later
9. Push commits and tags
* `git push && git push --tags`
10. Copy GitHub release content from previous release
* Update the leading text with a summary about the release
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)
* Write a small summary of each major feature

2797
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +0,0 @@
[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]
members = [
"rojo-test",
"rojo-insta-ext",
"clibrojo",
"vfs",
]
default-members = [
".",
"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"

49
DESIGN.md Normal file
View File

@@ -0,0 +1,49 @@
# Rojo Design - Protocol Version 1
This is a super rough draft that I'm trying to use to lay out some of my thoughts.
## API
### POST `/read`
Accepts a `Vec<Route>` of items to read.
Returns `Vec<Option<RbxInstance>>`, in the same order as the request.
### POST `/write`
Accepts a `Vec<{ Route, RbxInstance }>` of items to write.
I imagine that the `Name` attribute of the top-level `RbxInstance` would be ignored in favor of the route name?
## CLI
The `rojo serve` command uses three major components:
* A Virtual Filesystem (VFS), which exposes the filesystem as `VfsItem` objects
* A VFS watcher, which tracks changes to the filesystem and logs them
* An HTTP API, which exposes an interface to the Roblox Studio plugin
### Transform Plugins
Transform plugins (or filter plugins?) can interject in three places:
* Transform a `VfsItem` that's being read into an `RbxInstance` in the VFS
* Transform an `RbxInstance` that's being written into a `VfsItem` in the VFS
* Transform a file change into paths that need to be updated in the VFS watcher
The plan is to have several built-in plugins that can be rearranged/configured in project settings:
* Base plugin
* Transforms all unhandled files to/from StringValue objects
* Script plugin
* Transforms `*.lua` files to their appropriate file types
* JSON/rbxmx/rbxlx model plugin
* External binary plugin
* User passes a binary name (like `moonc`) that modifies file contents
## Roblox Studio Plugin
With the protocol version 1 change, the Roblox Studio plugin got a lot simpler. Notably, the plugin doesn't need to be aware of anything about the filesystem's semantics, which is super handy.
## Bi-directional syncing
Quenty laid out a good way to handle bi-directional syncing.
When receiving a change from the plugin:
1. Hash the new contents of the file, store it in a map from routes to hashes
2. Write the new file contents to the filesystem
3. Later down the line, receive a change event from the filesystem watcher
4. When receiving a change, if the item is in the hash map, read it and hash those contents
5. If the hash matches the last noted hash, discard the change, else continue as normal

View File

@@ -1,55 +1,60 @@
<div align="center">
<a href="https://rojo.space">
<img src="assets/logo-512.png" alt="Rojo" height="217" />
</a>
<img src="assets/rojo-logo.png" alt="Rojo" height="217" />
</div>
<div>&nbsp;</div>
<div align="center">
<a href="https://github.com/rojo-rbx/rojo/actions">
<img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" />
<a href="https://travis-ci.org/LPGhatguy/rojo">
<img src="https://api.travis-ci.org/LPGhatguy/rojo.svg?branch=master" alt="Travis-CI Build Status" />
</a>
<a href="https://crates.io/crates/rojo">
<img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" />
</a>
<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/latest_version-0.4.12-brightgreen.svg" alt="Current server version" />
<a href="https://lpghatguy.github.io/rojo">
<img src="https://img.shields.io/badge/documentation-website-brightgreen.svg" alt="Rojo Documentation" />
</a>
</div>
<hr />
**Rojo** is a tool designed to enable Roblox developers to use professional-grade software engineering tools.
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
With Rojo, it's possible to use industry-leading tools like **Visual Studio Code** and **Git**.
Rojo is designed for power users who want to use the best tools available for building games, libraries, and plugins.
It's designed for power users who want to use the **best tools available** for building games, libraries, and plugins.
## Features
Rojo enables:
Rojo lets you:
* Working on scripts and models from the filesystem, in your favorite editor
* Versioning your game, library, or plugin using Git or another VCS
* Streaming `rbxmx` and `rbxm` models into your game in real time
* Packaging and deploying your project to Roblox.com from the command line
* Work on scripts from the filesystem, in your favorite editor
* Version your place, library, or plugin using Git or another VCS
* Sync JSON-format models from the filesystem into your game
Soon, Rojo will be able to:
Later this year, Rojo will be able to:
* Automatically convert your existing game to work with Rojo
* Sync instances from Roblox Studio to the filesystem
* Automatically manage your assets on Roblox.com, like images and sounds
* Import custom instances like MoonScript code
* Sync `rbxmx` models between the filesystem and Roblox Studio
* Package projects into `rbxmx` files from the command line
## [Documentation](https://rojo.space/docs)
Documentation is hosted in the [rojo.space repository](https://github.com/rojo-rbx/rojo.space).
## [Documentation Website](https://lpghatguy.github.io/rojo)
You can also view the documentation by browsing the [docs folder of the repository](https://github.com/LPGhatguy/rojo/tree/master/docs), but because it uses a number of Markdown extensions, it may not be very readable.
## Inspiration
There are lots of other tools that sync scripts into Roblox or provide other tools for working with Roblox places.
Here are a few, if you're looking for alternatives or supplements to Rojo:
* [Studio Bridge by Vocksel](https://github.com/vocksel/studio-bridge)
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
I also have a couple tools that Rojo intends to replace:
* [rbxfs](https://github.com/LPGhatguy/rbxfs), which has been deprecated by Rojo
* [rbxpacker](https://github.com/LPGhatguy/rbxpacker), which is still useful
## Contributing
Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions for helping work on Rojo!
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.
All pull requests are run against a test suite on Travis CI. That test suite should always pass!
## License
Rojo is available under the terms of the 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](LICENSE) 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.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,66 +0,0 @@
{
"name": "[placeholder]",
"tree": {
"$className": "DataModel",
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
},
"Lighting": {
"$className": "Lighting",
"$properties": {
"Ambient": [
0,
0,
0
],
"Brightness": 2,
"GlobalShadows": true,
"Outlines": false,
"Technology": "Voxel"
}
},
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Source": {
"$path": "src"
}
},
"SoundService": {
"$className": "SoundService",
"$properties": {
"RespectFilteringEnabled": true
}
},
"Workspace": {
"$className": "Workspace",
"$properties": {
"FilteringEnabled": true
},
"Baseplate": {
"$className": "Part",
"$properties": {
"Anchored": true,
"Color": [
0.38823,
0.37254,
0.38823
],
"Locked": true,
"Position": [
0,
-10,
0
],
"Size": [
512,
20,
512
]
}
}
}
}
}

BIN
assets/rojo-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
assets/rojo-plugin-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

BIN
assets/rojo-sync-in.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

BIN
assets/rojo-test-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 B

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

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
}

View File

@@ -0,0 +1,77 @@
# Creating a Project
To use Rojo, you'll need to create a new project file, which tells Rojo what your project is, and how to load it into Roblox Studio.
## New Project
Create a new folder, then run `rojo init` inside that folder to initialize an empty project.
```sh
mkdir my-new-project
cd my-new-project
rojo init
```
Rojo will create an empty project file named `rojo.json` in the directory.
The default configuration doesn't do anything. We need to tell Rojo where our code is on the filesystem, and where we want to put it in the Roblox tree.
To do that, open up `rojo.json` and add an entry to the `partitions` table:
```json
{
"name": "your-project-name-here",
"servePort": 8000,
"partitions": {
"src": {
"path": "src",
"target": "ReplicatedStorage.Project"
}
}
}
```
!!! warning
Make sure that the `src` directory exists in your project, or Rojo will throw an error!
!!! warning
Any objects contained in the `target` of a partition will be destroyed by Rojo if not found on the filesystem!
A Rojo project has one or more *partitions*. Partitions define how code should be transferred between the filesystem and Roblox by mapping directories and files to Roblox objects.
Each partition has:
* A name (the key in the `partitions` object), which is used for debugging
* `path`, the location on the filesystem relative to `rojo.json`
* `target`, the location in Roblox relative to `game`
## Syncing into Studio
Once you've added your partition to the project file, you can start the Rojo dev server by running a command in your project's directory:
```sh
rojo serve
```
If your project is in the right place, Rojo will let you know that it was found and start an HTTP server that the plugin can connect to.
In Roblox Studio, open the plugins tab and find Rojo's buttons.
![Location of Rojo's plugin buttons in Roblox Studio](/images/plugin-buttons.png)
{: align="center" }
Press **Test Connection** to verify that the plugin can communicate with the dev server. Watch the Output panel for the results.
!!! info
If you see an error message, return to the previous steps and make sure that the Rojo dev server is running.
![Rojo error in Roblox Studio Output](/images/connection-error.png)
{: align="center" }
After your connection was successful, press **Sync In** to move code from the filesystem into Studio, or use **Toggle Polling** to have Rojo automatically sync in changes as they happen.
## Importing an Existing Project
Rojo will eventually support importing an existing Roblox project onto the filesystem for use with Rojo.
Rojo doesn't currently support converting an existing project or syncing files from Roblox Studio onto the filesystem. In the mean time, you can manually copy your files into the structure that Rojo expects.
Up-to-date information will be available on [issue #5](https://github.com/LPGhatguy/rojo/issues/5) as this is worked on.

View File

@@ -0,0 +1,26 @@
# Installation
Rojo has two components:
* The server, a binary written in Rust
* The plugin, a Roblox Studio plugin written in Lua
It's important that the plugin and server are compatible. The plugin will show errors in the Roblox Studio Output window if there is a version mismatch.
## Installing the Server
To install the server, either:
* If you have Rust installed, use `cargo install rojo`
* Or, download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
**The Rojo binary must be run from the command line, like Terminal on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo binary on your `PATH` to make this easier.**
## Installing the Plugin
To install the plugin, either:
* Install the plugin from the [Roblox plugin page](https://www.roblox.com/library/1211549683/Rojo).
* This gives you less control over what version you install -- you will always have the latest version.
* Or, download the latest release from [the GitHub releases section](https://github.com/LPGhatguy/rojo/releases) and install it into your Roblox plugins folder
* You can open this folder by clicking the "Plugins Folder" button from the Plugins toolbar in Roblox Studio
## Visual Studio Code Extension
If you use Visual Studio Code on Windows, you can install [Evaera's unofficial Rojo extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo), which will install both halves of Rojo for you. It even has a nifty UI to add partitions and start/stop the Rojo server!

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

6
docs/index.md Normal file
View File

@@ -0,0 +1,6 @@
# Home
This is the documentation home for Rojo.
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
This documentation is a work in progress, and is incomplete.

3
docs/requirements.txt Normal file
View File

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

65
docs/sync-details.md Normal file
View File

@@ -0,0 +1,65 @@
# Sync Details
This page aims to describe how Rojo turns files on the filesystem into Roblox objects.
## Folders
Any directory on the filesystem will turn into a `Folder` instance in Roblox, unless that folder matches the name of a service or other existing instance. In those cases, that instance will be preserved.
## Scripts
Rojo can represent `ModuleScript`, `Script`, and `LocalScript` objects. The default script type is `ModuleScript`, since most scripts in well-structued Roblox projects will be modules.
| File Name | Instance Type |
| -------------- | -------------- |
| `*.server.lua` | `Script` |
| `*.client.lua` | `LocalScript` |
| `*.lua` | `ModuleScript` |
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 conents of the `init` file. This can be used to create scripts inside of scripts.
For example, this file tree:
* my-game
* init.client.lua
* foo.lua
Will turn into these instances in Roblox:
![Example of Roblox instances](/images/sync-example.png)
## Models
Rojo supports a JSON model format for representing simple models. It's designed for instance types like `BindableEvent` or `Value` objects, and is not suitable for larger models.
Rojo JSON models are stored in `.model.json` files.
Starting in Rojo version **0.4.10**, model files named `init.model.json` that are located in folders will replace that folder, much like Rojo's `init.lua` support. This can be useful to version instances like `Tool` that tend to contain several instances as well as one or more scripts.
!!! info
In the future, Rojo will support `.rbxmx` models. See [issue #7](https://github.com/LPGhatguy/rojo/issues/7) for more details and updates on this feature.
!!! warning
Prior to Rojo version **0.4.9**, the `Properties` and `Children` properties are required on all instances in JSON models!
JSON model files are fairly strict; any syntax errors will cause the model to fail to sync! They look like this:
`hello.model.json`
```json
{
"Name": "hello",
"ClassName": "Model",
"Children": [
{
"Name": "Some Part",
"ClassName": "Part"
},
{
"Name": "Some StringValue",
"ClassName": "StringValue",
"Properties": {
"Value": {
"Type": "String",
"Value": "Hello, world!"
}
}
}
]
}
```

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

@@ -0,0 +1,21 @@
# Why Rojo?
There are a number of existing plugins for Roblox that move code from the filesystem into Roblox.
Besides Rojo, there is:
* [Studio Bridge](https://github.com/vocksel/studio-bridge) by [Vocksel](https://github.com/vocksel)
* [RbxRefresh](https://github.com/osyrisrblx/RbxRefresh) by [Osyris](https://github.com/osyrisrblx)
* [RbxSync](https://github.com/evaera/RbxSync) by [evaera](https://github.com/evaera)
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
* [rbxmk](https://github.com/anaminus/rbxmk) by [Anaminus](https://github.com/anaminus)
So why did I build Rojo?
Each of these tools solves what is essentially the same problem from a few different angles. The goal of Rojo is to take all of the lessons and ideas learned from these projects and build a tool that can solve the problem for good.
Additionally:
* I think that this tool needs to be built in a compiled language without a runtime, for easy distribution and good performance.
* I think that the conventions promoted by other sync plugins (`.module.lua` for modules, as well a single sync point) are sub-optimal.
* I think that I have a good enough understanding of the problem to build something robust.
* I think that Rojo should be able to do more than just sync code.

26
mkdocs.yml Normal file
View File

@@ -0,0 +1,26 @@
site_name: Rojo Documentation
site_url: https://lpghatguy.github.io/rojo/
repo_name: LPGhatguy/rojo
repo_url: https://github.com/LPGhatguy/rojo
theme:
name: material
palette:
primary: 'Red'
accent: 'Red'
pages:
- Home: index.md
- Why Rojo?: why-rojo.md
- Getting Started:
- Installation: getting-started/installation.md
- Creating a Project: getting-started/creating-a-project.md
- Sync Details: sync-details.md
markdown_extensions:
- attr_list
- admonition
- codehilite:
guess_lang: false
- toc:
permalink: true

4
plugin/.editorconfig Normal file
View File

@@ -0,0 +1,4 @@
[*.lua]
indent_style = tab
trim_trailing_whitespace = true
insert_final_newline = true

1
plugin/.gitignore vendored Normal file
View File

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

View File

@@ -13,14 +13,12 @@ stds.roblox = {
-- Types
"Vector2", "Vector3",
"Vector2int16", "Vector3int16",
"Color3",
"UDim", "UDim2",
"Rect",
"CFrame",
"Enum",
"Instance",
"DockWidgetPluginGuiInfo",
}
}
@@ -41,10 +39,6 @@ stds.testez = {
ignore = {
"212", -- unused arguments
"421", -- shadowing local variable
"422", -- shadowing argument
"431", -- shadowing upvalue
"432", -- shadowing upvalue argument
}
std = "lua51+roblox"

8
plugin/.luacov Normal file
View File

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

View File

@@ -1,30 +0,0 @@
{
"name": "Rojo",
"tree": {
"$className": "Folder",
"Plugin": {
"$path": "src"
},
"Log": {
"$path": "log"
},
"Http": {
"$path": "http"
},
"Fmt": {
"$path": "fmt"
},
"Roact": {
"$path": "modules/roact/src"
},
"Promise": {
"$path": "modules/promise/lib"
},
"t": {
"$path": "modules/t/lib"
},
"RbxDom": {
"$path": "modules/rbx-dom/rbx_dom_lua/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 Error = {}
Error.__index = Error
Error.Kind = {
HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" ..
"Check your game settings, located in the 'Home' tab of Studio.",
},
ConnectFailed = {
message = "Couldn't connect to the Rojo server.\n" ..
"Make sure the server is running -- use 'rojo serve' to run it!",
},
Timeout = {
message = "HTTP request timed out.",
},
Unknown = {
message = "Unknown HTTP error: {{message}}",
},
}
setmetatable(Error.Kind, {
__index = function(_, key)
error(("%q is not a valid member of Http.Error.Kind"):format(tostring(key)), 2)
end,
})
function Error.new(type, extraMessage)
extraMessage = extraMessage or ""
local message = type.message:gsub("{{message}}", extraMessage)
local err = {
type = type,
message = message,
}
setmetatable(err, Error)
return err
end
function Error:__tostring()
return self.message
end
--[[
This method shouldn't have to exist. Ugh.
]]
function Error.fromRobloxErrorString(message)
local lower = message:lower()
if lower:find("^http requests are not enabled") then
return Error.new(Error.Kind.HttpNotEnabled)
end
if lower:find("^httperror: timedout") then
return Error.new(Error.Kind.Timeout)
end
if lower:find("^httperror: connectfail") then
return Error.new(Error.Kind.ConnectFailed)
end
return Error.new(Error.Kind.Unknown, message)
end
return Error

View File

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

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

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

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 852c71b897

1
plugin/modules/rodux Submodule

Submodule plugin/modules/rodux added at b8ba486335

Submodule plugin/modules/t deleted from f643b50682

34
plugin/rojo.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "rojo",
"servePort": 8000,
"partitions": {
"plugin": {
"path": "src",
"target": "ReplicatedStorage.Rojo.plugin"
},
"modules/roact": {
"path": "modules/roact/lib",
"target": "ReplicatedStorage.Rojo.modules.Roact"
},
"modules/rodux": {
"path": "modules/rodux/lib",
"target": "ReplicatedStorage.Rojo.modules.Rodux"
},
"modules/roact-rodux": {
"path": "modules/roact-rodux/lib",
"target": "ReplicatedStorage.Rojo.modules.RoactRodux"
},
"modules/promise": {
"path": "modules/promise/lib",
"target": "ReplicatedStorage.Rojo.modules.Promise"
},
"modules/testez": {
"path": "modules/testez/lib",
"target": "ReplicatedStorage.TestEZ"
},
"tests": {
"path": "tests",
"target": "TestService"
}
}
}

69
plugin/spec.lua Normal file
View File

@@ -0,0 +1,69 @@
--[[
Loads our library and all of its dependencies, then runs tests using TestEZ.
]]
-- If you add any dependencies, add them to this table so they'll be loaded!
local LOAD_MODULES = {
{"src", "Plugin"},
{"modules/testez/lib", "TestEZ"},
}
-- This makes sure we can load Lemur and other libraries that depend on init.lua
package.path = package.path .. ";?/init.lua"
-- If this fails, make sure you've run `lua bin/install-dependencies.lua` first!
local lemur = require("modules.lemur")
--[[
Collapses ModuleScripts named 'init' into their parent folders.
This is the same result as the collapsing mechanism from Rojo.
]]
local function collapse(root)
local init = root:FindFirstChild("init")
if init then
init.Name = root.Name
init.Parent = root.Parent
for _, child in ipairs(root:GetChildren()) do
child.Parent = init
end
root:Destroy()
root = init
end
for _, child in ipairs(root:GetChildren()) do
if child:IsA("Folder") then
collapse(child)
end
end
return root
end
-- Create a virtual Roblox tree
local habitat = lemur.Habitat.new()
-- We'll put all of our library code and dependencies here
local Root = lemur.Instance.new("Folder")
Root.Name = "Root"
-- Load all of the modules specified above
for _, module in ipairs(LOAD_MODULES) do
local container = lemur.Instance.new("Folder", Root)
container.Name = module[2]
habitat:loadFromFs(module[1], container)
end
collapse(Root)
-- Load TestEZ and run our tests
local TestEZ = habitat:require(Root.TestEZ)
local results = TestEZ.TestBootstrap:run(Root.Plugin, TestEZ.Reporters.TextReporter)
-- Did something go wrong?
if results.failureCount > 0 then
os.exit(1)
end

114
plugin/src/Api.lua Normal file
View File

@@ -0,0 +1,114 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Parent.modules.Promise)
local Config = require(script.Parent.Config)
local Version = require(script.Parent.Version)
local Api = {}
Api.__index = Api
Api.Error = {
ServerIdMismatch = "ServerIdMismatch",
}
setmetatable(Api.Error, {
__index = function(_, key)
error("Invalid API.Error name " .. key, 2)
end
})
--[[
Api.connect(Http) -> Promise<Api>
Create a new Api using the given HTTP implementation.
Attempting to invoke methods on an invalid conext will throw errors!
]]
function Api.connect(http)
local context = {
http = http,
serverId = nil,
currentTime = 0,
}
setmetatable(context, Api)
return context:_start()
:andThen(function()
return context
end)
end
function Api:_start()
return self.http:get("/")
:andThen(function(response)
response = response:json()
if response.protocolVersion ~= Config.protocolVersion then
local message = (
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
"\nYour server is version %s, with protocol version %s." ..
"\n\nGo to https://github.com/LPGhatguy/rojo for more details."
):format(
Version.display(Config.version), Config.protocolVersion,
Config.expectedApiVersionString,
response.serverVersion, response.protocolVersion
)
return Promise.reject(message)
end
self.serverId = response.serverId
self.currentTime = response.currentTime
end)
end
function Api:getInfo()
return self.http:get("/")
:andThen(function(response)
response = response:json()
if response.serverId ~= self.serverId then
return Promise.reject(Api.Error.ServerIdMismatch)
end
return response
end)
end
function Api:read(paths)
local body = HttpService:JSONEncode(paths)
return self.http:post("/read", body)
:andThen(function(response)
response = response:json()
if response.serverId ~= self.serverId then
return Promise.reject(Api.Error.ServerIdMismatch)
end
return response.items
end)
end
function Api:getChanges()
local url = ("/changes/%f"):format(self.currentTime)
return self.http:get(url)
:andThen(function(response)
response = response:json()
if response.serverId ~= self.serverId then
return Promise.reject(Api.Error.ServerIdMismatch)
end
self.currentTime = response.currentTime
return response.changes
end)
end
return Api

View File

@@ -1,236 +0,0 @@
local Http = require(script.Parent.Parent.Http)
local Log = require(script.Parent.Parent.Log)
local Promise = require(script.Parent.Parent.Promise)
local Config = require(script.Parent.Config)
local Types = require(script.Parent.Types)
local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
--[[
Returns a promise that will never resolve nor reject.
]]
local function hangingPromise()
return Promise.new(function() end)
end
local function rejectFailedRequests(response)
if response.code >= 400 then
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
return Promise.reject(message)
end
return response
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)
assert(type(baseUrl) == "string")
local self = {
__baseUrl = baseUrl,
__sessionId = nil,
__messageCursor = -1,
__connected = true,
}
return setmetatable(self, ApiContext)
end
function ApiContext:__fmtDebug(output)
output:writeLine("ApiContext {{")
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
function ApiContext:connect()
local url = ("%s/api/rojo"):format(self.__baseUrl)
return Http.get(url)
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(rejectWrongProtocolVersion)
:andThen(function(body)
assert(validateApiInfo(body))
return body
end)
:andThen(rejectWrongPlaceId)
:andThen(function(body)
self.__sessionId = body.sessionId
return body
end)
end
function ApiContext:read(ids)
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url)
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRead(body))
return body
end)
end
function ApiContext:write(patch)
local url = ("%s/api/write"):format(self.__baseUrl)
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
-- Only add the 'added' field if the table is non-empty, or else Roblox's
-- 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()
return Http.get(url)
:catch(function(err)
if err.type == Http.Error.Kind.Timeout then
if self.__connected then
return sendRequest()
else
return hangingPromise()
end
end
return Promise.reject(err)
end)
end
return sendRequest()
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSubscribe(body))
self:setMessageCursor(body.messageCursor)
return body.messages
end)
end
return ApiContext

View File

@@ -1,34 +0,0 @@
local strict = require(script.Parent.strict)
local Assets = {
Sprites = {},
Slices = {
RoundBox = {
asset = "rbxassetid://2773204550",
offset = Vector2.new(0, 0),
size = Vector2.new(32, 32),
center = Rect.new(4, 4, 4, 4),
},
},
Images = {
Logo = "rbxassetid://3405346157",
Icon = "rbxassetid://3405341609",
},
StartSession = "",
SessionActive = "",
Configure = "",
}
local function guardForTypos(name, map)
strict(name, map)
for key, child in pairs(map) do
if type(child) == "table" then
guardForTypos(("%s.%s"):format(name, key), child)
end
end
end
guardForTypos("Assets", Assets)
return Assets

View File

@@ -1,225 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Log = require(Rojo.Log)
local ApiContext = require(Plugin.ApiContext)
local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config)
local DevSettings = require(Plugin.DevSettings)
local ServeSession = require(Plugin.ServeSession)
local Version = require(Plugin.Version)
local preloadAssets = require(Plugin.preloadAssets)
local strict = require(Plugin.strict)
local ConnectPanel = require(Plugin.Components.ConnectPanel)
local ConnectingPanel = require(Plugin.Components.ConnectingPanel)
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
local ErrorPanel = require(Plugin.Components.ErrorPanel)
local e = Roact.createElement
local function showUpgradeMessage(lastVersion)
local message = (
"Rojo detected an upgrade from version %s to version %s." ..
"\nMake sure you have also upgraded your server!" ..
"\n\nRojo plugin version %s is intended for use with server version %s."
):format(
Version.display(lastVersion), Version.display(Config.version),
Version.display(Config.version), Config.expectedServerVersionString
)
Log.info(message)
end
--[[
Check if the user is using a newer version of Rojo than last time. If they
are, show them a reminder to make sure they check their server version.
]]
local function checkUpgrade(plugin)
-- When developing Rojo, there's no use in doing version checks
if DevSettings:isEnabled() then
return
end
local lastVersion = plugin:GetSetting("LastRojoVersion")
if lastVersion then
local wasUpgraded = Version.compare(Config.version, lastVersion) == 1
if wasUpgraded then
showUpgradeMessage(lastVersion)
end
end
plugin:SetSetting("LastRojoVersion", Config.version)
end
local AppStatus = strict("AppStatus", {
NotStarted = "NotStarted",
Connecting = "Connecting",
Connected = "Connected",
Error = "Error",
})
local App = Roact.Component:extend("App")
function App:init()
self:setState({
appStatus = AppStatus.NotStarted,
errorMessage = nil,
})
self.signals = {}
self.serveSession = nil
self.displayedVersion = DevSettings:isEnabled()
and Config.codename
or Version.display(Config.version)
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
self.toggleButton = toolbar:CreateButton(
"Rojo",
"Show or hide the Rojo panel",
Assets.Images.Icon)
self.toggleButton.ClickableWhenViewportHidden = true
self.toggleButton.Click:Connect(function()
self.dockWidget.Enabled = not self.dockWidget.Enabled
end)
local widgetInfo = DockWidgetPluginGuiInfo.new(
Enum.InitialDockState.Right,
false, -- Initially enabled state
false, -- Whether to override the widget's previous state
360, 190, -- Floating size
360, 190 -- Minimum size
)
self.dockWidget = self.props.plugin:CreateDockWidgetPluginGui("Rojo-" .. self.displayedVersion, widgetInfo)
self.dockWidget.Name = "Rojo " .. self.displayedVersion
self.dockWidget.Title = "Rojo " .. self.displayedVersion
self.dockWidget.AutoLocalize = false
self.dockWidget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
self.signals.dockWidgetEnabled = self.dockWidget:GetPropertyChangedSignal("Enabled"):Connect(function()
self.toggleButton:SetActive(self.dockWidget.Enabled)
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()
local children
if self.state.appStatus == AppStatus.NotStarted 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 = {
ConnectionActivePanel = e(ConnectionActivePanel, {
stopSession = function()
Log.trace("Disconnecting session")
self.serveSession:stop()
self.serveSession = nil
self:setState({
appStatus = AppStatus.NotStarted,
})
Log.trace("Session terminated by user")
end,
}),
}
elseif self.state.appStatus == AppStatus.Error then
children = {
ErrorPanel = Roact.createElement(ErrorPanel, {
errorMessage = self.state.errorMessage,
onDismiss = function()
self:setState({
appStatus = AppStatus.NotStarted,
})
end,
}),
}
end
return Roact.createElement(Roact.Portal, {
target = self.dockWidget,
}, children)
end
function App:didMount()
Log.trace("Rojo {} initializing", self.displayedVersion)
checkUpgrade(self.props.plugin)
preloadAssets()
end
function App:willUnmount()
if self.serveSession ~= nil then
self.serveSession:stop()
self.serveSession = nil
end
for _, signal in pairs(self.signals) do
signal:Disconnect()
end
end
return App

View File

@@ -1,161 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Config = require(Plugin.Config)
local Theme = require(Plugin.Theme)
local Panel = require(Plugin.Components.Panel)
local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton)
local FormTextInput = require(Plugin.Components.FormTextInput)
local e = Roact.createElement
local ConnectPanel = Roact.Component:extend("ConnectPanel")
function ConnectPanel:init()
self:setState({
address = "",
port = "",
})
end
function ConnectPanel:render()
local startSession = self.props.startSession
return e(Panel, nil, {
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
Inputs = e(FitList, {
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 1,
},
layoutProps = {
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
Address = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = Theme.TitleFont,
TextSize = 20,
Text = "Address",
TextColor3 = Theme.PrimaryColor,
}),
Input = e(FormTextInput, {
layoutOrder = 2,
width = UDim.new(0, 220),
value = self.state.address,
placeholderValue = Config.defaultHost,
onValueChange = function(newValue)
self:setState({
address = newValue,
})
end,
}),
}),
Port = e(FitList, {
containerProps = {
LayoutOrder = 2,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = Theme.TitleFont,
TextSize = 20,
Text = "Port",
TextColor3 = Theme.PrimaryColor,
}),
Input = e(FormTextInput, {
layoutOrder = 2,
width = UDim.new(0, 80),
value = self.state.port,
placeholderValue = Config.defaultPort,
onValueChange = function(newValue)
self:setState({
port = newValue,
})
end,
}),
}),
}),
Buttons = e(FitList, {
fitAxes = "Y",
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 0),
},
layoutProps = {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 0),
PaddingBottom = UDim.new(0, 20),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
e(FormButton, {
layoutOrder = 2,
text = "Connect",
onClick = function()
if startSession ~= nil then
local address = self.state.address
if address:len() == 0 then
address = Config.defaultHost
end
local port = self.state.port
if port:len() == 0 then
port = Config.defaultPort
end
startSession(address, port)
end
end,
}),
}),
})
end
return ConnectPanel

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,46 +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 FormButton = require(Plugin.Components.FormButton)
local e = Roact.createElement
local ConnectionActivePanel = Roact.Component:extend("ConnectionActivePanel")
function ConnectionActivePanel:render()
local stopSession = self.props.stopSession
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 = "Connected to Live-Sync Server",
TextColor3 = Theme.PrimaryColor,
BackgroundTransparency = 1,
}),
DisconnectButton = e(FormButton, {
layoutOrder = 2,
text = "Disconnect",
secondary = true,
onClick = function()
stopSession()
end,
}),
})
end
return ConnectionActivePanel

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,63 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Dictionary = require(script.Parent.Parent.Dictionary)
local e = Roact.createElement
local FitList = Roact.Component:extend("FitList")
function FitList:init()
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
end
function FitList:render()
local containerKind = self.props.containerKind or "Frame"
local fitAxes = self.props.fitAxes or "XY"
local containerProps = self.props.containerProps
local layoutProps = self.props.layoutProps
local paddingProps = self.props.paddingProps
local padding
if paddingProps ~= nil then
padding = e("UIPadding", paddingProps)
end
local children = Dictionary.merge(self.props[Roact.Children], {
["$Layout"] = e("UIListLayout", Dictionary.merge({
SortOrder = Enum.SortOrder.LayoutOrder,
[Roact.Change.AbsoluteContentSize] = function(instance)
local contentSize = instance.AbsoluteContentSize
if paddingProps ~= nil then
contentSize = contentSize + Vector2.new(
paddingProps.PaddingLeft.Offset + paddingProps.PaddingRight.Offset,
paddingProps.PaddingTop.Offset + paddingProps.PaddingBottom.Offset)
end
local combinedSize
if fitAxes == "X" then
combinedSize = UDim2.new(0, contentSize.X, containerProps.Size.Y.Scale, containerProps.Size.Y.Offset)
elseif fitAxes == "Y" then
combinedSize = UDim2.new(containerProps.Size.X.Scale, containerProps.Size.X.Offset, 0, contentSize.Y)
elseif fitAxes == "XY" then
combinedSize = UDim2.new(0, contentSize.X, 0, contentSize.Y)
else
error("Invalid fitAxes value")
end
self.setSize(combinedSize)
end,
}, layoutProps)),
["$Padding"] = padding,
})
local fullContainerProps = Dictionary.merge(containerProps, {
Size = self.sizeBinding,
})
return e(containerKind, fullContainerProps, children)
end
return FitList

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

@@ -1,88 +0,0 @@
local TextService = game:GetService("TextService")
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Dictionary = require(script.Parent.Parent.Dictionary)
local e = Roact.createElement
local FitText = Roact.Component:extend("FitText")
function FitText:init()
self.ref = Roact.createRef()
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
end
function FitText:render()
local kind = self.props.Kind or "TextLabel"
local containerProps = Dictionary.merge(self.props, {
FitAxis = Dictionary.None,
Kind = Dictionary.None,
Padding = Dictionary.None,
MinSize = Dictionary.None,
Size = self.sizeBinding,
[Roact.Ref] = self.ref,
[Roact.Change.AbsoluteSize] = function()
self:updateTextMeasurements()
end
})
return e(kind, containerProps)
end
function FitText:didMount()
self:updateTextMeasurements()
end
function FitText:didUpdate()
self:updateTextMeasurements()
end
function FitText:updateTextMeasurements()
local minSize = self.props.MinSize or Vector2.new(0, 0)
local padding = self.props.Padding or Vector2.new(0, 0)
local fitAxis = self.props.FitAxis or "XY"
local baseSize = self.props.Size
local text = self.props.Text or ""
local font = self.props.Font or Enum.Font.Legacy
local textSize = self.props.TextSize or 12
local containerSize = self.ref.current.AbsoluteSize
local textBounds
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)
end
return FitText

View File

@@ -1,62 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme)
local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText)
local e = Roact.createElement
local RoundBox = Assets.Slices.RoundBox
local function FormButton(props)
local text = props.text
local layoutOrder = props.layoutOrder
local onClick = props.onClick
local textColor
local backgroundColor
if props.secondary then
textColor = Theme.AccentColor
backgroundColor = Theme.SecondaryColor
else
textColor = Theme.SecondaryColor
backgroundColor = Theme.AccentColor
end
return e(FitList, {
containerKind = "ImageButton",
containerProps = {
LayoutOrder = layoutOrder,
BackgroundTransparency = 1,
Image = RoundBox.asset,
ImageRectOffset = RoundBox.offset,
ImageRectSize = RoundBox.size,
SliceCenter = RoundBox.center,
ScaleType = Enum.ScaleType.Slice,
ImageColor3 = backgroundColor,
[Roact.Event.Activated] = function()
if onClick ~= nil then
onClick()
end
end,
},
}, {
Text = e(FitText, {
Kind = "TextLabel",
Text = text,
TextSize = 18,
TextColor3 = textColor,
Font = Theme.ButtonFont,
Padding = Vector2.new(16, 8),
BackgroundTransparency = 1,
}),
})
end
return FormButton

View File

@@ -1,80 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme)
local e = Roact.createElement
local RoundBox = Assets.Slices.RoundBox
local TEXT_SIZE = 22
local PADDING = 8
local FormTextInput = Roact.Component:extend("FormTextInput")
function FormTextInput:init()
self:setState({
focused = false,
})
end
function FormTextInput:render()
local value = self.props.value
local placeholderValue = self.props.placeholderValue
local onValueChange = self.props.onValueChange
local layoutOrder = self.props.layoutOrder
local width = self.props.width
local shownPlaceholder
if self.state.focused then
shownPlaceholder = ""
else
shownPlaceholder = placeholderValue
end
return e("ImageLabel", {
LayoutOrder = layoutOrder,
Image = RoundBox.asset,
ImageRectOffset = RoundBox.offset,
ImageRectSize = RoundBox.size,
ScaleType = Enum.ScaleType.Slice,
SliceCenter = RoundBox.center,
ImageColor3 = Theme.SecondaryColor,
Size = UDim2.new(width.Scale, width.Offset, 0, TEXT_SIZE + PADDING * 2),
BackgroundTransparency = 1,
}, {
InputInner = e("TextBox", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -PADDING * 2, 1, -PADDING * 2),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Font = Theme.InputFont,
ClearTextOnFocus = false,
TextXAlignment = Enum.TextXAlignment.Center,
TextSize = TEXT_SIZE,
Text = value,
PlaceholderText = shownPlaceholder,
PlaceholderColor3 = Theme.LightTextColor,
TextColor3 = Theme.PrimaryColor,
[Roact.Change.Text] = function(rbx)
onValueChange(rbx.Text)
end,
[Roact.Event.Focused] = function()
self:setState({
focused = true,
})
end,
[Roact.Event.FocusLost] = function()
self:setState({
focused = false,
})
end,
}),
})
end
return FormTextInput

View File

@@ -1,34 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local RojoFooter = require(Plugin.Components.RojoFooter)
local e = Roact.createElement
local Panel = Roact.Component:extend("Panel")
function Panel:init()
self.footerSize, self.setFooterSize = Roact.createBinding(Vector2.new())
end
function Panel:render()
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
Body = e("Frame", {
Size = UDim2.new(0, 360, 1, -32),
BackgroundTransparency = 1,
}, self.props[Roact.Children]),
Footer = e(RojoFooter),
})
end
return Panel

View File

@@ -1,67 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Config = require(Plugin.Config)
local Version = require(Plugin.Version)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme)
local e = Roact.createElement
local RojoFooter = Roact.Component:extend("RojoFooter")
function RojoFooter:init()
self.footerSize, self.setFooterSize = Roact.createBinding(Vector2.new())
self.footerVersionSize, self.setFooterVersionSize = Roact.createBinding(Vector2.new())
end
function RojoFooter:render()
return e("Frame", {
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, 32),
BackgroundColor3 = Theme.SecondaryColor,
BorderSizePixel = 0,
}, {
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 4),
PaddingBottom = UDim.new(0, 4),
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
}),
LogoContainer = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 0, 32),
}, {
Logo = e("ImageLabel", {
Image = Assets.Images.Logo,
Size = UDim2.new(0, 80, 0, 40),
ScaleType = Enum.ScaleType.Fit,
BackgroundTransparency = 1,
Position = UDim2.new(0, 0, 1, -10),
AnchorPoint = Vector2.new(0, 1),
}),
}),
Version = e("TextLabel", {
Position = UDim2.new(1, 0, 0, 0),
Size = UDim2.new(0, 0, 1, 0),
AnchorPoint = Vector2.new(1, 0),
Font = Theme.TitleFont,
TextSize = 18,
Text = Version.display(Config.version),
TextXAlignment = Enum.TextXAlignment.Right,
TextColor3 = Theme.LightTextColor,
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(rbx)
self.setFooterVersionSize(rbx.AbsoluteSize)
end,
}),
})
end
return RojoFooter

View File

@@ -1,13 +1,12 @@
local strict = require(script.Parent.strict)
local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
return strict("Config", {
isDevBuild = isDevBuild,
codename = "Epiphany",
version = {0, 6, 0, "-alpha.2"},
expectedServerVersionString = "0.6.0 or newer",
protocolVersion = 3,
defaultHost = "localhost",
defaultPort = 34872,
})
return {
pollingRate = 0.2,
version = {0, 4, 12},
expectedServerVersionString = "0.4.x",
protocolVersion = 1,
icons = {
syncIn = "rbxassetid://1820320573",
togglePolling = "rbxassetid://1820320064",
testConnection = "rbxassetid://1820320989",
},
dev = false,
}

View File

@@ -0,0 +1,7 @@
return function()
local Config = require(script.Parent.Config)
it("should have 'dev' disabled", function()
expect(Config.dev).to.equal(false)
end)
end

View File

@@ -1,151 +0,0 @@
local Config = require(script.Parent.Config)
local Environment = {
User = "User",
Dev = "Dev",
Test = "Test",
}
local DEFAULT_ENVIRONMENT = Config.isDevBuild and Environment.Dev or Environment.User
local VALUES = {
LogLevel = {
type = "IntValue",
values = {
[Environment.User] = 2,
[Environment.Dev] = 4,
[Environment.Test] = 4,
},
},
TypecheckingEnabled = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = true,
[Environment.Test] = true,
},
},
UnstableTwoWaySync = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = false,
[Environment.Test] = false,
},
},
}
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
local function getValueContainer()
return game:FindFirstChild(CONTAINER_NAME)
end
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)
if valueContainer == nil then
return nil
end
local valueObject = valueContainer:FindFirstChild(name)
if valueObject == nil then
return nil
end
return valueObject.Value
end
local function setStoredValue(name, kind, value)
local object = valueContainer:FindFirstChild(name)
if object == nil then
object = Instance.new(kind)
object.Name = name
object.Parent = valueContainer
end
object.Value = value
end
local function createAllValues(environment)
assert(Environment[environment] ~= nil, "Invalid environment")
valueContainer = getValueContainer()
if valueContainer == nil then
valueContainer = Instance.new("Folder")
valueContainer.Name = CONTAINER_NAME
valueContainer.Parent = game
end
for name, value in pairs(VALUES) do
setStoredValue(name, value.type, value.values[environment])
end
end
local function getValue(name)
assert(VALUES[name] ~= nil, "Invalid DevSettings name")
local stored = getStoredValue(name)
if stored ~= nil then
return stored
end
return VALUES[name].values[DEFAULT_ENVIRONMENT]
end
local DevSettings = {}
function DevSettings:createDevSettings()
createAllValues(Environment.Dev)
end
function DevSettings:createTestSettings()
createAllValues(Environment.Test)
end
function DevSettings:hasChangedValues()
return valueContainer ~= nil
end
function DevSettings:resetValues()
if valueContainer then
valueContainer:Destroy()
valueContainer = nil
end
end
function DevSettings:isEnabled()
return valueContainer ~= nil
end
function DevSettings:getLogLevel()
return getValue("LogLevel")
end
function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled")
end
function DevSettings:twoWaySyncEnabled()
return getValue("UnstableTwoWaySync")
end
function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings()
end
return DevSettings

View File

@@ -1,33 +0,0 @@
--[[
This is a placeholder module waiting for Cryo to become available.
]]
local None = newproxy(true)
getmetatable(None).__tostring = function()
return "None"
end
local function merge(...)
local output = {}
for i = 1, select("#", ...) do
local source = select(i, ...)
if source ~= nil then
for key, value in pairs(source) do
if value == None then
output[key] = nil
else
output[key] = value
end
end
end
end
return output
end
return {
None = None,
merge = merge,
}

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

@@ -0,0 +1,68 @@
local HttpService = game:GetService("HttpService")
local HTTP_DEBUG = false
local Promise = require(script.Parent.Parent.modules.Promise)
local HttpError = require(script.Parent.HttpError)
local HttpResponse = require(script.Parent.HttpResponse)
local function dprint(...)
if HTTP_DEBUG then
print(...)
end
end
local Http = {}
Http.__index = Http
function Http.new(baseUrl)
assert(type(baseUrl) == "string", "Http.new needs a baseUrl!")
local http = {
baseUrl = baseUrl
}
setmetatable(http, Http)
return http
end
function Http:get(endpoint)
dprint("\nGET", endpoint)
return Promise.new(function(resolve, reject)
spawn(function()
local ok, result = pcall(function()
return HttpService:GetAsync(self.baseUrl .. endpoint, true)
end)
if ok then
dprint("\t", result, "\n")
resolve(HttpResponse.new(result))
else
reject(HttpError.fromErrorString(result))
end
end)
end)
end
function Http:post(endpoint, body)
dprint("\nPOST", endpoint)
dprint(body)
return Promise.new(function(resolve, reject)
spawn(function()
local ok, result = pcall(function()
return HttpService:PostAsync(self.baseUrl .. endpoint, body)
end)
if ok then
dprint("\t", result, "\n")
resolve(HttpResponse.new(result))
else
reject(HttpError.fromErrorString(result))
end
end)
end)
end
return Http

60
plugin/src/HttpError.lua Normal file
View File

@@ -0,0 +1,60 @@
local HttpError = {}
HttpError.__index = HttpError
HttpError.Error = {
HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" ..
"Check your game settings, located in the 'Home' tab of Studio.",
},
ConnectFailed = {
message = "Rojo plugin couldn't connect to the Rojo server.\n" ..
"Make sure the server is running -- use 'Rojo serve' to run it!",
},
Unknown = {
message = "Rojo encountered an unknown error: {{message}}",
},
}
function HttpError.new(type, extraMessage)
extraMessage = extraMessage or ""
local message = type.message:gsub("{{message}}", extraMessage)
local err = {
type = type,
message = message,
}
setmetatable(err, HttpError)
return err
end
function HttpError:__tostring()
return self.message
end
--[[
This method shouldn't have to exist. Ugh.
]]
function HttpError.fromErrorString(err)
err = err:lower()
if err:find("^http requests are not enabled") then
return HttpError.new(HttpError.Error.HttpNotEnabled)
end
if err:find("^curl error") then
return HttpError.new(HttpError.Error.ConnectFailed)
end
return HttpError.new(HttpError.Error.Unknown, err)
end
function HttpError:report()
warn(self.message)
if self.type == HttpError.Error.HttpNotEnabled then
game:GetService("Selection"):Set{game:GetService("HttpService")}
end
end
return HttpError

View File

@@ -0,0 +1,20 @@
local HttpService = game:GetService("HttpService")
local HttpResponse = {}
HttpResponse.__index = HttpResponse
function HttpResponse.new(body)
local response = {
body = body,
}
setmetatable(response, HttpResponse)
return response
end
function HttpResponse:json()
return HttpService:JSONDecode(self.body)
end
return HttpResponse

View File

@@ -1,177 +0,0 @@
local Log = require(script.Parent.Parent.Log)
--[[
A bidirectional map between instance IDs and Roblox instances. It lets us
keep track of every instance we know about.
TODO: Track ancestry to catch when stuff moves?
]]
local InstanceMap = {}
InstanceMap.__index = InstanceMap
function InstanceMap.new(onInstanceChanged)
local self = {
fromIds = {},
fromInstances = {},
instancesToSignal = {},
onInstanceChanged = onInstanceChanged,
}
return setmetatable(self, InstanceMap)
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)
self.fromIds[id] = instance
self.fromInstances[instance] = id
self:__connectSignals(instance)
end
function InstanceMap:removeId(id)
local instance = self.fromIds[id]
if instance ~= nil then
self:__disconnectSignals(instance)
self.fromIds[id] = nil
self.fromInstances[instance] = nil
else
Log.warn("Attempted to remove nonexistant ID {}", id)
end
end
function InstanceMap:removeInstance(instance)
local id = self.fromInstances[instance]
self:__disconnectSignals(instance)
if id ~= nil then
self.fromInstances[instance] = nil
self.fromIds[id] = nil
else
Log.warn("Attempted to remove nonexistant instance {}", instance)
end
end
function InstanceMap:destroyInstance(instance)
local id = self.fromInstances[instance]
if id ~= nil then
self:destroyId(id)
else
Log.warn("Attempted to destroy untracked instance {}", instance)
end
end
function InstanceMap:destroyId(id)
local instance = self.fromIds[id]
self:removeId(id)
if instance ~= nil then
local descendantsToDestroy = {}
for otherInstance in pairs(self.fromInstances) do
if otherInstance:IsDescendantOf(instance) then
table.insert(descendantsToDestroy, otherInstance)
end
end
for _, otherInstance in ipairs(descendantsToDestroy) do
self:removeInstance(otherInstance)
end
instance:Destroy()
else
Log.warn("Attempted to destroy nonexistant ID {}", 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
return InstanceMap

View File

@@ -0,0 +1,84 @@
if not plugin then
return
end
local Plugin = require(script.Parent.Plugin)
local Config = require(script.Parent.Config)
local Version = require(script.Parent.Version)
--[[
Check if the user is using a newer version of Rojo than last time. If they
are, show them a reminder to make sure they check their server version.
]]
local function checkUpgrade()
-- When developing Rojo, there's no use in doing version checks
if Config.dev then
return
end
local lastVersion = plugin:GetSetting("LastRojoVersion")
if lastVersion then
local wasUpgraded = Version.compare(Config.version, lastVersion) == 1
if wasUpgraded then
local message = (
"\nRojo detected an upgrade from version %s to version %s." ..
"\nMake sure you have also upgraded your server!" ..
"\n\nRojo version %s is intended for use with server version %s.\n"
):format(
Version.display(lastVersion), Version.display(Config.version),
Version.display(Config.version), Config.expectedServerVersionString
)
print(message)
end
end
plugin:SetSetting("LastRojoVersion", Config.version)
end
local function main()
local pluginInstance = Plugin.new()
local displayedVersion = Config.dev and "DEV" or Version.display(Config.version)
local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion)
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", Config.icons.testConnection)
.Click:Connect(function()
checkUpgrade()
pluginInstance:connect()
:catch(function(err)
warn(err)
end)
end)
local function syncIn()
checkUpgrade()
pluginInstance:syncIn()
:catch(function(err)
warn(err)
end)
end
local shortDescription = "Sync In"
local longDescription = "Sync into Roblox Studio"
toolbar:CreateButton(shortDescription, longDescription, Config.icons.syncIn).Click:Connect(syncIn)
plugin:CreatePluginAction("RojoSyncIn", shortDescription, longDescription).Triggered:Connect(syncIn)
toolbar:CreateButton("Toggle Polling", "Poll server for changes", Config.icons.togglePolling)
.Click:Connect(function()
checkUpgrade()
pluginInstance:togglePolling()
:catch(function(err)
warn(err)
end)
end)
end
main()

311
plugin/src/Plugin.lua Normal file
View File

@@ -0,0 +1,311 @@
local CoreGui = game:GetService("CoreGui")
local Promise = require(script.Parent.Parent.modules.Promise)
local Config = require(script.Parent.Config)
local Http = require(script.Parent.Http)
local Api = require(script.Parent.Api)
local Reconciler = require(script.Parent.Reconciler)
local Version = require(script.Parent.Version)
local MESSAGE_SERVER_CHANGED = "Rojo: The server has changed since the last request, reloading plugin..."
local MESSAGE_PLUGIN_CHANGED = "Rojo: Another instance of Rojo came online, unloading..."
local function collectMatch(source, pattern)
local result = {}
for match in source:gmatch(pattern) do
table.insert(result, match)
end
return result
end
local Plugin = {}
Plugin.__index = Plugin
function Plugin.new()
local address = "localhost"
local port = Config.dev and 8001 or 8000
local remote = ("http://%s:%d"):format(address, port)
local self = {
_http = Http.new(remote),
_reconciler = Reconciler.new(),
_api = nil,
_polling = false,
_syncInProgress = false,
}
setmetatable(self, Plugin)
do
local uiName = ("Rojo %s UI"):format(Version.display(Config.version))
if Config.dev then
uiName = "Rojo Dev UI"
end
-- If there's an existing Rojo UI, like from a Roblox plugin upgrade
-- that wasn't Rojo, make sure we clean it up.
local existingUi = CoreGui:FindFirstChild(uiName)
if existingUi ~= nil then
existingUi:Destroy()
end
local screenGui = Instance.new("ScreenGui")
screenGui.Name = uiName
screenGui.Parent = CoreGui
screenGui.DisplayOrder = -1
screenGui.Enabled = false
local label = Instance.new("TextLabel")
label.Font = Enum.Font.SourceSans
label.TextSize = 20
label.Text = "Rojo polling..."
label.BackgroundColor3 = Color3.fromRGB(31, 31, 31)
label.BackgroundTransparency = 0.5
label.BorderSizePixel = 0
label.TextColor3 = Color3.new(1, 1, 1)
label.Size = UDim2.new(0, 120, 0, 28)
label.Position = UDim2.new(0, 0, 0, 0)
label.Parent = screenGui
self._label = screenGui
-- If our UI was destroyed, we assume it was from another instance of
-- the Rojo plugin coming online.
--
-- Roblox doesn't notify plugins when they get unloaded, so this is the
-- best trigger we have right now unless we create a dedicated event
-- object.
screenGui.AncestryChanged:Connect(function(_, parent)
if parent == nil then
warn(MESSAGE_PLUGIN_CHANGED)
self:restart()
end
end)
end
return self
end
--[[
Clears all state and issues a notice to the user that the plugin has
restarted.
]]
function Plugin:restart()
self:stopPolling()
self._reconciler:destruct()
self._reconciler = Reconciler.new()
self._api = nil
self._polling = false
self._syncInProgress = false
end
function Plugin:getApi()
if self._api == nil then
return Api.connect(self._http)
:andThen(function(api)
self._api = api
return api
end, function(err)
return Promise.reject(err)
end)
end
return Promise.resolve(self._api)
end
function Plugin:connect()
print("Rojo: Testing connection...")
return self:getApi()
:andThen(function(api)
local ok, info = api:getInfo():await()
if not ok then
return Promise.reject(info)
end
print("Rojo: Server found!")
print("Rojo: Protocol version:", info.protocolVersion)
print("Rojo: Server version:", info.serverVersion)
end)
:catch(function(err)
if err == Api.Error.ServerIdMismatch then
warn(MESSAGE_SERVER_CHANGED)
self:restart()
return self:connect()
else
return Promise.reject(err)
end
end)
end
function Plugin:togglePolling()
if self._polling then
return self:stopPolling()
else
return self:startPolling()
end
end
function Plugin:stopPolling()
if not self._polling then
return Promise.resolve(false)
end
print("Rojo: Stopped polling server for changes.")
self._polling = false
self._label.Enabled = false
return Promise.resolve(true)
end
function Plugin:_pull(api, project, fileRoutes)
return api:read(fileRoutes)
:andThen(function(items)
for index = 1, #fileRoutes do
local fileRoute = fileRoutes[index]
local partitionName = fileRoute[1]
local partition = project.partitions[partitionName]
local item = items[index]
local partitionTargetRbxRoute = collectMatch(partition.target, "[^.]+")
-- If the item route's length was 1, we need to rename the instance to
-- line up with the partition's root object name.
if item ~= nil and #fileRoute == 1 then
local objectName = partition.target:match("[^.]+$")
item.Name = objectName
end
local itemRbxRoute = {}
for _, piece in ipairs(partitionTargetRbxRoute) do
table.insert(itemRbxRoute, piece)
end
for i = 2, #fileRoute do
table.insert(itemRbxRoute, fileRoute[i])
end
self._reconciler:reconcileRoute(itemRbxRoute, item, fileRoute)
end
end)
end
function Plugin:startPolling()
if self._polling then
return
end
print("Rojo: Starting to poll server for changes...")
self._polling = true
self._label.Enabled = true
return self:getApi()
:andThen(function(api)
local infoOk, info = api:getInfo():await()
if not infoOk then
return Promise.reject(info)
end
local syncOk, result = self:syncIn():await()
if not syncOk then
return Promise.reject(result)
end
while self._polling do
local changesOk, changes = api:getChanges():await()
if not changesOk then
return Promise.reject(changes)
end
if #changes > 0 then
local routes = {}
for _, change in ipairs(changes) do
table.insert(routes, change.route)
end
local pullOk, pullResult = self:_pull(api, info.project, routes):await()
if not pullOk then
return Promise.reject(pullResult)
end
end
wait(Config.pollingRate)
end
end)
:catch(function(err)
if err == Api.Error.ServerIdMismatch then
warn(MESSAGE_SERVER_CHANGED)
self:restart()
return self:startPolling()
else
self:stopPolling()
return Promise.reject(err)
end
end)
end
function Plugin:syncIn()
if self._syncInProgress then
warn("Rojo: Can't sync right now, because a sync is already in progress.")
return Promise.resolve()
end
self._syncInProgress = true
print("Rojo: Syncing from server...")
return self:getApi()
:andThen(function(api)
local ok, info = api:getInfo():await()
if not ok then
return Promise.reject(info)
end
local fileRoutes = {}
for name in pairs(info.project.partitions) do
table.insert(fileRoutes, {name})
end
local pullSuccess, pullResult = self:_pull(api, info.project, fileRoutes):await()
self._syncInProgress = false
if not pullSuccess then
return Promise.reject(pullResult)
end
print("Rojo: Sync successful!")
end)
:catch(function(err)
self._syncInProgress = false
if err == Api.Error.ServerIdMismatch then
warn(MESSAGE_SERVER_CHANGED)
self:restart()
return self:syncIn()
else
return Promise.reject(err)
end
end)
end
return Plugin

View File

@@ -1,401 +1,250 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
local RouteMap = require(script.Parent.RouteMap)
local RbxDom = require(script.Parent.Parent.RbxDom)
local t = require(script.Parent.Parent.t)
local Log = require(script.Parent.Parent.Log)
local function classEqual(a, b)
assert(typeof(a) == "string")
assert(typeof(b) == "string")
local Types = require(script.Parent.Types)
local invariant = require(script.Parent.invariant)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
if a == "*" or b == "*" then
return true
end
return a == b
end
local function applyProperties(target, properties)
assert(typeof(target) == "Instance")
assert(typeof(properties) == "table")
for key, property in pairs(properties) do
-- TODO: Transform property value based on property.Type
-- Right now, we assume that 'value' is primitive!
target[key] = property.Value
end
end
--[[
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.
Attempt to parent `rbx` to `parent`, doing nothing if:
* parent is already `parent`
* Changing parent threw an error
]]
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),
})
local function reparent(rbx, parent)
assert(typeof(rbx) == "Instance")
assert(typeof(parent) == "Instance")
--[[
Attempt to safely set the parent of an instance.
if rbx.Parent == parent then
return
end
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)
-- Setting `Parent` can fail if:
-- * The object has been destroyed
-- * The object is a service and cannot be reparented
pcall(function()
instance.Parent = newParent
rbx.Parent = parent
end)
end
--[[
Similar to setting Parent, some instances really don't like being renamed.
Attempts to match up Roblox instances and object specifiers for
reconciliation.
TODO: Should we be throwing away these results or can we be more careful?
An object is considered a match if they have the same Name and ClassName.
primaryChildren and secondaryChildren can each be either a list of Roblox
instances or object specifiers. Since they share a common shape, switching
the two around isn't problematic!
visited is expected to be an empty table initially. It will be filled with
the set of children that have been visited so far.
]]
local function safeSetName(instance, name)
pcall(function()
instance.Name = name
end)
local function findNextChildPair(primaryChildren, secondaryChildren, visited)
for _, primaryChild in ipairs(primaryChildren) do
if not visited[primaryChild] then
visited[primaryChild] = true
for _, secondaryChild in ipairs(secondaryChildren) do
if classEqual(primaryChild.ClassName, secondaryChild.ClassName) and primaryChild.Name == secondaryChild.Name then
visited[secondaryChild] = true
return primaryChild, secondaryChild
end
end
return primaryChild, nil
end
end
return nil, nil
end
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
function Reconciler.new()
local reconciler = {
_routeMap = RouteMap.new(),
}
return setmetatable(self, Reconciler)
setmetatable(reconciler, Reconciler)
return reconciler
end
--[[
See Reconciler:__hydrateInternal().
A semi-smart algorithm that attempts to apply the given item's children to
an existing Roblox object.
]]
function Reconciler:hydrate(apiInstances, id, instance)
local hydratePatch = {
removed = {},
added = {},
updated = {},
}
function Reconciler:_reconcileChildren(rbx, item)
local visited = {}
local rbxChildren = rbx:GetChildren()
self:__hydrateInternal(apiInstances, id, instance, hydratePatch)
-- Reconcile any children that were added or updated
while true do
local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited)
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)
if itemChild == nil then
break
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
local newRbxChild = self:reconcile(rbxChild, itemChild)
removedInstance:Destroy()
if newRbxChild ~= nil then
newRbxChild.Parent = rbx
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
-- Reconcile any children that were deleted
while true do
local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited)
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
)
if rbxChild == nil then
break
end
if update.changedClassName ~= nil then
error("TODO: Support changing class name by destroying + recreating instance.")
end
local newRbxChild = self:reconcile(rbxChild, itemChild)
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
if newRbxChild ~= nil then
newRbxChild.Parent = rbx
end
end
end
--[[
Transforms a value into one that can be sent over the network back to the
Rojo server.
This operation can fail, and so it returns bool, value.
Construct a new Roblox object from the given item.
]]
function Reconciler:encodeApiValue(value)
if typeof(value) == "string" then
return true, {
Type = "String",
Value = value,
}
function Reconciler:_reify(item)
local className = item.ClassName
-- "*" represents a match of any class. It reifies as a folder!
if className == "*" then
className = "Folder"
end
return false
local rbx = Instance.new(className)
rbx.Name = item.Name
applyProperties(rbx, item.Properties)
for _, child in ipairs(item.Children) do
reparent(self:_reify(child), rbx)
end
if item.Route ~= nil then
self._routeMap:insert(item.Route, rbx)
end
return rbx
end
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
Clears any state that the Reconciler has, stopping it completely.
]]
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
function Reconciler:destruct()
self._routeMap:destruct()
end
--[[
Constructs an instance from an ApiInstance without any of its children.
Apply the changes represented by the given item to a Roblox object that's a
child of the given instance.
]]
local reifySingleInstanceSchema = Types.ifEnabled(t.tuple(
Types.ApiInstance
))
function Reconciler:__reifySingleInstance(apiInstance)
assert(reifySingleInstanceSchema(apiInstance))
function Reconciler:reconcile(rbx, item)
-- Item was deleted
if item == nil then
if rbx ~= nil then
-- TODO: If this is a partition root, should we leave it alone?
-- 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
))
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
Rojo plugin knows about, and generates a patch that would update the Roblox
tree to match Rojo's view of the tree.
]]
local hydrateSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance,
IPatch
))
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
assert(hydrateSchema(apiInstances, id, instance, hydratePatch))
self.__instanceMap:insert(id, instance)
local apiInstance = apiInstances[id]
local function markIdAdded(id)
local apiInstance = apiInstances[id]
hydratePatch.added[id] = apiInstance
for _, childId in ipairs(apiInstance.Children) do
markIdAdded(childId)
self._routeMap:removeByRbx(rbx)
rbx:Destroy()
end
return nil
end
local changedName = nil
local changedProperties = {}
if apiInstance.Name ~= instance.Name then
changedName = apiInstance.Name
-- Item was created!
if rbx == nil then
return self:_reify(item)
end
for propertyName, virtualValue in pairs(apiInstance.Properties) do
local success, existingValue = getCanonicalProperty(instance, propertyName)
-- Item changed type!
if not classEqual(rbx.ClassName, item.ClassName) then
self._routeMap:removeByRbx(rbx)
rbx:Destroy()
if success then
local decodedValue = self:__decodeApiValue(virtualValue)
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
end
return self:_reify(item)
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,
})
-- It's possible that the instance we're associating with this item hasn't
-- been inserted into the RouteMap yet.
if item.Route ~= nil then
self._routeMap:insert(item.Route, rbx)
end
local existingChildren = instance:GetChildren()
applyProperties(rbx, item.Properties)
self:_reconcileChildren(rbx, item)
-- For each existing child, we'll track whether it's been paired with an
-- instance that the Rojo server knows about.
local isExistingChildVisited = {}
for i = 1, #existingChildren do
isExistingChildVisited[i] = false
end
return rbx
end
for _, childId in ipairs(apiInstance.Children) do
local apiChild = apiInstances[childId]
function Reconciler:reconcileRoute(rbxRoute, item, fileRoute)
local parent
local rbx = game
local childInstance
for i = 1, #rbxRoute do
local piece = rbxRoute[i]
for childIndex, instance in ipairs(existingChildren) do
if not isExistingChildVisited[childIndex] then
-- We guard accessing Name and ClassName in order to avoid
-- 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)
local child = rbx:FindFirstChild(piece)
-- 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
end
-- We should get services instead of making folders here.
if rbx == game and child == nil then
local success
success, child = pcall(game.GetService, game, piece)
-- That isn't a valid service!
if not success then
child = nil
end
end
if childInstance ~= nil then
-- We found an instance that matches the instance from the API, yay!
self:__hydrateInternal(apiInstances, childId, childInstance, hydratePatch)
else
markIdAdded(childId)
-- We don't want to create a folder if we're reaching our target item!
if child == nil and i ~= #rbxRoute then
child = Instance.new("Folder")
child.Parent = rbx
child.Name = piece
end
parent = rbx
rbx = child
end
-- Any unvisited children at this point aren't known by Rojo and we can
-- destroy them unless the user has explicitly asked us to preserve children
-- of this instance.
local shouldClearUnknown = self:__shouldClearUnknownChildren(apiInstance)
if shouldClearUnknown then
for childIndex, visited in ipairs(isExistingChildVisited) do
if not visited then
table.insert(hydratePatch.removed, existingChildren[childIndex])
end
end
-- Let's check the route map!
if rbx == nil then
rbx = self._routeMap:get(fileRoute)
end
rbx = self:reconcile(rbx, item)
if rbx ~= nil then
reparent(rbx, parent)
end
end
function Reconciler:__shouldClearUnknownChildren(apiInstance)
if apiInstance.Metadata ~= nil then
return not apiInstance.Metadata.ignoreUnknownInstances
else
return true
end
end
return Reconciler
return Reconciler

123
plugin/src/RouteMap.lua Normal file
View File

@@ -0,0 +1,123 @@
--[[
A map from Route objects (given by the server) to Roblox instances (created
by the plugin).
]]
local function hashRoute(route)
return table.concat(route, "/")
end
local RouteMap = {}
RouteMap.__index = RouteMap
function RouteMap.new()
local self = {
_map = {},
_reverseMap = {},
_connectionsByRbx = {},
}
setmetatable(self, RouteMap)
return self
end
function RouteMap:insert(route, rbx)
local hashed = hashRoute(route)
-- Make sure that each route and instance are only present in RouteMap once.
self:removeByRoute(route)
self:removeByRbx(rbx)
self._map[hashed] = rbx
self._reverseMap[rbx] = hashed
self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent)
if parent == nil then
self:removeByRbx(rbx)
end
end)
end
function RouteMap:get(route)
return self._map[hashRoute(route)]
end
function RouteMap:removeByRoute(route)
local hashedRoute = hashRoute(route)
local rbx = self._map[hashedRoute]
if rbx ~= nil then
self:_removeInternal(rbx, hashedRoute)
end
end
function RouteMap:removeByRbx(rbx)
local hashedRoute = self._reverseMap[rbx]
if hashedRoute ~= nil then
self:_removeInternal(rbx, hashedRoute)
end
end
--[[
Correcly removes the given Roblox Instance/Route pair from the RouteMap.
]]
function RouteMap:_removeInternal(rbx, hashedRoute)
self._map[hashedRoute] = nil
self._reverseMap[rbx] = nil
self._connectionsByRbx[rbx]:Disconnect()
self._connectionsByRbx[rbx] = nil
self:_removeRbxDescendants(rbx)
end
--[[
Ensure that there are no descendants of the given Roblox Instance still
present in the map, guaranteeing that it has been cleaned out.
]]
function RouteMap:_removeRbxDescendants(parentRbx)
for rbx in pairs(self._reverseMap) do
if rbx:IsDescendantOf(parentRbx) then
self:removeByRbx(rbx)
end
end
end
--[[
Remove all items from the map and disconnect all connections, cleaning up
the RouteMap.
]]
function RouteMap:destruct()
self._map = {}
self._reverseMap = {}
for _, connection in pairs(self._connectionsByRbx) do
connection:Disconnect()
end
self._connectionsByRbx = {}
end
function RouteMap:visualize()
-- Log all of our keys so that the visualization has a stable order.
local keys = {}
for key in pairs(self._map) do
table.insert(keys, key)
end
table.sort(keys)
local buffer = {}
for _, key in ipairs(keys) do
local visualized = ("- %s: %s"):format(
key,
self._map[key]:GetFullName()
)
table.insert(buffer, visualized)
end
return table.concat(buffer, "\n")
end
return RouteMap

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

View File

@@ -1,14 +0,0 @@
local strict = require(script.Parent.strict)
return strict("Theme", {
ButtonFont = Enum.Font.GothamSemibold,
InputFont = Enum.Font.Code,
TitleFont = Enum.Font.GothamBold,
MainFont = Enum.Font.Gotham,
AccentColor = Color3.fromRGB(225, 56, 53),
AccentLightColor = Color3.fromRGB(255, 146, 145),
PrimaryColor = Color3.fromRGB(64, 64, 64),
SecondaryColor = Color3.fromRGB(235, 235, 235),
LightTextColor = Color3.fromRGB(160, 160, 160),
})

View File

@@ -1,99 +0,0 @@
local t = require(script.Parent.Parent.t)
local DevSettings = require(script.Parent.DevSettings)
local strict = require(script.Parent.strict)
local RbxId = t.string
local ApiValue = t.interface({
Type = t.string,
Value = t.optional(t.any),
})
local ApiInstanceMetadata = t.interface({
ignoreUnknownInstances = t.optional(t.boolean),
})
local ApiInstance = t.interface({
Id = RbxId,
Parent = t.optional(RbxId),
Name = t.string,
ClassName = t.string,
Properties = t.map(t.string, ApiValue),
Metadata = t.optional(ApiInstanceMetadata),
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)
return function(...)
if DevSettings:shouldTypecheck() then
return innerCheck(...)
else
return true
end
end
end
return strict("Types", {
ifEnabled = ifEnabled,
ApiInfoResponse = ApiInfoResponse,
ApiReadResponse = ApiReadResponse,
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,
})

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