Compare commits

..

82 Commits

Author SHA1 Message Date
Lucien Greathouse
836b18e68a Release 6.0.0-rc.2 2020-11-19 10:25:25 -08:00
Lucien Greathouse
046dc0d598 plugin: Fix grammar in comments a bit :) 2020-11-16 11:49:30 -08:00
Lucien Greathouse
039d92ce78 plugin: Support ClassName changes in applyPatch 2020-11-16 11:44:56 -08:00
Lucien Greathouse
2136da15d6 plugin: Ensure InstanceMap deletes existing entries before inserting 2020-11-16 11:44:39 -08:00
Lucien Greathouse
e5041d80ef plugin: Fix warning in applyPatch.lua 2020-11-11 17:11:34 -08:00
Lucien Greathouse
f66860bdfe Break apart plugin reconciler (#332)
* Start splitting apart reconciler, with tests

* Reify children in reify

* Baseline hydrate implementation

* Remove debug print

* Scaffold out diff implementation, just supporting name changes

* invariant -> error in decodeValue

* Flesh out diff and add getProperty

* Clear out top-level reconciler interface, start updating code that touches it

* Address review feedback

* Add (experimental) Selene configuration

* Add emptiness checks to PatchSet, remove unimplement invert method

* Improve descendant destruction behavior in InstanceMap

* Track instanceId on all reify errors

* Base implementation of applyPatch, returning partial patches on failure

* Change reify to accept InstanceMap and insert instances into it

* Start testing applyPatch for removals

* Add test for applyPatch adding instances successfully and not

* Add , which is just error with formatting

* Correctly use new diff and applyPatch APIs

* Improve applyPatch logging and fix field name typo

* Better debug output when reify fails

* Print out unapplied patch in debug mode

* Don't write properties if their values are not different.

This was exposed trying to sync the Rojo plugin, which
has a gigantic ModuleScript in it with the reflection
database. This workaround was present in some form in
many versions of Rojo, and I guess we still need it.

This time, I actually documented why it's here so that
I don't forget for the umpteenth time...

* Add placeholder test that needs to happen still

* Introduce easier plugin testing, write applyPatch properties test

* Delete legacy get/setCanonicalProperty files

* Fix trying to remove numbers instead of instances

* Change applyPatch to return partial patches instead of binary success

* Work towards being able to decode and apply refs

* Add helpers for PatchSet assertions

* Apply refs in reify, test all cases

* Improve diagnostics when patches fail to apply

* Stop logging when destroying untracked instances, it's ok

* Remove read before setting property in applyPatch

* Fix diff thinking all properties are changed
2020-11-11 16:30:23 -08:00
Lucien Greathouse
50f0a2bd2e Update CLI dependencies 2020-10-29 10:36:20 -07:00
Lucien Greathouse
7cd9bd383e Update to latest reflection database 2020-10-29 10:35:55 -07:00
Lucien Greathouse
45a20a1633 Remove outdated notices 2020-09-09 17:39:24 -07:00
Lucien Greathouse
ec5b3f80ef Fix theme component error regression 2020-07-03 12:21:49 -07:00
Lucien Greathouse
3b257ea87a Update repo references after Roblox move 2020-06-23 11:55:46 -07:00
Lucien Greathouse
6b82cead9c Move from rojo-rbx org to Roblox org 2020-06-22 14:14:42 -07:00
cliffchapmanrbx
79ae4c52cd Enable CLA bot (#333) 2020-06-22 14:11:53 -07:00
Lucien Greathouse
a4616cda7d Fix test place's CharacterAutoLoads value 2020-06-20 22:16:35 -07:00
Lucien Greathouse
95648361be Recreate test place, just running in Studio 2020-06-20 21:51:26 -07:00
Lucien Greathouse
0c41e9c10b Depend on latest Rojo release from Rojo 2020-06-20 21:50:25 -07:00
Lucien Greathouse
61c7ef3cb0 plugin: lazily access settings() to help with testing 2020-06-20 21:50:14 -07:00
Lucien Greathouse
65898125d0 Update changelog 2020-06-17 23:14:24 -07:00
Lucien Greathouse
da05078ff3 Load project file from VFS instead of through fs.
Fixes #320.

Previously, the root project file was loaded via methods on Project
(which do not know about the VFS) instead of through the VFS like
all other disk access.

This meant that Rojo was unable to build its own plugin because
there is no project file on the real disk, only in the VFS.
2020-06-17 23:13:29 -07:00
Lucien Greathouse
badb5c3636 Stop redundantly adding ignore paths when starting ServeSession 2020-06-17 22:54:35 -07:00
Lucien Greathouse
9453588ab1 Load built-in plugin from absolute path to make errors more apparent 2020-06-17 22:54:13 -07:00
Lucien Greathouse
4cbb3874a4 Use anyhow error reporting instead of custom 2020-06-17 14:56:09 -07:00
Lucien Greathouse
940aff7ef4 Enable globIgnorePaths by default 2020-06-17 14:42:46 -07:00
Lucien Greathouse
a3edb93273 Update Changelog 2020-06-17 14:38:39 -07:00
Lucien Greathouse
782b054b1a Pass build watch argument into Vfs 2020-06-17 14:11:48 -07:00
Lucien Greathouse
fc27b2911e Allow turning off file watching in memofs.
Also preemptively bumping version to 0.1.3 so I don't forget on next release
2020-06-17 14:06:44 -07:00
Lucien Greathouse
486b067567 Flatten snapshot middleware to be much simpler (#324)
* First take at flattening middleware for simpler code and better perf

* Undo debug prints

* Fix using wrong path in snapshot_from_vfs

* Disable some broken tests

* Re-enable (mistakenly?) disabled CSV test

* Fix some tests

* Update project file tests

* Fix benchmark
2020-06-17 13:47:09 -07:00
Lucien Greathouse
bdd1afea57 Run CI on master and PRs to master only 2020-05-20 15:30:44 -07:00
Lucien Greathouse
5ccd02939b Replace rojo-test with regular tests folder again (#323)
* Replace rojo-test with regular tests folder again

* Bump MSRV to 1.43.1
2020-05-20 15:30:05 -07:00
Lucien Greathouse
ca5b8ab309 Restore improperly tested dependency on rojo from rojo-test 2020-05-20 11:56:34 -07:00
Lucien Greathouse
9481fdd38d Add missing Cargo.lock change 2020-05-02 21:47:36 -07:00
Lucien Greathouse
56bf6d282b Stop building Rojo in rojo-test, since it doesn't work as intended 2020-05-02 21:39:13 -07:00
Lucien Greathouse
5364c9c1bc Fix Lua string escaping.
Closes #314.
2020-04-16 12:00:02 -07:00
Lucien Greathouse
a4d4beeb97 Update default place template 2020-03-30 11:34:41 -07:00
Lucien Greathouse
30a01381be Fix malformed CSV files causing crashes; fixes #310 2020-03-30 11:12:20 -07:00
Lucien Greathouse
def99a9e4d Update release job to fix 6.0.0 release 2020-03-29 17:09:52 -07:00
Lucien Greathouse
1214fc8b0d Release 6.0.0-rc.1
This change also includes some minor packaging changes in order to make Cargo happy.
2020-03-29 16:58:37 -07:00
Lucien Greathouse
5a5b1268d3 Update changelog 2020-03-29 16:03:58 -07:00
jeparlefrancais
6a1fffd1ce Infer class name (#210)
* infer service names

* Update project code and add support for StarterPlayer

* Store parent_class in InstigatingSource

* Update snapshots

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2020-03-29 16:03:15 -07:00
Lucien Greathouse
571ef3060a Update Changelog 2020-03-29 13:46:27 -07:00
jeparlefrancais
3cf82e112f Install plugin from CLI (#304)
* add install command

* cargo fmt

* filter spec files

* Update src/cli/plugin.rs

Co-Authored-By: Lucien Greathouse <me@lpghatguy.com>

* Update src/cli/plugin.rs

Co-Authored-By: Lucien Greathouse <me@lpghatguy.com>

* fix comments

* encode plugin with rbx_binary

* update build script

* refactor pathbuf error into io error

* fix rojo typo

* remove snafu

* Update `snapshot_from_fs_path`

* Print `rerun-if-changed` even for directories, in order to run the
  build.rs script when files are added.

* Switch `filter_map` loop to a regular for loop. I like the FP-style
  iterator stuff in Rust, but I think Result handling is easier in a
  normal loop. Also, I don't believe the result of read_dir implements
  `ExactSizedIterator`, so some of the wins of map+collect aren't there.

* Replace Result::unwrap with ? in build.rs

* Simplify error handling code in runtime

* Checkout with submodules

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2020-03-29 13:41:54 -07:00
Lucien Greathouse
9b459c20d6 Fix GHA only running on pushes to master 2020-03-29 13:17:06 -07:00
Lucien Greathouse
5c85cd27c3 Fix default place project.
Closes #311.
2020-03-29 12:21:55 -07:00
Lucien Greathouse
4bf73c7a8a Implement support for turning .json files into Lua modules (#308)
* Stub implementation

* Flesh out feature and add tests. Other snapshots currently failing.

* Blacklist .meta.json in JSON handler

* Write to correct property (Source) instead of Value

* Update changelog
2020-03-28 00:36:01 -07:00
Lucien Greathouse
62e51b7535 Upgrade to latest rbx-dom 2020-03-27 23:58:31 -07:00
Lucien Greathouse
729a7f0053 Turn panics into errors in ServeSession 2020-03-26 12:16:55 -07:00
Lucien Greathouse
03c297190d Make ServeSession::new fallible 2020-03-26 12:07:44 -07:00
Lucien Greathouse
9c790eddd7 Tidy up ServeSession now that trait bounds are gone 2020-03-26 12:06:16 -07:00
Lucien Greathouse
8ebe7e332b Update Changelog 2020-03-25 17:02:32 -07:00
Lucien Greathouse
f43777e37e Require a Rojo project again (#307) 2020-03-25 17:01:28 -07:00
Lucien Greathouse
691a8fcdeb Upgrade lockfile using latest rustc 2020-03-25 16:15:21 -07:00
Lucien Greathouse
69c0e8d70e Fix warnings 2020-03-21 17:49:56 -07:00
Lucien Greathouse
330c92c9a8 Refactor ChangeProcessor loop to get rid of panics 2020-03-21 17:47:25 -07:00
Lucien Greathouse
cf0ff60d31 plugin: Add simple signal implementation for future work 2020-03-18 23:31:22 -07:00
Lucien Greathouse
9e9cf5dd1f plugin: Add support for pausing updates tracked by InstanceMap 2020-03-18 23:27:30 -07:00
Lucien Greathouse
5768d8e4a4 plugin: Miscellaneous cleanup 2020-03-18 23:15:03 -07:00
Lucien Greathouse
3b433e53be Memofs v0.1.1 2020-03-18 18:35:44 -07:00
Lucien Greathouse
28ddf40344 memofs: Update fs-err and use it more 2020-03-18 18:06:58 -07:00
Lucien Greathouse
c1286db9c1 Update Changelog 2020-03-18 16:26:41 -07:00
Lucien Greathouse
f13940262e Update CHANGELOG 2020-03-18 12:03:50 -07:00
Lucien Greathouse
9f0a6101b8 Add configurable color options 2020-03-18 12:03:07 -07:00
Lucien Greathouse
0b0fe01a7c Tidy up root repository files 2020-03-18 11:40:12 -07:00
Lucien Greathouse
85e098d5c8 Update README 2020-03-18 11:36:50 -07:00
Lucien Greathouse
e8d1faf4e2 Update changelog 2020-03-18 10:43:42 -07:00
Lucien Greathouse
2a46df1110 Expose two-way sync.
- Convert plugin DevSettings flag to settings panel feature
- Remove server feature, always enable write API
2020-03-18 10:39:40 -07:00
Lucien Greathouse
1601e6d26e Update changelog 2020-03-17 23:20:40 -07:00
Lucien Greathouse
0e4f6dea2b plugin: Add setting for opening scripts externally 2020-03-17 23:20:05 -07:00
Lucien Greathouse
a2356773dc Add checkbox and fill out settings panel 2020-03-17 23:14:32 -07:00
Lucien Greathouse
4a4da4737d Fix plugin settings persistent 2020-03-17 23:03:59 -07:00
Lucien Greathouse
2cefd1bf2e plugin: Add PluginSettings context item, render it in settings screen 2020-03-17 23:03:01 -07:00
Lucien Greathouse
c5ce15fe34 plugin: Add dummy settings panel 2020-03-17 22:38:53 -07:00
Lucien Greathouse
76dea568c9 Update changelog 2020-03-17 22:30:00 -07:00
Lucien Greathouse
8e81140eff Increase verbosity of logging 2020-03-17 22:29:23 -07:00
Lucien Greathouse
d58e1f0792 Add logging when running rojo build 2020-03-17 22:28:38 -07:00
Lucien Greathouse
830c242751 plugin: Stop using codename in dev mode 2020-03-17 22:25:04 -07:00
Lucien Greathouse
91d45afd0f Add plugin feature 'UnstableOpenScriptsExternally' 2020-03-17 18:13:52 -07:00
Lucien Greathouse
102c77b23e Implement /api/open/{id} to open a script by ID in your default editor 2020-03-17 17:50:54 -07:00
Lucien Greathouse
aa4039a2e7 bye snafu 2020-03-16 23:37:00 -07:00
Lucien Greathouse
c065ded440 Tidy up SnapshotError a lot 2020-03-16 21:35:46 -07:00
Lucien Greathouse
f69096dadb Use thiserror and anyhow for command-level error types 2020-03-16 21:13:38 -07:00
Lucien Greathouse
363f95ba14 memofs: Use fs_err instead of std::fs when possible 2020-03-16 20:35:58 -07:00
Lucien Greathouse
dcc15e8911 Refactor upload to use ServeSession and drop common_setup 2020-03-16 20:20:12 -07:00
218 changed files with 26412 additions and 3810 deletions

View File

@@ -1,6 +1,13 @@
name: CI
on: [push]
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
@@ -9,10 +16,12 @@ jobs:
strategy:
matrix:
rust_version: [stable, "1.40.0"]
rust_version: [stable, "1.43.1"]
steps:
- uses: actions/checkout@v1
with:
submodules: true
- name: Setup Rust toolchain
run: rustup default ${{ matrix.rust_version }}
@@ -27,10 +36,4 @@ jobs:
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
if: matrix.rust_version == 'stable'

View File

@@ -10,6 +10,8 @@ jobs:
steps:
- uses: actions/checkout@v1
with:
submodules: true
- name: Build release binary
run: cargo build --verbose --locked --release
@@ -25,6 +27,8 @@ jobs:
steps:
- uses: actions/checkout@v1
with:
submodules: true
- name: Install Rust
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
@@ -47,6 +51,8 @@ jobs:
steps:
- uses: actions/checkout@v1
with:
submodules: true
- name: Build
run: cargo build --locked --verbose --release

8
.gitignore vendored
View File

@@ -10,9 +10,15 @@
/*.rbxl
/*.rbxlx
# Test places for the Roblox Studio Plugin
/plugin/*.rbxlx
# Roblox Studio holds 'lock' files on places
*.rbxl.lock
*.rbxlx.lock
# Snapshot files from the 'insta' Rust crate
**/*.snap.new
**/*.snap.new
# Selene generates a roblox.toml file that should not be checked in.
/roblox.toml

5
.gitmodules vendored
View File

@@ -9,7 +9,4 @@
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
url = https://github.com/osyrisrblx/t.git

View File

@@ -33,7 +33,7 @@ stds.plugin = {
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"it", "itFOCUS", "itSKIP", "itFIXME",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}

View File

@@ -1,6 +1,31 @@
# Rojo Changelog
## Unreleased Changes for 0.6.x
## Unreleased Changes
## [6.0.0 Release Candidate 2](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (November 19, 2020)
* Fixed crash when malformed CSV files are put into a project. ([#310](https://github.com/rojo-rbx/rojo/issues/310))
* Fixed incorrect string escaping when producing Lua code from JSON files. ([#314](https://github.com/rojo-rbx/rojo/issues/314))
* Fixed performance issues introduced in Rojo 6.0.0-rc.1. ([#317](https://github.com/rojo-rbx/rojo/issues/317))
* Fixed `rojo plugin install` subcommand failing for everyone except Rojo developers. ([#320](https://github.com/rojo-rbx/rojo/issues/320))
* Updated default place template to take advantage of [#210](https://github.com/rojo-rbx/rojo/pull/210).
* Enabled glob ignore patterns by default and removed the `unstable_glob_ignore` feature.
* `globIgnorePaths` can be set on a project to a list of globs to ignore.
* The Rojo plugin now completes as much as it can from a patch without disconnecting. Warnings are shown in the console.
* Fixed 6.0.0-rc.1 regression causing instances that changed ClassName to instead... not change ClassName.
## [6.0.0 Release Candidate 1](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (March 29, 2020)
This release jumped from 0.6.0 to 6.0.0. Rojo has been in use in production for many users for quite a long times, and so 6.0 is a more accurate reflection of Rojo's version than a pre-1.0 version.
* Added basic settings panel to plugin, with two settings:
* "Open Scripts Externally": When enabled, opening a script in Studio will instead open it in your default text editor.
* "Two-Way Sync": When enabled, Rojo will attempt to save changes to your place back to the filesystem. **Very early feature, very broken, beware!**
* Added `--color` option to force-enable or force-disable color in Rojo's output.
* Added support for turning `.json` files into `ModuleScript` instances ([#308](https://github.com/rojo-rbx/rojo/pull/308))
* Added `rojo plugin install` and `rojo plugin uninstall` to allow Rojo to manage its Roblox Studio plugin. ([#304](https://github.com/rojo-rbx/rojo/pull/304))
* Class names no longer need to be specified for Roblox services in Rojo projects. ([#210](https://github.com/rojo-rbx/rojo/pull/210))
* The server half of **experimental** two-way sync is now enabled by default.
* Increased default logging verbosity in commands like `rojo build`.
* Rojo now requires a project file again, just like 0.5.4.
## [0.6.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.3) (March 13, 2020)
* Added `--watch` argument to `rojo build`. ([#284](https://github.com/rojo-rbx/rojo/pull/284))
@@ -108,7 +133,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
## [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
* Building [*Road Not Taken*](https://github.com/LPGhatguy/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.
@@ -305,4 +330,4 @@ This is a general maintenance release for the Rojo 0.5.x release series.
* 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)
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)

View File

@@ -1,5 +1,5 @@
# 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).
Rojo is a big project and can always use more help!
Some of the repositories covered are:
@@ -15,8 +15,7 @@ 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)
* [Foreman](https://github.com/Roblox/foreman)
## Documentation
Documentation impacts way more people than the individual lines of code we write.
@@ -33,15 +32,13 @@ 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)
1. Bump server version in [`Cargo.toml`](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
@@ -52,4 +49,5 @@ The Rojo release process is pretty manual right now. If you need to do it, here'
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
* Write a small summary of each major feature
* Attach release artifacts from GitHub Actions for each platform

2546
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rojo"
version = "0.6.0-alpha.3"
version = "6.0.0-rc.2"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
@@ -11,7 +11,6 @@ readme = "README.md"
edition = "2018"
exclude = [
"/plugin/**",
"/test-projects/**",
]
@@ -24,18 +23,11 @@ panic = "abort"
[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",
"memofs",
@@ -43,7 +35,6 @@ members = [
default-members = [
".",
"rojo-test",
"rojo-insta-ext",
"memofs",
]
@@ -61,12 +52,15 @@ name = "build"
harness = false
[dependencies]
memofs = { version = "0.1.0", path = "memofs" }
memofs = { version = "0.1.2", path = "memofs" }
anyhow = "1.0.27"
backtrace = "0.3"
bincode = "1.2.1"
crossbeam-channel = "0.4.0"
csv = "1.1.1"
env_logger = "0.7.1"
fs-err = "2.2.0"
futures = "0.1.29"
globset = "0.4.4"
humantime = "1.3.0"
@@ -85,17 +79,26 @@ regex = "1.3.1"
reqwest = "0.9.20"
ritz = "0.1.0"
rlua = "0.17.0"
roblox_install = "0.2.2"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
snafu = "0.6.0"
structopt = "0.3.5"
termcolor = "1.0.5"
thiserror = "1.0.11"
tokio = "0.1.22"
uuid = { version = "0.8.1", features = ["v4", "serde"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.6.2"
[build-dependencies]
memofs = { version = "0.1.3", path = "memofs" }
anyhow = "1.0.27"
bincode = "1.2.1"
fs-err = "2.3.0"
maplit = "1.0.1"
[dev-dependencies]
rojo-insta-ext = { path = "rojo-insta-ext" }

View File

@@ -13,8 +13,8 @@
<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" />
<a href="https://rojo.space/docs">
<img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" />
</a>
</div>
@@ -34,11 +34,10 @@ Rojo enables:
* Streaming `rbxmx` and `rbxm` models into your game in real time
* Packaging and deploying your project to Roblox.com from the command line
Soon, Rojo will be able to:
In the future, 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
* Automatically convert your existing game to work with Rojo
* Import custom instances like MoonScript code
## [Documentation](https://rojo.space/docs)
@@ -49,7 +48,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome!
Rojo supports Rust 1.40.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
Rojo supports Rust 1.43.1 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -4,27 +4,19 @@
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Common": {
"$path": "src/common"
"$path": "src/shared"
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"Server": {
"$path": "src/server"
}
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"Client": {
"$path": "src/client"
}
@@ -32,7 +24,6 @@
},
"Workspace": {
"$className": "Workspace",
"$properties": {
"FilteringEnabled": true
},
@@ -60,7 +51,6 @@
}
},
"Lighting": {
"$className": "Lighting",
"$properties": {
"Ambient": [
0,
@@ -74,16 +64,9 @@
}
},
"SoundService": {
"$className": "SoundService",
"$properties": {
"RespectFilteringEnabled": true
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
}
}
}

View File

@@ -35,6 +35,7 @@ fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
let options = BuildCommand {
project: input,
watch: false,
output,
};

74
build.rs Normal file
View File

@@ -0,0 +1,74 @@
use std::{
env, io,
path::{Path, PathBuf},
};
use fs_err as fs;
use fs_err::File;
use maplit::hashmap;
use memofs::VfsSnapshot;
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
println!("cargo:rerun-if-changed={}", path.display());
if path.is_dir() {
let mut children = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let file_name = entry.file_name().to_str().unwrap().to_owned();
// We can skip any TestEZ test files since they aren't necessary for
// the plugin to run.
if file_name.ends_with(".spec.lua") {
continue;
}
let child_snapshot = snapshot_from_fs_path(&entry.path())?;
children.push((file_name, child_snapshot));
}
Ok(VfsSnapshot::dir(children))
} else {
let content = fs::read_to_string(path)?;
Ok(VfsSnapshot::file(content))
}
}
fn main() -> Result<(), anyhow::Error> {
let out_dir = env::var_os("OUT_DIR").unwrap();
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
let plugin_root = PathBuf::from(root_dir).join("plugin");
let plugin_modules = plugin_root.join("modules");
let snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?,
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
"modules" => VfsSnapshot::dir(hashmap! {
"roact" => VfsSnapshot::dir(hashmap! {
"src" => snapshot_from_fs_path(&plugin_modules.join("roact").join("src"))?
}),
"promise" => VfsSnapshot::dir(hashmap! {
"lib" => snapshot_from_fs_path(&plugin_modules.join("promise").join("lib"))?
}),
"t" => VfsSnapshot::dir(hashmap! {
"lib" => snapshot_from_fs_path(&plugin_modules.join("t").join("lib"))?
}),
}),
});
let out_path = Path::new(&out_dir).join("plugin.bincode");
let out_file = File::create(&out_path)?;
bincode::serialize_into(out_file, &snapshot)?;
Ok(())
}

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
}

3
foreman.toml Normal file
View File

@@ -0,0 +1,3 @@
[tools]
rojo = { source = "rojo-rbx/rojo", version = "6.0.0-rc.1" }
run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" }

View File

@@ -1,6 +1,13 @@
# memofs Changelog
## Unreleased Changes
* Added `set_watch_enabled` to `Vfs` and `VfsLock` to allow turning off file watching.
## 0.1.2 (2020-03-29)
* `VfsSnapshot` now implements Serde's `Serialize` and `Deserialize` traits.
## 0.1.1 (2020-03-18)
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
## 0.1.0 (2020-03-10)
* Initial release

View File

@@ -1,7 +1,7 @@
[package]
name = "memofs"
description = "Virtual filesystem with configurable backends."
version = "0.1.0"
version = "0.1.3"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
readme = "README.md"
@@ -11,5 +11,7 @@ homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
notify = "4.0.15"
crossbeam-channel = "0.4.0"
fs-err = "2.3.0"
notify = "4.0.15"
serde = { version = "1.0", features = ["derive"] }

View File

@@ -19,4 +19,4 @@ memofs is currently an unstable minimum viable library. Its primary consumer is
* Configurable caching (write-through, write-around, write-back)
## License
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.

View File

@@ -140,13 +140,18 @@ pub enum VfsEvent {
/// the public interfaces to this type.
struct VfsInner {
backend: Box<dyn VfsBackend>,
watch_enabled: bool,
}
impl VfsInner {
fn read<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<Vec<u8>>> {
let path = path.as_ref();
let contents = self.backend.read(path)?;
self.backend.watch(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
Ok(Arc::new(contents))
}
@@ -159,7 +164,11 @@ impl VfsInner {
fn read_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<ReadDir> {
let path = path.as_ref();
let dir = self.backend.read_dir(path)?;
self.backend.watch(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
Ok(dir)
}
@@ -215,6 +224,7 @@ impl Vfs {
pub fn new<B: VfsBackend>(backend: B) -> Self {
let lock = VfsInner {
backend: Box::new(backend),
watch_enabled: true,
};
Self {
@@ -229,6 +239,16 @@ impl Vfs {
}
}
/// Turns automatic file watching on or off. Enabled by default.
///
/// Turning off file watching may be useful for single-use cases, especially
/// on platforms like macOS where registering file watches has significant
/// performance cost.
pub fn set_watch_enabled(&self, enabled: bool) {
let mut inner = self.inner.lock().unwrap();
inner.watch_enabled = enabled;
}
/// Read a file from the VFS, or the underlying backend if it isn't
/// resident.
///
@@ -318,6 +338,15 @@ pub struct VfsLock<'a> {
}
impl VfsLock<'_> {
/// Turns automatic file watching on or off. Enabled by default.
///
/// Turning off file watching may be useful for single-use cases, especially
/// on platforms like macOS where registering file watches has significant
/// performance cost.
pub fn set_watch_enabled(&mut self, enabled: bool) {
self.inner.watch_enabled = enabled;
}
/// Read a file from the VFS, or the underlying backend if it isn't
/// resident.
///

View File

@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// A slice of a tree of files. Can be loaded into an
/// [`InMemoryFs`](struct.InMemoryFs.html).
#[derive(Debug)]
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum VfsSnapshot {
File {

View File

@@ -1,4 +1,3 @@
use std::fs;
use std::io;
use std::path::Path;
use std::sync::mpsc;
@@ -55,15 +54,15 @@ impl StdBackend {
impl VfsBackend for StdBackend {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> {
fs::read(path)
fs_err::read(path)
}
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> {
fs::write(path, data)
fs_err::write(path, data)
}
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let entries: Result<Vec<_>, _> = fs::read_dir(path)?.collect();
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
let mut entries = entries?;
entries.sort_by_cached_key(|entry| entry.file_name());
@@ -78,15 +77,15 @@ impl VfsBackend for StdBackend {
}
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
fs::remove_file(path)
fs_err::remove_file(path)
}
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> {
fs::remove_dir_all(path)
fs_err::remove_dir_all(path)
}
fn metadata(&mut self, path: &Path) -> io::Result<Metadata> {
let inner = fs::metadata(path)?;
let inner = fs_err::metadata(path)?;
Ok(Metadata {
is_file: inner.is_file(),

10
perf-test.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -e
cargo build --release
echo "Known good:"
time rojo build ../uiblox/test-place.project.json -o UIBlox.rbxlx
echo "Current:"
time ./target/release/rojo build ../uiblox/test-place.project.json -o UIBlox.rbxlx

View File

@@ -14,6 +14,9 @@
"Fmt": {
"$path": "fmt"
},
"RbxDom": {
"$path": "rbx_dom_lua"
},
"Roact": {
"$path": "modules/roact/src"
},
@@ -22,9 +25,6 @@
},
"t": {
"$path": "modules/t/lib"
},
"RbxDom": {
"$path": "modules/rbx-dom/rbx_dom_lua/src"
}
}
}

View File

@@ -53,4 +53,8 @@ function Log.warn(template, ...)
end
end
function Log.error(template, ...)
error(Fmt.fmt(template, ...))
end
return Log

View File

@@ -0,0 +1,44 @@
stds.roblox = {
read_globals = {
game = {
other_fields = true,
},
-- Roblox globals
"script",
-- Extra functions
"tick", "warn",
"wait", "typeof",
-- Types
"CFrame",
"Color3",
"Enum",
"Instance",
"NumberRange",
"Rect",
"UDim", "UDim2",
"Vector2", "Vector3",
"Vector2int16", "Vector3int16",
}
}
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}
}
ignore = {
"212", -- unused arguments
}
std = "lua51+roblox"
files["**/*.spec.lua"] = {
std = "+testez",
}

View File

@@ -0,0 +1,2 @@
# rbx_dom_lua
Roblox Lua implementation of rbx-dom mechanisms, intended to work with rbx_dom_weak and friends.

View File

@@ -0,0 +1,6 @@
{
"name": "rbx_dom_lua",
"tree": {
"$path": "src"
}
}

View File

@@ -0,0 +1,242 @@
local base64 = require(script.Parent.base64)
local function identity(...)
return ...
end
local function unpackDecoder(f)
return function(value)
return f(unpack(value))
end
end
local function serializeFloat(value)
-- TODO: Figure out a better way to serialize infinity and NaN, neither of
-- which fit into JSON.
if value == math.huge or value == -math.huge then
return 999999999 * math.sign(value)
end
return value
end
local encoders
encoders = {
Bool = identity,
Content = identity,
Float32 = serializeFloat,
Float64 = serializeFloat,
Int32 = identity,
Int64 = identity,
String = identity,
BinaryString = base64.encode,
SharedString = base64.encode,
BrickColor = function(value)
return value.Number
end,
CFrame = function(value)
return {value:GetComponents()}
end,
Color3 = function(value)
return {value.r, value.g, value.b}
end,
NumberRange = function(value)
return {value.Min, value.Max}
end,
NumberSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = {
Time = keypoint.Time,
Value = keypoint.Value,
Envelope = keypoint.Envelope,
}
end
return {
Keypoints = keypoints,
}
end,
ColorSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = {
Time = keypoint.Time,
Color = encoders.Color3(keypoint.Value),
}
end
return {
Keypoints = keypoints,
}
end,
Rect = function(value)
return {
Min = {value.Min.X, value.Min.Y},
Max = {value.Max.X, value.Max.Y},
}
end,
UDim = function(value)
return {value.Scale, value.Offset}
end,
UDim2 = function(value)
return {value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset}
end,
Vector2 = function(value)
return {
serializeFloat(value.X),
serializeFloat(value.Y),
}
end,
Vector2int16 = function(value)
return {value.X, value.Y}
end,
Vector3 = function(value)
return {
serializeFloat(value.X),
serializeFloat(value.Y),
serializeFloat(value.Z),
}
end,
Vector3int16 = function(value)
return {value.X, value.Y, value.Z}
end,
PhysicalProperties = function(value)
if value == nil then
return nil
else
return {
Density = value.Density,
Friction = value.Friction,
Elasticity = value.Elasticity,
FrictionWeight = value.FrictionWeight,
ElasticityWeight = value.ElasticityWeight,
}
end
end,
Ref = function(value)
return nil
end,
}
local decoders = {
Bool = identity,
Content = identity,
Enum = identity,
Float32 = identity,
Float64 = identity,
Int32 = identity,
Int64 = identity,
String = identity,
BinaryString = base64.decode,
SharedString = base64.decode,
BrickColor = BrickColor.new,
CFrame = unpackDecoder(CFrame.new),
Color3 = unpackDecoder(Color3.new),
Color3uint8 = unpackDecoder(Color3.fromRGB),
NumberRange = unpackDecoder(NumberRange.new),
UDim = unpackDecoder(UDim.new),
UDim2 = unpackDecoder(UDim2.new),
Vector2 = unpackDecoder(Vector2.new),
Vector2int16 = unpackDecoder(Vector2int16.new),
Vector3 = unpackDecoder(Vector3.new),
Vector3int16 = unpackDecoder(Vector3int16.new),
Rect = function(value)
return Rect.new(value.Min[1], value.Min[2], value.Max[1], value.Max[2])
end,
NumberSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = NumberSequenceKeypoint.new(
keypoint.Time,
keypoint.Value,
keypoint.Envelope
)
end
return NumberSequence.new(keypoints)
end,
ColorSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = ColorSequenceKeypoint.new(
keypoint.Time,
Color3.new(unpack(keypoint.Color))
)
end
return ColorSequence.new(keypoints)
end,
PhysicalProperties = function(properties)
if properties == nil then
return nil
else
return PhysicalProperties.new(
properties.Density,
properties.Friction,
properties.Elasticity,
properties.FrictionWeight,
properties.ElasticityWeight
)
end
end,
Ref = function()
return nil
end,
}
local EncodedValue = {}
function EncodedValue.decode(encodedValue)
local decoder = decoders[encodedValue.Type]
if decoder ~= nil then
return true, decoder(encodedValue.Value)
end
return false, "Couldn't decode value " .. tostring(encodedValue.Type)
end
function EncodedValue.encode(rbxValue, propertyType)
assert(propertyType ~= nil, "Property type descriptor is required")
if propertyType.type == "Data" then
local encoder = encoders[propertyType.name]
if encoder == nil then
return false, ("Missing encoder for property type %q"):format(propertyType.name)
end
if encoder ~= nil then
return true, {
Type = propertyType.name,
Value = encoder(rbxValue),
}
end
elseif propertyType.type == "Enum" then
return true, {
Type = "Enum",
Value = rbxValue.Value,
}
end
return false, ("Unknown property descriptor type %q"):format(tostring(propertyType.type))
end
return EncodedValue

View File

@@ -0,0 +1,127 @@
return function()
local RbxDom = require(script.Parent)
local EncodedValue = require(script.Parent.EncodedValue)
it("should decode Rect values", function()
local input = {
Type = "Rect",
Value = {
Min = {1, 2},
Max = {3, 4},
},
}
local output = Rect.new(1, 2, 3, 4)
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
it("should decode ColorSequence values", function()
local input = {
Type = "ColorSequence",
Value = {
Keypoints = {
{
Time = 0,
Color = { 0.12, 0.34, 0.56 },
},
{
Time = 1,
Color = { 0.13, 0.33, 0.37 },
},
}
},
}
local output = ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.new(0.12, 0.34, 0.56)),
ColorSequenceKeypoint.new(1, Color3.new(0.13, 0.33, 0.37)),
})
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
it("should decode NumberSequence values", function()
local input = {
Type = "NumberSequence",
Value = {
Keypoints = {
{
Time = 0,
Value = 0.5,
Envelope = 0,
},
{
Time = 1,
Value = 0.5,
Envelope = 0,
},
}
},
}
local output = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0.5, 0),
NumberSequenceKeypoint.new(1, 0.5, 0),
})
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
it("should decode PhysicalProperties values", function()
local input = {
Type = "PhysicalProperties",
Value = {
Density = 0.1,
Friction = 0.2,
Elasticity = 0.3,
FrictionWeight = 0.4,
ElasticityWeight = 0.5,
},
}
local output = PhysicalProperties.new(
0.1,
0.2,
0.3,
0.4,
0.5
)
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
-- This part of rbx_dom_lua needs some work still.
itSKIP("should encode Rect values", function()
local input = Rect.new(10, 20, 30, 40)
local output = {
Type = "Rect",
Value = {
Min = {10, 20},
Max = {30, 40},
},
}
local descriptor = RbxDom.findCanonicalPropertyDescriptor("ImageLabel", "SliceCenter")
local ok, encoded = EncodedValue.encode(input, descriptor)
assert(ok, encoded)
expect(encoded.Type).to.equal(output.Type)
expect(encoded.Value.Min[1]).to.equal(output.Value.Min[1])
expect(encoded.Value.Min[2]).to.equal(output.Value.Min[2])
expect(encoded.Value.Max[1]).to.equal(output.Value.Max[1])
expect(encoded.Value.Max[2]).to.equal(output.Value.Max[2])
end)
end

View File

@@ -0,0 +1,28 @@
local Error = {}
Error.__index = Error
Error.Kind = {
UnknownProperty = "UnknownProperty",
PropertyNotReadable = "PropertyNotReadable",
PropertyNotWritable = "PropertyNotWritable",
Roblox = "Roblox",
}
setmetatable(Error.Kind, {
__index = function(_, key)
error(("%q is not a valid member of Error.Kind"):format(tostring(key)), 2)
end,
})
function Error.new(kind, extra)
return setmetatable({
kind = kind,
extra = extra,
}, Error)
end
function Error:__tostring()
return ("Error(%s: %s)"):format(self.kind, tostring(self.extra))
end
return Error

View File

@@ -0,0 +1,80 @@
local Error = require(script.Parent.Error)
local customProperties = require(script.Parent.customProperties)
-- A wrapper around a property descriptor from the reflection database with some
-- extra convenience methods.
--
-- The aim of this API is to facilitate looking up a property once, then reading
-- from it or writing to it multiple times. It's also useful when a consumer
-- wants to check additional constraints on the property before trying to use
-- it, like scriptability.
local PropertyDescriptor = {}
PropertyDescriptor.__index = PropertyDescriptor
local function get(container, key)
return container[key]
end
local function set(container, key, value)
container[key] = value
end
function PropertyDescriptor.fromRaw(data, className, propertyName)
return setmetatable({
scriptability = data.scriptability,
className = className,
name = propertyName,
}, PropertyDescriptor)
end
function PropertyDescriptor:read(instance)
if self.scriptability == "ReadWrite" or self.scriptability == "Read" then
local success, value = xpcall(get, debug.traceback, instance, self.name)
if success then
return success, value
else
return false, Error.new(Error.Kind.Roblox, value)
end
end
if self.scriptability == "Custom" then
local interface = customProperties[self.className][self.name]
return interface.read(instance, self.name)
end
if self.scriptability == "None" or self.scriptability == "Write" then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
end
error(("Internal error: unexpected value of 'scriptability': %s"):format(tostring(self.scriptability)), 2)
end
function PropertyDescriptor:write(instance, value)
if self.scriptability == "ReadWrite" or self.scriptability == "Write" then
local success, err = xpcall(set, debug.traceback, instance, self.name, value)
if success then
return success
else
return false, Error.new(Error.Kind.Roblox, err)
end
end
if self.scriptability == "Custom" then
local interface = customProperties[self.className][self.name]
return interface.write(instance, self.name, value)
end
if self.scriptability == "None" or self.scriptability == "Read" then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
end
end
return PropertyDescriptor

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
return {
classes = require(script.classes)
}

View File

@@ -0,0 +1,139 @@
-- Thanks to Tiffany352 for this base64 implementation!
local floor = math.floor
local char = string.char
local function encodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
-- 3 octets become 4 hextets
for i = 1, strLen - 2, 3 do
local b1, b2, b3 = str:byte(i, i + 3)
local word = b3 + b2 * 256 + b1 * 256 * 256
local h4 = word % 64 + 1
word = floor(word / 64)
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = alphabet:sub(h4, h4)
nOut = nOut + 4
end
local remainder = strLen % 3
if remainder == 2 then
-- 16 input bits -> 3 hextets (2 full, 1 partial)
local b1, b2 = str:byte(-2, -1)
-- partial is 4 bits long, leaving 2 bits of zero padding ->
-- offset = 4
local word = b2 * 4 + b1 * 4 * 256
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = "="
elseif remainder == 1 then
-- 8 input bits -> 2 hextets (2 full, 1 partial)
local b1 = str:byte(-1, -1)
-- partial is 2 bits long, leaving 4 bits of zero padding ->
-- offset = 16
local word = b1 * 16
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = "="
out[nOut + 4] = "="
end
-- if the remainder is 0, then no work is needed
return table.concat(out, "")
end
local function decodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
local acc = 0
local nAcc = 0
local alphabetLut = {}
for i = 1, #alphabet do
alphabetLut[alphabet:sub(i, i)] = i - 1
end
-- 4 hextets become 3 octets
for i = 1, strLen do
local ch = str:sub(i, i)
local byte = alphabetLut[ch]
if byte then
acc = acc * 64 + byte
nAcc = nAcc + 1
end
if nAcc == 4 then
local b3 = acc % 256
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
out[nOut + 3] = char(b3)
nOut = nOut + 3
nAcc = 0
acc = 0
end
end
if nAcc == 3 then
-- 3 hextets -> 16 bit output
acc = acc * 64
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
elseif nAcc == 2 then
-- 2 hextets -> 8 bit output
acc = acc * 64
acc = floor(acc / 256)
acc = acc * 64
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
elseif nAcc == 1 then
error("Base64 has invalid length")
end
return table.concat(out, "")
end
return {
decode = decodeBase64,
encode = encodeBase64,
}

View File

@@ -0,0 +1,29 @@
return function()
local base64 = require(script.Parent.base64)
it("should encode and decode", function()
local function try(str, expected)
local encoded = base64.encode(str)
expect(encoded).to.equal(expected)
expect(base64.decode(encoded)).to.equal(str)
end
try("Man", "TWFu")
try("Ma", "TWE=")
try("M", "TQ==")
try("ManM", "TWFuTQ==")
try(
[[Man is distinguished, not only by his reason, but by this ]]..
[[singular passion from other animals, which is a lust of the ]]..
[[mind, that by a perseverance of delight in the continued and ]]..
[[indefatigable generation of knowledge, exceeds the short ]]..
[[vehemence of any carnal pleasure.]],
[[TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sI]]..
[[GJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYW]]..
[[xzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJ]]..
[[zZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRl]]..
[[ZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZ]]..
[[SBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=]]
)
end)
end

View File

@@ -0,0 +1,47 @@
local CollectionService = game:GetService("CollectionService")
-- Defines how to read and write properties that aren't directly scriptable.
--
-- The reflection database refers to these as having scriptability = "Custom"
return {
Instance = {
Tags = {
read = function(instance, key)
local tagList = CollectionService:GetTags(instance)
return true, table.concat(tagList, "\0")
end,
write = function(instance, key, value)
local existingTags = CollectionService:GetTags(instance)
local unseenTags = {}
for _, tag in ipairs(existingTags) do
unseenTags[tag] = true
end
local tagList = string.split(value, "\0")
for _, tag in ipairs(tagList) do
unseenTags[tag] = nil
CollectionService:AddTag(instance, tag)
end
for tag in pairs(unseenTags) do
CollectionService:RemoveTag(instance, tag)
end
return true
end,
},
},
LocalizationTable = {
Contents = {
read = function(instance, key)
return true, instance:GetContents()
end,
write = function(instance, key, value)
instance:SetContents(value)
return true
end,
},
},
}

View File

@@ -0,0 +1,67 @@
local ReflectionDatabase = require(script.ReflectionDatabase)
local Error = require(script.Error)
local PropertyDescriptor = require(script.PropertyDescriptor)
local function findCanonicalPropertyDescriptor(className, propertyName)
local currentClassName = className
repeat
local currentClass = ReflectionDatabase.classes[currentClassName]
if currentClass == nil then
return currentClass
end
local propertyData = currentClass.properties[propertyName]
if propertyData ~= nil then
if propertyData.isCanonical then
return PropertyDescriptor.fromRaw(propertyData, currentClassName, propertyName)
end
if propertyData.canonicalName ~= nil then
return PropertyDescriptor.fromRaw(
currentClass.properties[propertyData.canonicalName],
currentClassName,
propertyData.canonicalName)
end
return nil
end
currentClassName = currentClass.superclass
until currentClassName == nil
return nil
end
local function readProperty(instance, propertyName)
local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
if descriptor == nil then
local fullName = ("%s.%s"):format(instance.className, propertyName)
return false, Error.new(Error.Kind.UnknownProperty, fullName)
end
return descriptor:read(instance)
end
local function writeProperty(instance, propertyName, value)
local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
if descriptor == nil then
local fullName = ("%s.%s"):format(instance.className, propertyName)
return false, Error.new(Error.Kind.UnknownProperty, fullName)
end
return descriptor:write(instance, value)
end
return {
readProperty = readProperty,
writeProperty = writeProperty,
findCanonicalPropertyDescriptor = findCanonicalPropertyDescriptor,
Error = Error,
EncodedValue = require(script.EncodedValue),
}

View File

@@ -0,0 +1,7 @@
return function()
local RbxDom = require(script.Parent)
it("should load", function()
expect(RbxDom).to.be.ok()
end)
end

View File

@@ -0,0 +1,35 @@
{
"name": "rbx_dom_lua test place",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"RbxDom": {
"$path": "src"
},
"TestEZ": {
"$path": "modules/testez/lib"
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"Run Tests": {
"$path": "test.server.lua"
}
},
"Players": {
"$className": "Players",
"$properties": {
"CharacterAutoLoads": false
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
}
}
}

View File

@@ -0,0 +1,7 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local LIB_ROOT = ReplicatedStorage.RbxDom
local TestEZ = require(ReplicatedStorage.TestEZ)
TestEZ.TestBootstrap:run({LIB_ROOT})

View File

@@ -233,4 +233,19 @@ function ApiContext:retrieveMessages()
end)
end
function ApiContext:open(id)
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
return Http.post(url, "")
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
return nil
end)
end
return ApiContext

View File

@@ -13,11 +13,11 @@ local Version = require(Plugin.Version)
local preloadAssets = require(Plugin.preloadAssets)
local strict = require(Plugin.strict)
local Theme = require(Plugin.Components.Theme)
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 SettingsPanel = require(Plugin.Components.SettingsPanel)
local e = Roact.createElement
@@ -62,6 +62,7 @@ local AppStatus = strict("AppStatus", {
Connecting = "Connecting",
Connected = "Connected",
Error = "Error",
Settings = "Settings",
})
local App = Roact.Component:extend("App")
@@ -74,10 +75,7 @@ function App:init()
self.signals = {}
self.serveSession = nil
self.displayedVersion = DevSettings:isEnabled()
and Config.codename
or Version.display(Config.version)
self.displayedVersion = Version.display(Config.version)
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
@@ -109,12 +107,14 @@ function App:init()
end)
end
function App:startSession(address, port)
function App:startSession(address, port, sessionOptions)
Log.trace("Starting new session")
local baseUrl = ("http://%s:%s"):format(address, port)
self.serveSession = ServeSession.new({
apiContext = ApiContext.new(baseUrl),
openScriptsExternally = sessionOptions.openScriptsExternally,
twoWaySync = sessionOptions.twoWaySync,
})
self.serveSession:onStatusChanged(function(status, details)
@@ -155,8 +155,13 @@ function App:render()
if self.state.appStatus == AppStatus.NotStarted then
children = {
ConnectPanel = e(ConnectPanel, {
startSession = function(address, port)
self:startSession(address, port)
startSession = function(address, port, settings)
self:startSession(address, port, settings)
end,
openSettings = function()
self:setState({
appStatus = AppStatus.Settings,
})
end,
cancel = function()
Log.trace("Canceling session configuration")
@@ -169,7 +174,7 @@ function App:render()
}
elseif self.state.appStatus == AppStatus.Connecting then
children = {
ConnectingPanel = Roact.createElement(ConnectingPanel),
ConnectingPanel = e(ConnectingPanel),
}
elseif self.state.appStatus == AppStatus.Connected then
children = {
@@ -187,9 +192,19 @@ function App:render()
end,
}),
}
elseif self.state.appStatus == AppStatus.Settings then
children = {
e(SettingsPanel, {
back = function()
self:setState({
appStatus = AppStatus.NotStarted,
})
end,
}),
}
elseif self.state.appStatus == AppStatus.Error then
children = {
ErrorPanel = Roact.createElement(ErrorPanel, {
ErrorPanel = e(ErrorPanel, {
errorMessage = self.state.errorMessage,
onDismiss = function()
self:setState({
@@ -200,11 +215,9 @@ function App:render()
}
end
return Roact.createElement(Theme.StudioProvider, nil, {
UI = Roact.createElement(Roact.Portal, {
target = self.dockWidget,
}, children),
})
return e(Roact.Portal, {
target = self.dockWidget,
}, children)
end
function App:didMount()

View File

@@ -0,0 +1,39 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement
local function Checkbox(props)
local checked = props.checked
local layoutOrder = props.layoutOrder
local onChange = props.onChange
return Theme.with(function(theme)
return e("ImageButton", {
LayoutOrder = layoutOrder,
Size = UDim2.new(0, 20, 0, 20),
BorderSizePixel = 2,
BorderColor3 = theme.Text2,
BackgroundColor3 = theme.Background2,
[Roact.Event.Activated] = function()
onChange(not checked)
end,
}, {
Indicator = e("Frame", {
Size = UDim2.new(0, 18, 0, 18),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BorderSizePixel = 0,
BackgroundColor3 = theme.Brand1,
BackgroundTransparency = checked and 0 or 1,
})
})
end)
end
return Checkbox

View File

@@ -11,6 +11,7 @@ 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 PluginSettings = require(Plugin.Components.PluginSettings)
local e = Roact.createElement
@@ -25,138 +26,157 @@ end
function ConnectPanel:render()
local startSession = self.props.startSession
local openSettings = self.props.openSettings
return Theme.with(function(theme)
return e(Panel, nil, {
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
return PluginSettings.with(function(settings)
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, {
Inputs = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
LayoutOrder = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
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),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.TitleFont,
TextSize = 20,
Text = "Address",
TextColor3 = theme.Text1,
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.Text1,
}),
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,
}),
}),
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.Text1,
}),
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,
}),
}),
}),
Port = e(FitList, {
Buttons = e(FitList, {
fitAxes = "Y",
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 2,
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 0),
},
layoutProps = {
Padding = UDim.new(0, 4),
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),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.TitleFont,
TextSize = 20,
Text = "Port",
TextColor3 = theme.Text1,
e(FormButton, {
layoutOrder = 1,
text = "Settings",
secondary = true,
onClick = function()
if openSettings ~= nil then
openSettings()
end
end,
}),
Input = e(FormTextInput, {
e(FormButton, {
layoutOrder = 2,
width = UDim.new(0, 80),
value = self.state.port,
placeholderValue = Config.defaultPort,
onValueChange = function(newValue)
self:setState({
port = newValue,
})
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
local sessionOptions = {
openScriptsExternally = settings:get("openScriptsExternally"),
twoWaySync = settings:get("twoWaySync"),
}
startSession(address, port, sessionOptions)
end
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)
end)
end

View File

@@ -0,0 +1,121 @@
--[[
Persistent plugin settings that can be accessed via Roact context.
]]
local Rojo = script:FindFirstAncestor("Rojo")
local Roact = require(Rojo.Roact)
local defaultSettings = {
openScriptsExternally = false,
twoWaySync = false,
}
local Settings = {}
Settings.__index = Settings
function Settings.fromPlugin(plugin)
local values = {}
for name, defaultValue in pairs(defaultSettings) do
local savedValue = plugin:GetSetting("Rojo_" .. name)
if savedValue == nil then
plugin:SetSetting("Rojo_" .. name, defaultValue)
values[name] = defaultValue
else
values[name] = savedValue
end
end
return setmetatable({
__values = values,
__plugin = plugin,
__updateListeners = {},
}, Settings)
end
function Settings:get(name)
if defaultSettings[name] == nil then
error("Invalid setings name " .. tostring(name), 2)
end
return self.__values[name]
end
function Settings:set(name, value)
self.__plugin:SetSetting("Rojo_" .. name, value)
self.__values[name] = value
for callback in pairs(self.__updateListeners) do
callback(name, value)
end
end
function Settings:onUpdate(newCallback)
local newListeners = {}
for callback in pairs(self.__updateListeners) do
newListeners[callback] = true
end
newListeners[newCallback] = true
self.__updateListeners = newListeners
return function()
local newListeners = {}
for callback in pairs(self.__updateListeners) do
if callback ~= newCallback then
newListeners[callback] = true
end
end
self.__updateListeners = newListeners
end
end
local Context = Roact.createContext(nil)
local StudioProvider = Roact.Component:extend("StudioProvider")
function StudioProvider:init()
self.settings = Settings.fromPlugin(self.props.plugin)
end
function StudioProvider:render()
return Roact.createElement(Context.Provider, {
value = self.settings,
}, self.props[Roact.Children])
end
local InternalConsumer = Roact.Component:extend("InternalConsumer")
function InternalConsumer:render()
return self.props.render(self.props.settings)
end
function InternalConsumer:didMount()
self.disconnect = self.props.settings:onUpdate(function()
-- Trigger a dummy state update to update the settings consumer.
self:setState({})
end)
end
function InternalConsumer:willUnmount()
self.disconnect()
end
local function with(callback)
return Roact.createElement(Context.Consumer, {
render = function(settings)
return Roact.createElement(InternalConsumer, {
settings = settings,
render = callback,
})
end,
})
end
return {
StudioProvider = StudioProvider,
with = with,
}

View File

@@ -0,0 +1,119 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Checkbox = require(Plugin.Components.Checkbox)
local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton)
local Panel = require(Plugin.Components.Panel)
local PluginSettings = require(Plugin.Components.PluginSettings)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement
local SettingsPanel = Roact.Component:extend("SettingsPanel")
function SettingsPanel:render()
local back = self.props.back
return Theme.with(function(theme)
return PluginSettings.with(function(settings)
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 16),
}),
OpenScriptsExternally = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.MainFont,
TextSize = 16,
Text = "Open Scripts Externally",
TextColor3 = theme.Text1,
}),
Padding = e("Frame", {
Size = UDim2.new(0, 8, 0, 0),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Input = e(Checkbox, {
layoutOrder = 3,
checked = settings:get("openScriptsExternally"),
onChange = function(newValue)
settings:set("openScriptsExternally", not settings:get("openScriptsExternally"))
end,
}),
}),
TwoWaySync = e(FitList, {
containerProps = {
LayoutOrder = 2,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.MainFont,
TextSize = 16,
Text = "Two-Way Sync (Experimental!)",
TextColor3 = theme.Text1,
}),
Padding = e("Frame", {
Size = UDim2.new(0, 8, 0, 0),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Input = e(Checkbox, {
layoutOrder = 3,
checked = settings:get("twoWaySync"),
onChange = function(newValue)
settings:set("twoWaySync", not settings:get("twoWaySync"))
end,
}),
}),
BackButton = e(FormButton, {
layoutOrder = 4,
text = "Okay",
secondary = true,
onClick = function()
back()
end,
}),
})
end)
end)
end
return SettingsPanel

View File

@@ -5,7 +5,16 @@
name. This isn't exactly best practice.
]]
local Studio = settings():GetService("Studio")
-- Studio does not exist outside Roblox Studio, so we'll lazily initialize it
-- when possible.
local _Studio
local function getStudio()
if _Studio == nil then
_Studio = settings():GetService("Studio")
end
return _Studio
end
local Rojo = script:FindFirstAncestor("Rojo")
@@ -52,7 +61,7 @@ local StudioProvider = Roact.Component:extend("StudioProvider")
-- Pull the current theme from Roblox Studio and update state with it.
function StudioProvider:updateTheme()
local studioTheme = Studio.Theme
local studioTheme = getStudio().Theme
if studioTheme.Name == "Light" then
self:setState({
@@ -82,7 +91,7 @@ function StudioProvider:render()
end
function StudioProvider:didMount()
self.connection = Studio.ThemeChanged:Connect(function()
self.connection = getStudio().ThemeChanged:Connect(function()
self:updateTheme()
end)
end

View File

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

View File

@@ -25,14 +25,6 @@ local VALUES = {
[Environment.Test] = true,
},
},
UnstableTwoWaySync = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = false,
[Environment.Test] = false,
},
},
}
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
@@ -140,10 +132,6 @@ function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled")
end
function DevSettings:twoWaySyncEnabled()
return getValue("UnstableTwoWaySync")
end
function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings()
end

View File

@@ -11,15 +11,38 @@ InstanceMap.__index = InstanceMap
function InstanceMap.new(onInstanceChanged)
local self = {
-- A map from IDs to instances.
fromIds = {},
-- A map from instances to IDs.
fromInstances = {},
-- A set of all instances that updates should be paused for. This set
-- should generally be empty, and will be filled by pauseInstance
-- temporarily.
pausedUpdateInstances = {},
-- A map from instances to a signal or list of signals connected to it.
instancesToSignal = {},
-- Callback that's invoked whenever an instance is changed and it was
-- not paused.
onInstanceChanged = onInstanceChanged,
}
return setmetatable(self, InstanceMap)
end
function InstanceMap:size()
local size = 0
for _ in pairs(self.fromIds) do
size = size + 1
end
return size
end
--[[
Disconnect all connections and release all instance references.
]]
@@ -56,6 +79,9 @@ function InstanceMap:__fmtDebug(output)
end
function InstanceMap:insert(id, instance)
self:removeId(id)
self:removeInstance(instance)
self.fromIds[id] = instance
self.fromInstances[instance] = id
self:__connectSignals(instance)
@@ -68,8 +94,6 @@ function InstanceMap:removeId(id)
self:__disconnectSignals(instance)
self.fromIds[id] = nil
self.fromInstances[instance] = nil
else
Log.warn("Attempted to remove nonexistant ID {}", id)
end
end
@@ -80,8 +104,6 @@ function InstanceMap:removeInstance(instance)
if id ~= nil then
self.fromInstances[instance] = nil
self.fromIds[id] = nil
else
Log.warn("Attempted to remove nonexistant instance {}", instance)
end
end
@@ -89,10 +111,14 @@ 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)
self:removeId(id)
end
for _, descendantInstance in ipairs(instance:GetDescendants()) do
self:removeInstance(descendantInstance)
end
instance:Destroy()
end
function InstanceMap:destroyId(id)
@@ -100,21 +126,37 @@ function InstanceMap:destroyId(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)
for _, descendantInstance in ipairs(instance:GetDescendants()) do
self:removeInstance(descendantInstance)
end
instance:Destroy()
end
end
--[[
Pause updates for an instance momentarily and invoke a callback.
If the callback throws an error, InstanceMap will still be kept in a
consistent state.
]]
function InstanceMap:pauseInstance(instance, callback)
local id = self.fromInstances[instance]
-- If we don't know about this instance, ignore it and do not invoke the
-- callback.
if id == nil then
return
end
self.pausedUpdateInstances[instance] = true
local success, result = xpcall(callback, debug.traceback)
self.pausedUpdateInstances[instance] = false
if success then
return result
else
Log.warn("Attempted to destroy nonexistant ID {}", id)
error(result, 2)
end
end
@@ -150,9 +192,15 @@ end
function InstanceMap:__maybeFireInstanceChanged(instance, propertyName)
Log.trace("{}.{} changed", instance:GetFullName(), propertyName)
if self.onInstanceChanged ~= nil then
self.onInstanceChanged(instance, propertyName)
if self.pausedUpdateInstances[instance] then
return
end
if self.onInstanceChanged == nil then
return
end
self.onInstanceChanged(instance, propertyName)
end
function InstanceMap:__disconnectSignals(instance)

184
plugin/src/PatchSet.lua Normal file
View File

@@ -0,0 +1,184 @@
--[[
Methods to operate on either a patch created by the hydrate method, or a
patch returned from the API.
]]
local t = require(script.Parent.Parent.t)
local Types = require(script.Parent.Types)
local PatchSet = {}
PatchSet.validate = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[
Create a new, empty PatchSet.
]]
function PatchSet.newEmpty()
return {
removed = {},
added = {},
updated = {},
}
end
--[[
Tells whether the given PatchSet is empty.
]]
function PatchSet.isEmpty(patchSet)
return next(patchSet.removed) == nil and
next(patchSet.added) == nil and
next(patchSet.updated) == nil
end
--[[
Tells whether the given PatchSet has any remove operations.
]]
function PatchSet.hasRemoves(patchSet)
return next(patchSet.removed) ~= nil
end
--[[
Tells whether the given PatchSet has any add operations.
]]
function PatchSet.hasAdditions(patchSet)
return next(patchSet.added) ~= nil
end
--[[
Tells whether the given PatchSet has any update operations.
]]
function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil
end
--[[
Merge multiple PatchSet objects into the given PatchSet.
]]
function PatchSet.assign(target, ...)
for i = 1, select("#", ...) do
local sourcePatch = select(i, ...)
for _, removed in ipairs(sourcePatch.removed) do
table.insert(target.removed, removed)
end
for id, added in pairs(sourcePatch.added) do
target.added[id] = added
end
for _, update in ipairs(sourcePatch.updated) do
table.insert(target.updated, update)
end
end
return target
end
--[[
Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users.
]]
function PatchSet.humanSummary(instanceMap, patchSet)
local statements = {}
for _, idOrInstance in ipairs(patchSet.removed) do
local instance, id
if type(idOrInstance) == "string" then
id = idOrInstance
instance = instanceMap.fromIds[id]
else
instance = idOrInstance
id = instanceMap.fromInstances[instance]
end
if instance ~= nil then
table.insert(statements, string.format("- Delete instance %s", instance:GetFullName()))
else
table.insert(statements, string.format("- Delete instance with ID %s", id))
end
end
local additionsMentioned = {}
local function addAllDescendents(virtualInstance)
additionsMentioned[virtualInstance.Id] = true
for _, childId in ipairs(virtualInstance.Children) do
addAllDescendents(patchSet.added[childId])
end
end
for id, addition in pairs(patchSet.added) do
if additionsMentioned[id] then
continue
end
local virtualInstance = addition
while true do
if virtualInstance.Parent == nil then
break
end
local virtualParent = patchSet.added[virtualInstance.Parent]
if virtualParent == nil then
break
end
virtualInstance = virtualParent
end
local parentDisplayName = "nil (how strange!)"
if virtualInstance.Parent ~= nil then
local parent = instanceMap.fromIds[virtualInstance.Parent]
if parent ~= nil then
parentDisplayName = parent:GetFullName()
end
end
table.insert(statements, string.format(
"- Add instance %q (ClassName %q) to %s",
virtualInstance.Name, virtualInstance.ClassName, parentDisplayName))
end
for _, update in ipairs(patchSet.updated) do
local updatedProperties = {}
if update.changedMetadata ~= nil then
table.insert(updatedProperties, "Rojo's Metadata")
end
if update.changedName ~= nil then
table.insert(updatedProperties, "Name")
end
if update.changedClassName ~= nil then
table.insert(updatedProperties, "ClassName")
end
for name in pairs(update.changedProperties) do
table.insert(updatedProperties, name)
end
local instance = instanceMap.fromIds[update.id]
local displayName
if instance ~= nil then
displayName = instance:GetFullName()
else
displayName = "[unknown instance]"
end
table.insert(statements, string.format(
"- Update properties on %s: %s",
displayName, table.concat(updatedProperties, ",")))
end
return table.concat(statements, "\n")
end
return PatchSet

View File

@@ -1,401 +0,0 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
local RbxDom = require(script.Parent.Parent.RbxDom)
local t = require(script.Parent.Parent.t)
local Log = require(script.Parent.Parent.Log)
local Types = require(script.Parent.Types)
local invariant = require(script.Parent.invariant)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
--[[
This interface represents either a patch created by the hydrate method, or a
patch returned from the API.
This type should be a subset of Types.ApiInstanceUpdate.
]]
local IPatch = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[
Attempt to safely set the parent of an instance.
This function will always succeed, even if the actual set failed. This is
important for some types like services that will throw even if their current
parent is already set to the requested parent.
TODO: See if we can eliminate this by being more nuanced with property
assignment?
]]
local function safeSetParent(instance, newParent)
pcall(function()
instance.Parent = newParent
end)
end
--[[
Similar to setting Parent, some instances really don't like being renamed.
TODO: Should we be throwing away these results or can we be more careful?
]]
local function safeSetName(instance, name)
pcall(function()
instance.Name = name
end)
end
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
}
return setmetatable(self, Reconciler)
end
--[[
See Reconciler:__hydrateInternal().
]]
function Reconciler:hydrate(apiInstances, id, instance)
local hydratePatch = {
removed = {},
added = {},
updated = {},
}
self:__hydrateInternal(apiInstances, id, instance, hydratePatch)
return hydratePatch
end
--[[
Applies a patch to the Roblox DOM using the reconciler's internal state.
TODO: This function might only apply some of the patch in the future and
require content negotiation with the Rojo server to handle types that aren't
editable by scripts.
]]
local applyPatchSchema = Types.ifEnabled(t.tuple(
IPatch
))
function Reconciler:applyPatch(patch)
assert(applyPatchSchema(patch))
for _, removedIdOrInstance in ipairs(patch.removed) do
local removedInstance
if Types.RbxId(removedIdOrInstance) then
-- If this value is an ID, it's assumed to be an instance that the
-- Rojo server knows about.
removedInstance = self.__instanceMap.fromIds[removedIdOrInstance]
self.__instanceMap:removeId(removedIdOrInstance)
end
-- If this entry was an ID that we didn't know about, removedInstance
-- will be nil, which we guard against in case of minor tree desync.
if removedInstance ~= nil then
-- Ensure that if any descendants are tracked by Rojo, that we
-- properly un-track them.
for _, descendantInstance in ipairs(removedInstance:GetDescendants()) do
self.__instanceMap:removeInstance(descendantInstance)
end
removedInstance:Destroy()
end
end
-- TODO: This loop assumes that apiInstance.ParentId is never nil. The Rojo
-- plugin can't create a new top-level DataModel anyways, so this should
-- only be violated in cases that are already erroneous.
for id, apiInstance in pairs(patch.added) do
if self.__instanceMap.fromIds[id] == nil then
-- Find the first ancestor of this instance that is marked for an
-- addition.
--
-- This helps us make sure we only reify each instance once, and we
-- start from the top.
while patch.added[apiInstance.Parent] ~= nil do
id = apiInstance.Parent
apiInstance = patch.added[id]
end
local parentInstance = self.__instanceMap.fromIds[apiInstance.Parent]
if parentInstance == nil then
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
apiInstance.Parent,
self.__instanceMap
)
end
self:__reifyInstance(patch.added, id, parentInstance)
end
end
for _, update in ipairs(patch.updated) do
local instance = self.__instanceMap.fromIds[update.id]
if instance == nil then
invariant(
"Cannot update an instance that does not exist in the reconciler's state.\nInstance {}\nState: {:#?}",
update.id,
self.__instanceMap
)
end
if update.changedClassName ~= nil then
error("TODO: Support changing class name by destroying + recreating instance.")
end
if update.changedName ~= nil then
instance.Name = update.changedName
end
if update.changedMetadata ~= nil then
print("TODO: Support changing metadata, if necessary.")
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
-- TODO: Gracefully handle this error instead?
assert(setCanonicalProperty(instance, propertyName, self:__decodeApiValue(propertyValue)))
end
end
end
end
--[[
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.
]]
function Reconciler:encodeApiValue(value)
if typeof(value) == "string" then
return true, {
Type = "String",
Value = value,
}
end
return false
end
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
]]
function Reconciler:__decodeApiValue(apiValue)
assert(Types.ApiValue(apiValue))
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
if apiValue.Type == "Ref" then
-- TODO: This ref could be pointing at an instance we haven't created
-- yet!
return self.__instanceMap.fromIds[apiValue.Value]
end
local success, decodedValue = RbxDom.EncodedValue.decode(apiValue)
if not success then
error(decodedValue, 2)
end
return decodedValue
end
--[[
Constructs an instance from an ApiInstance without any of its children.
]]
local reifySingleInstanceSchema = Types.ifEnabled(t.tuple(
Types.ApiInstance
))
function Reconciler:__reifySingleInstance(apiInstance)
assert(reifySingleInstanceSchema(apiInstance))
-- Instance.new can fail if we're passing in something that can't be
-- created, like a service, something enabled with a feature flag, or
-- something that requires higher security than we have.
local ok, instance = pcall(Instance.new, apiInstance.ClassName)
if not ok then
return false, instance
end
-- TODO: When can setting Name fail here?
safeSetName(instance, apiInstance.Name)
for key, value in pairs(apiInstance.Properties) do
setCanonicalProperty(instance, key, self:__decodeApiValue(value))
end
return true, instance
end
--[[
Construct an instance and all of its descendants, parent it to the given
instance, and insert it into the reconciler's internal state.
]]
local reifyInstanceSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance
))
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)
end
end
local changedName = nil
local changedProperties = {}
if apiInstance.Name ~= instance.Name then
changedName = apiInstance.Name
end
for propertyName, virtualValue in pairs(apiInstance.Properties) do
local success, existingValue = getCanonicalProperty(instance, propertyName)
if success then
local decodedValue = self:__decodeApiValue(virtualValue)
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
end
end
-- If any properties differed from the virtual instance we read, add it to
-- the hydrate patch so that we can catch up.
if changedName ~= nil or next(changedProperties) ~= nil then
table.insert(hydratePatch.updated, {
id = id,
changedName = changedName,
changedClassName = nil,
changedProperties = changedProperties,
changedMetadata = nil,
})
end
local existingChildren = instance:GetChildren()
-- 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
for _, childId in ipairs(apiInstance.Children) do
local apiChild = apiInstances[childId]
local childInstance
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)
-- 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
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)
end
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
end
end
function Reconciler:__shouldClearUnknownChildren(apiInstance)
if apiInstance.Metadata ~= nil then
return not apiInstance.Metadata.ignoreUnknownInstances
else
return true
end
end
return Reconciler

View File

@@ -0,0 +1,37 @@
--[[
Defines the errors that can be returned by the reconciler.
]]
local Fmt = require(script.Parent.Parent.Parent.Fmt)
local Error = {}
local function makeVariant(name)
Error[name] = setmetatable({}, {
__tostring = function()
return "Error." .. name
end,
})
end
makeVariant("CannotCreateInstance")
makeVariant("CannotDecodeValue")
makeVariant("LackingPropertyPermissions")
makeVariant("OtherPropertyError")
makeVariant("RefDidNotExist")
makeVariant("UnknownProperty")
makeVariant("UnreadableProperty")
makeVariant("UnwritableProperty")
function Error.new(kind, details)
return setmetatable({
kind = kind,
details = details,
}, Error)
end
function Error:__tostring()
return Fmt.fmt("Error({}): {:#?}", self.kind, self.details)
end
return Error

View File

@@ -0,0 +1,200 @@
--[[
Apply a patch to the DOM. Returns any portions of the patch that weren't
possible to apply.
Patches can come from the server or be generated by the client.
]]
local Log = require(script.Parent.Parent.Parent.Log)
local PatchSet = require(script.Parent.Parent.PatchSet)
local Types = require(script.Parent.Parent.Types)
local invariant = require(script.Parent.Parent.invariant)
local decodeValue = require(script.Parent.decodeValue)
local reify = require(script.Parent.reify)
local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch)
-- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty()
for _, removedIdOrInstance in ipairs(patch.removed) do
if Types.RbxId(removedIdOrInstance) then
instanceMap:destroyId(removedIdOrInstance)
else
instanceMap:destroyInstance(removedIdOrInstance)
end
end
for id, virtualInstance in pairs(patch.added) do
if instanceMap.fromIds[id] ~= nil then
-- This instance already exists. We might've already added it in a
-- previous iteration of this loop, or maybe this patch was not
-- supposed to list this instance.
--
-- It's probably fine, right?
continue
end
-- 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[virtualInstance.Parent] ~= nil do
id = virtualInstance.Parent
virtualInstance = patch.added[id]
end
local parentInstance = instanceMap.fromIds[virtualInstance.Parent]
if parentInstance == nil then
-- This would be peculiar. If you create an instance with no
-- parent, were you supposed to create it at all?
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
virtualInstance.Parent,
instanceMap
)
end
local failedToReify = reify(instanceMap, patch.added, id, parentInstance)
if not PatchSet.isEmpty(failedToReify) then
Log.debug("Failed to reify as part of applying a patch: {}", failedToReify)
PatchSet.assign(unappliedPatch, failedToReify)
end
end
for _, update in ipairs(patch.updated) do
local instance = instanceMap.fromIds[update.id]
if instance == nil then
-- We can't update an instance that doesn't exist.
table.insert(unappliedPatch.updated, update)
continue
end
-- Track any part of this update that could not be applied.
local unappliedUpdate = {
id = update.id,
changedProperties = {},
}
local partiallyApplied = false
-- If the instance's className changed, we have a bumpy ride ahead while
-- we recreate this instance and move all of its children into the new
-- version atomically...ish.
if update.changedClassName ~= nil then
-- If the instance's name also changed, we'll do it here, since this
-- branch will skip the rest of the loop iteration.
local newName = update.changedName or instance.Name
-- TODO: When changing between instances that have similar sets of
-- properties, like between an ImageLabel and an ImageButton, we
-- should preserve all of the properties that are shared between the
-- two classes unless they're changed as part of this patch. This is
-- similar to how "class changer" Studio plugins work.
--
-- For now, we'll only apply properties that are mentioned in this
-- update. Patches with changedClassName set only occur in specific
-- circumstances, usually between Folder and ModuleScript instances.
-- While this may result in some issues, like not preserving the
-- "Archived" property, a robust solution is sufficiently
-- complicated that we're pushing it off for now.
local newProperties = update.changedProperties
-- If the instance's ClassName changed, we'll kick into reify to
-- create this instance. We'll handle moving all of children between
-- the instances after the new one is created.
local mockVirtualInstance = {
Id = update.id,
Name = newName,
ClassName = update.changedClassName,
Properties = newProperties,
Children = {},
}
local mockAdded = {
[update.id] = mockVirtualInstance,
}
local failedToReify = reify(instanceMap, mockAdded, update.id, instance.Parent)
local newInstance = instanceMap.fromIds[update.id]
-- Some parts of reify may have failed, but this is not necessarily
-- critical. If the instance wasn't recreated or has the wrong Name,
-- we'll consider our attempt a failure.
if instance == newInstance or newInstance.Name ~= newName then
table.insert(unappliedPatch.updated, update)
continue
end
-- Here are the non-critical failures. We know that the instance
-- succeeded in creating and that assigning Name did not fail, but
-- other property assignments might've failed.
if not PatchSet.isEmpty(failedToReify) then
PatchSet.assign(unappliedPatch, failedToReify)
end
-- Watch out, this is the scary part! Move all of the children of
-- instance into newInstance.
--
-- TODO: If this fails part way through, should we move everything
-- back? For now, we assume that moving things will not fail.
for _, child in ipairs(instance:GetChildren()) do
child.Parent = newInstance
end
-- See you later, original instance.
--
-- TODO: Can this fail? Some kinds of instance may not appreciate
-- being destroyed, like services.
instance:Destroy()
-- This completes your rebuilding a plane mid-flight safety
-- instruction. Please sit back, relax, and enjoy your flight.
continue
end
if update.changedName ~= nil then
instance.Name = update.changedName
end
if update.changedMetadata ~= nil then
-- TODO: Support changing metadata. This will become necessary when
-- Rojo persistently tracks metadata for each instance in order to
-- remove extra instances.
unappliedUpdate.changedMetadata = update.changedMetadata
partiallyApplied = true
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
local ok, decodedValue = decodeValue(propertyValue, instanceMap)
if not ok then
unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true
continue
end
local ok = setProperty(instance, propertyName, decodedValue)
if not ok then
unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true
end
end
end
if partiallyApplied then
table.insert(unappliedPatch.updated, unappliedUpdate)
end
end
return unappliedPatch
end
return applyPatch

View File

@@ -0,0 +1,198 @@
return function()
local applyPatch = require(script.Parent.applyPatch)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet)
local dummy = Instance.new("Folder")
local function wasDestroyed(instance)
-- If an instance was destroyed, its parent property is locked.
local ok = pcall(function()
local oldParent = instance.Parent
instance.Parent = dummy
instance.Parent = oldParent
end)
return not ok
end
it("should return an empty patch if given an empty patch", function()
local patch = applyPatch(InstanceMap.new(), PatchSet.newEmpty())
assert(PatchSet.isEmpty(patch), "expected remaining patch to be empty")
end)
it("should destroy instances listed for remove", function()
local root = Instance.new("Folder")
local child = Instance.new("Folder")
child.Name = "Child"
child.Parent = root
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
instanceMap:insert("CHILD", child)
local patch = PatchSet.newEmpty()
table.insert(patch.removed, child)
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
assert(not wasDestroyed(root), "expected root to be left alone")
assert(wasDestroyed(child), "expected child to be destroyed")
instanceMap:stop()
end)
it("should destroy IDs listed for remove", function()
local root = Instance.new("Folder")
local child = Instance.new("Folder")
child.Name = "Child"
child.Parent = root
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
instanceMap:insert("CHILD", child)
local patch = PatchSet.newEmpty()
table.insert(patch.removed, "CHILD")
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(instanceMap:size()).to.equal(1)
assert(not wasDestroyed(root), "expected root to be left alone")
assert(wasDestroyed(child), "expected child to be destroyed")
instanceMap:stop()
end)
it("should add instances to the DOM", function()
-- Many of the details of this functionality are instead covered by
-- tests on reify, not here.
local root = Instance.new("Folder")
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
local patch = PatchSet.newEmpty()
patch.added["CHILD"] = {
Id = "CHILD",
ClassName = "Model",
Name = "Child",
Parent = "ROOT",
Children = {"GRANDCHILD"},
Properties = {},
}
patch.added["GRANDCHILD"] = {
Id = "GRANDCHILD",
ClassName = "Part",
Name = "Grandchild",
Parent = "CHILD",
Children = {},
Properties = {},
}
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(instanceMap:size()).to.equal(3)
local child = root:FindFirstChild("Child")
expect(child).to.be.ok()
expect(child.ClassName).to.equal("Model")
expect(child).to.equal(instanceMap.fromIds["CHILD"])
local grandchild = child:FindFirstChild("Grandchild")
expect(grandchild).to.be.ok()
expect(grandchild.ClassName).to.equal("Part")
expect(grandchild).to.equal(instanceMap.fromIds["GRANDCHILD"])
end)
it("should return unapplied additions when instances cannot be created", function()
local root = Instance.new("Folder")
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
local patch = PatchSet.newEmpty()
patch.added["OOPSIE"] = {
Id = "OOPSIE",
-- Hopefully Roblox never makes an instance with this ClassName.
ClassName = "UH OH",
Name = "FUBAR",
Parent = "ROOT",
Children = {},
Properties = {},
}
local unapplied = applyPatch(instanceMap, patch)
expect(unapplied.added["OOPSIE"]).to.equal(patch.added["OOPSIE"])
expect(instanceMap:size()).to.equal(1)
expect(#root:GetChildren()).to.equal(0)
end)
it("should apply property changes to instances", function()
local value = Instance.new("StringValue")
value.Value = "HELLO"
local instanceMap = InstanceMap.new()
instanceMap:insert("VALUE", value)
local patch = PatchSet.newEmpty()
table.insert(patch.updated, {
id = "VALUE",
changedProperties = {
Value = {
Type = "String",
Value = "WORLD",
},
},
})
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(value.Value).to.equal("WORLD")
end)
it("should recreate instances when changedClassName is set, preserving children", function()
local root = Instance.new("Folder")
root.Name = "Initial Root Name"
local child = Instance.new("Folder")
child.Name = "Child"
child.Parent = root
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
instanceMap:insert("CHILD", child)
local patch = PatchSet.newEmpty()
table.insert(patch.updated, {
id = "ROOT",
changedName = "Updated Root Name",
changedClassName = "StringValue",
changedProperties = {
Value = {
Type = "String",
Value = "I am Root",
},
},
})
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
local newRoot = instanceMap.fromIds["ROOT"]
assert(newRoot ~= root, "expected instance to be recreated")
expect(newRoot.ClassName).to.equal("StringValue")
expect(newRoot.Name).to.equal("Updated Root Name")
expect(newRoot.Value).to.equal("I am Root")
local newChild = newRoot:FindFirstChild("Child")
assert(newChild ~= nil, "expected child to be present")
assert(newChild == child, "expected child to be preserved")
end)
end

View File

@@ -0,0 +1,35 @@
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
local Error = require(script.Parent.Error)
local function decodeValue(virtualValue, instanceMap)
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
if virtualValue.Type == "Ref" then
local instance = instanceMap.fromIds[virtualValue.Value]
if instance ~= nil then
return true, instance
else
return false, Error.new(Error.RefDidNotExist, {
virtualValue = virtualValue,
})
end
end
local ok, decodedValue = RbxDom.EncodedValue.decode(virtualValue)
if not ok then
return false, Error.new(Error.CannotDecodeValue, {
virtualValue = virtualValue,
innerError = decodedValue,
})
end
return true, decodedValue
end
return decodeValue

View File

@@ -0,0 +1,148 @@
--[[
Defines the process for diffing a virtual DOM and the real DOM to compute a
patch that can be later applied.
]]
local Log = require(script.Parent.Parent.Parent.Log)
local invariant = require(script.Parent.Parent.invariant)
local getProperty = require(script.Parent.getProperty)
local Error = require(script.Parent.Error)
local decodeValue = require(script.Parent.decodeValue)
local function isEmpty(table)
return next(table) == nil
end
local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances
else
return true
end
end
local function diff(instanceMap, virtualInstances, rootId)
local patch = {
removed = {},
added = {},
updated = {},
}
-- Add a virtual instance and all of its descendants to the patch, marked as
-- being added.
local function markIdAdded(id)
local virtualInstance = virtualInstances[id]
patch.added[id] = virtualInstance
for _, childId in ipairs(virtualInstance.Children) do
markIdAdded(childId)
end
end
-- Internal recursive kernel for diffing an instance with the given ID.
local function diffInternal(id)
local virtualInstance = virtualInstances[id]
local instance = instanceMap.fromIds[id]
if virtualInstance == nil then
invariant("Cannot diff an instance not present in virtualInstances\nID: {}", id)
end
if instance == nil then
invariant("Cannot diff an instance not present in InstanceMap\nID: {}", id)
end
if virtualInstance.ClassName ~= instance.ClassName then
error("unimplemented: support changing ClassName")
end
local changedName = nil
if virtualInstance.Name ~= instance.Name then
changedName = virtualInstance.Name
end
local changedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
local ok, existingValueOrErr = getProperty(instance, propertyName)
if ok then
local existingValue = existingValueOrErr
local ok, decodedValue = decodeValue(virtualValue, instanceMap)
if ok then
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
else
Log.warn("Failed to decode property of type {}", virtualValue.Type)
end
else
local err = existingValueOrErr
if err.kind == Error.UnknownProperty then
Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName)
elseif err.kind == Error.UnreadableProperty then
Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
else
return false, err
end
end
end
if changedName ~= nil or not isEmpty(changedProperties) then
table.insert(patch.updated, {
id = id,
changedName = changedName,
changedClassName = nil,
changedProperties = changedProperties,
changedMetadata = nil,
})
end
-- Traverse the list of children in the DOM. Any instance that has no
-- corresponding virtual instance should be removed. Any instance that
-- does have a corresponding virtual instance is recursively diffed.
for _, childInstance in ipairs(instance:GetChildren()) do
local childId = instanceMap.fromInstances[childInstance]
if childId == nil then
-- This is an existing instance not present in the virtual DOM.
-- We can mark it for deletion unless the user has asked us not
-- to delete unknown stuff.
if shouldDeleteUnknownInstances(virtualInstance) then
table.insert(patch.removed, childInstance)
end
else
local ok, err = diffInternal(childId)
if not ok then
return false, err
end
end
end
-- Traverse the list of children in the virtual DOM. Any virtual
-- instance that has no corresponding real instance should be created.
for _, childId in ipairs(virtualInstance.Children) do
local childInstance = instanceMap.fromIds[childId]
if childInstance == nil then
-- This instance is present in the virtual DOM, but doesn't
-- exist in the real DOM.
markIdAdded(childId)
end
end
return true
end
local ok, err = diffInternal(rootId)
if not ok then
return false, err
end
return true, patch
end
return diff

View File

@@ -0,0 +1,292 @@
return function()
local Log = require(script.Parent.Parent.Parent.Log)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet)
local diff = require(script.Parent.diff)
local function isEmpty(table)
return next(table) == nil, "Table was not empty"
end
local function size(dict)
local len = 0
for _ in pairs(dict) do
len = len + 1
end
return len
end
it("should generate an empty patch for empty instances", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Some Name",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
rootInstance.Name = "Some Name"
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
end)
it("should generate a patch with a changed name", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Some Name",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
expect(#patch.updated).to.equal(1)
local update = patch.updated[1]
expect(update.id).to.equal("ROOT")
expect(update.changedName).to.equal("Some Name")
assert(isEmpty(update.changedProperties))
end)
it("should generate a patch with a changed property", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Value",
Properties = {
Value = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local rootInstance = Instance.new("StringValue")
rootInstance.Value = "Initial Value"
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
expect(#patch.updated).to.equal(1)
local update = patch.updated[1]
expect(update.id).to.equal("ROOT")
expect(update.changedName).to.equal(nil)
expect(size(update.changedProperties)).to.equal(1)
local patchProperty = update.changedProperties["Value"]
expect(patchProperty).to.be.a("table")
expect(patchProperty.Type).to.equal("String")
expect(patchProperty.Value).to.equal("Hello, world!")
end)
it("should generate an empty patch if no properties changed", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Value",
Properties = {
Value = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local rootInstance = Instance.new("StringValue")
rootInstance.Value = "Hello, world!"
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(PatchSet.isEmpty(patch), "expected empty patch")
end)
it("should ignore unknown properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {
FAKE_PROPERTY = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
end)
--[[
Because rbx_dom_lua resolves non-canonical properties to their canonical
variants, this test does not work as intended.
Instead, heat_xml is diffed with Heat, the canonical property variant,
and a patch trying to assign to heat_xml is generated. This is
incorrect, but will require more invasive changes to fix later.
]]
itFIXME("should ignore unreadable properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Fire",
Name = "Fire",
Properties = {
-- heat_xml is a serialization-only property that is not
-- exposed to Lua.
heat_xml = {
Type = "Float32",
Value = 5,
},
},
Children = {},
},
}
local rootInstance = Instance.new("Fire")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
Log.warn("{:#?}", patch)
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
end)
it("should generate a patch removing unknown children by default", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
local unknownChild = Instance.new("Folder")
unknownChild.Parent = rootInstance
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
expect(#patch.removed).to.equal(1)
expect(patch.removed[1]).to.equal(unknownChild)
end)
it("should generate an empty patch if unknown children should be ignored", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {},
Children = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
}
local rootInstance = Instance.new("Folder")
local unknownChild = Instance.new("Folder")
unknownChild.Parent = rootInstance
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
assert(isEmpty(patch.removed))
end)
it("should generate a patch with an added child", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.updated))
assert(isEmpty(patch.removed))
expect(size(patch.added)).to.equal(1)
expect(patch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
end)
end

View File

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

View File

@@ -0,0 +1,50 @@
--[[
Defines the process of "hydration" -- matching up a virtual DOM with
concrete instances and assigning them IDs.
]]
local invariant = require(script.Parent.Parent.invariant)
local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
local virtualInstance = virtualInstances[rootId]
if virtualInstance == nil then
invariant("Cannot hydrate an instance not present in virtualInstances\nID: {}", rootId)
end
instanceMap:insert(rootId, rootInstance)
local existingChildren = rootInstance:GetChildren()
-- 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
for _, childId in ipairs(virtualInstance.Children) do
local virtualChild = virtualInstances[childId]
for childIndex, childInstance 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 childInstance.Name, childInstance.ClassName
end)
-- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced.
if ok and name == virtualChild.Name and className == virtualChild.ClassName then
isExistingChildVisited[childIndex] = true
hydrate(instanceMap, virtualInstances, childId, childInstance)
break
end
end
end
end
end
return hydrate

View File

@@ -0,0 +1,129 @@
return function()
local hydrate = require(script.Parent.hydrate)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
it("should match the root instance no matter what", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Model",
Name = "Foo",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(1)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
end)
it("should not match children with mismatched ClassName", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
-- ClassName of this instance is intentionally different
local child = Instance.new("Model")
child.Name = "Child"
child.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(1)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
end)
it("should not match children with mismatched Name", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
-- Name of this instance is intentionally different
local child = Instance.new("Folder")
child.Name = "Not Child"
child.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(1)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
end)
it("should pair instances with matching Name and ClassName", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {"CHILD1", "CHILD2"},
},
CHILD1 = {
ClassName = "Folder",
Name = "Child 1",
Properties = {},
Children = {},
},
CHILD2 = {
ClassName = "Model",
Name = "Child 2",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
local child1 = Instance.new("Folder")
child1.Name = "Child 1"
child1.Parent = rootInstance
local child2 = Instance.new("Model")
child2.Name = "Child 2"
child2.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(3)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
end)
end

View File

@@ -0,0 +1,34 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
local applyPatch = require(script.applyPatch)
local hydrate = require(script.hydrate)
local diff = require(script.diff)
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
}
return setmetatable(self, Reconciler)
end
function Reconciler:applyPatch(patch)
return applyPatch(self.__instanceMap, patch)
end
function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
return hydrate(self.__instanceMap, virtualInstances, rootId, rootInstance)
end
function Reconciler:diff(virtualInstances, rootId)
return diff(self.__instanceMap, virtualInstances, rootId)
end
return Reconciler

View File

@@ -0,0 +1,152 @@
--[[
"Reifies" a virtual DOM, constructing a real DOM with the same shape.
]]
local invariant = require(script.Parent.Parent.invariant)
local PatchSet = require(script.Parent.Parent.PatchSet)
local setProperty = require(script.Parent.setProperty)
local decodeValue = require(script.Parent.decodeValue)
local reifyInner, applyDeferredRefs
local function reify(instanceMap, virtualInstances, rootId, parentInstance)
-- Create an empty patch that will be populated with any parts of this reify
-- that could not happen, like instances that couldn't be created and
-- properties that could not be assigned.
local unappliedPatch = PatchSet.newEmpty()
-- Contains a list of all of the ref properties that we'll need to assign
-- after all instances are created. We apply refs in a second pass, after
-- we create as many instances as we can, so that we ensure that referents
-- can be mapped to instances correctly.
local deferredRefs = {}
reifyInner(instanceMap, virtualInstances, rootId, parentInstance, unappliedPatch, deferredRefs)
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
return unappliedPatch
end
--[[
Add the given ID and all of its descendants in virtualInstances to the given
PatchSet, marked for addition.
]]
local function addAllToPatch(patchSet, virtualInstances, id)
local virtualInstance = virtualInstances[id]
patchSet.added[id] = virtualInstance
for _, childId in ipairs(virtualInstance.Children) do
addAllToPatch(patchSet, virtualInstances, childId)
end
end
--[[
Inner function that defines the core routine.
]]
function reifyInner(instanceMap, virtualInstances, id, parentInstance, unappliedPatch, deferredRefs)
local virtualInstance = virtualInstances[id]
if virtualInstance == nil then
invariant("Cannot reify an instance not present in virtualInstances\nID: {}", id)
end
-- 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, virtualInstance.ClassName)
if not ok then
addAllToPatch(unappliedPatch, virtualInstances, id)
return
end
-- TODO: Can this fail? Previous versions of Rojo guarded against this, but
-- the reason why was uncertain.
instance.Name = virtualInstance.Name
-- Track all of the properties that we've failed to assign to this instance.
local unappliedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
-- Because refs may refer to instances that we haven't constructed yet,
-- we defer applying any ref properties until all instances are created.
if virtualValue.Type == "Ref" then
table.insert(deferredRefs, {
id = id,
instance = instance,
propertyName = propertyName,
virtualValue = virtualValue,
})
continue
end
local ok, value = decodeValue(virtualValue, instanceMap)
if not ok then
unappliedProperties[propertyName] = virtualValue
continue
end
local ok = setProperty(instance, propertyName, value)
if not ok then
unappliedProperties[propertyName] = virtualValue
end
end
-- If there were any properties that we failed to assign, push this into our
-- unapplied patch as an update that would need to be applied.
if next(unappliedProperties) ~= nil then
table.insert(unappliedPatch.updated, {
id = id,
changedProperties = unappliedProperties,
})
end
for _, childId in ipairs(virtualInstance.Children) do
reifyInner(instanceMap, virtualInstances, childId, instance, unappliedPatch, deferredRefs)
end
instance.Parent = parentInstance
instanceMap:insert(id, instance)
end
function applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
local function markFailed(id, propertyName, virtualValue)
-- If there is already an updated entry in the unapplied patch for this
-- ref, use the existing one. This could match other parts of the
-- instance that failed to be created, or even just other refs that
-- failed to apply.
--
-- This is important for instances like selectable GUI objects, which
-- have many similar referent properties.
for _, existingUpdate in ipairs(unappliedPatch.updated) do
if existingUpdate.id == id then
existingUpdate.changedProperties[propertyName] = virtualValue
return
end
end
-- We didn't find an existing entry that matched, so push a new entry
-- into our unapplied patch.
table.insert(unappliedPatch.updated, {
id = id,
changedProperties = {
[propertyName] = virtualValue,
},
})
end
for _, entry in ipairs(deferredRefs) do
local targetInstance = instanceMap.fromIds[entry.virtualValue.Value]
if targetInstance == nil then
markFailed(entry.id, entry.propertyName, entry.virtualValue)
continue
end
local ok = setProperty(entry.instance, entry.propertyName, targetInstance)
if not ok then
markFailed(entry.id, entry.propertyName, entry.virtualValue)
end
end
end
return reify

View File

@@ -0,0 +1,352 @@
return function()
local reify = require(script.Parent.reify)
local PatchSet = require(script.Parent.Parent.PatchSet)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local Error = require(script.Parent.Error)
local function isEmpty(table)
return next(table) == nil, "Table was not empty"
end
local function size(dict)
local len = 0
for _ in pairs(dict) do
len = len + 1
end
return len
end
it("should throw when given a bogus ID", function()
expect(function()
reify(InstanceMap.new(), {}, "Hi, mom!", game)
end).to.throw()
end)
it("should return an error when given bogus class names", function()
local virtualInstances = {
ROOT = {
ClassName = "Balogna",
Name = "Food",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT", nil)
assert(instanceMap:size() == 0, "expected instanceMap to be empty")
expect(size(unappliedPatch.added)).to.equal(1)
expect(unappliedPatch.added["ROOT"]).to.equal(virtualInstances["ROOT"])
assert(isEmpty(unappliedPatch.removed), "expected no removes")
assert(isEmpty(unappliedPatch.updated), "expected no updates")
end)
it("should assign name and properties", function()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Spaghetti",
Properties = {
Value = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("StringValue")
expect(instance.Name).to.equal("Spaghetti")
expect(instance.Value).to.equal("Hello, world!")
expect(instanceMap:size()).to.equal(1)
end)
it("should construct children", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Parent",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("Folder")
expect(instance.Name).to.equal("Parent")
local child = instance.Child
expect(child.ClassName).to.equal("Folder")
expect(instanceMap:size()).to.equal(2)
end)
it("should still construct parents if children fail", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Parent",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "this ain't an Instance",
Name = "Child",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
expect(size(unappliedPatch.added)).to.equal(1)
expect(unappliedPatch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
assert(isEmpty(unappliedPatch.updated), "expected no updates")
assert(isEmpty(unappliedPatch.removed), "expected no removes")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("Folder")
expect(instance.Name).to.equal("Parent")
expect(#instance:GetChildren()).to.equal(0)
expect(instanceMap:size()).to.equal(1)
end)
it("should fail gracefully when setting erroneous properties", function()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Root",
Properties = {
Value = {
Type = "Vector3",
Value = {1, 2, 3},
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("StringValue")
expect(instance.Name).to.equal("Root")
assert(isEmpty(unappliedPatch.added), "expected no additions")
expect(#unappliedPatch.updated).to.equal(1)
assert(isEmpty(unappliedPatch.removed), "expected no removes")
local update = unappliedPatch.updated[1]
expect(update.id).to.equal("ROOT")
expect(size(update.changedProperties)).to.equal(1)
local property = update.changedProperties["Value"]
expect(property).to.equal(virtualInstances["ROOT"].Properties.Value)
end)
-- This is the simplest ref case: ensure that setting a ref property that
-- points to an instance that was previously created as part of the same
-- reify operation works.
it("should apply properties containing refs to ancestors", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "ObjectValue",
Name = "Child",
Properties = {
Value = {
Type = "Ref",
Value = "ROOT",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local root = instanceMap.fromIds["ROOT"]
local child = instanceMap.fromIds["CHILD"]
expect(child.Value).to.equal(root)
end)
-- This is another simple case: apply a ref property that points to an
-- existing instance. In this test, that instance was created before the
-- reify operation started and is present in instanceMap.
it("should apply properties containing refs to previously-existing instances", function()
local virtualInstances = {
ROOT = {
ClassName = "ObjectValue",
Name = "Root",
Properties = {
Value = {
Type = "Ref",
Value = "EXISTING",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local existing = Instance.new("Folder")
existing.Name = "Existing"
instanceMap:insert("EXISTING", existing)
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local root = instanceMap.fromIds["ROOT"]
expect(root.Value).to.equal(existing)
end)
-- This is a tricky ref case: CHILD_A points to CHILD_B, but is constructed
-- first. Deferred ref application is required to implement this case
-- correctly.
it("should apply properties containing refs to later siblings correctly", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {"CHILD_A", "CHILD_B"},
},
CHILD_A = {
ClassName = "ObjectValue",
Name = "Child A",
Properties = {
Value = {
Type = "Ref",
Value = "Child B",
},
},
Children = {},
},
CHILD_B = {
ClassName = "Folder",
Name = "Child B",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local childA = instanceMap.fromIds["CHILD_A"]
local childB = instanceMap.fromIds["CHILD_B"]
expect(childA.Value).to.equal(childB)
end)
-- This is the classic case that calls for deferred ref application. In this
-- test, the root instance has a ref property that refers to its child. The
-- root is definitely constructed first.
--
-- This is distinct from the sibling case in that the child will be
-- constructed as part of a recursive call before the parent has totally
-- finished. Given deferred refs, this should not fail, but it is a good
-- case to test.
it("should apply properties containing refs to later siblings correctly", function()
local virtualInstances = {
ROOT = {
ClassName = "ObjectValue",
Name = "Root",
Properties = {
Value = {
Type = "Ref",
Value = "CHILD",
},
},
Children = {"CHILD"},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local root = instanceMap.fromIds["ROOT"]
local child = instanceMap.fromIds["CHILD"]
expect(root.Value).to.equal(child)
end)
it("should return a partial patch when applying invalid refs", function()
local virtualInstances = {
ROOT = {
ClassName = "ObjectValue",
Name = "Root",
Properties = {
Value = {
Type = "Ref",
Value = "SORRY",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(not PatchSet.hasRemoves(unappliedPatch), "expected no removes")
assert(not PatchSet.hasAdditions(unappliedPatch), "expected no additions")
expect(#unappliedPatch.updated).to.equal(1)
local update = unappliedPatch.updated[1]
expect(update.id).to.equal("ROOT")
expect(update.changedProperties.Value).to.equal(virtualInstances["ROOT"].Properties.Value)
end)
end

View File

@@ -0,0 +1,48 @@
--[[
Attempts to set a property on the given instance.
]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
local Log = require(script.Parent.Parent.Parent.Log)
local Error = require(script.Parent.Error)
local function setProperty(instance, propertyName, value)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
--
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
Log.trace("Skipping unknown property {}.{}", instance.ClassName, propertyName)
return true
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
return false, Error.new(Error.UnwritableProperty, {
className = instance.ClassName,
propertyName = propertyName,
})
end
local ok, err = descriptor:write(instance, value)
if not ok then
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, Error.new(Error.LackingPropertyPermissions, {
className = instance.ClassName,
propertyName = propertyName,
})
end
return false, Error.new(Error.OtherPropertyError, {
className = instance.ClassName,
propertyName = propertyName,
})
end
return true
end
return setProperty

View File

@@ -1,9 +1,11 @@
local StudioService = game:GetService("StudioService")
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 PatchSet = require(script.Parent.PatchSet)
local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict)
@@ -43,6 +45,8 @@ ServeSession.Status = Status
local validateServeOptions = t.strictInterface({
apiContext = t.table,
openScriptsExternally = t.boolean,
twoWaySync = t.boolean,
})
function ServeSession.new(options)
@@ -57,12 +61,28 @@ function ServeSession.new(options)
local instanceMap = InstanceMap.new(onInstanceChanged)
local reconciler = Reconciler.new(instanceMap)
local connections = {}
local connection = StudioService
:GetPropertyChangedSignal("ActiveScript")
:Connect(function()
local activeScript = StudioService.ActiveScript
if activeScript ~= nil then
self:__onActiveScriptChanged(activeScript)
end
end)
table.insert(connections, connection)
self = {
__status = Status.NotStarted,
__apiContext = options.apiContext,
__openScriptsExternally = options.openScriptsExternally,
__twoWaySync = options.twoWaySync,
__reconciler = reconciler,
__instanceMap = instanceMap,
__statusChangedCallback = nil,
__connections = connections,
}
setmetatable(self, ServeSession)
@@ -108,8 +128,39 @@ function ServeSession:stop()
self:__stopInternal()
end
function ServeSession:__onActiveScriptChanged(activeScript)
if not self.__openScriptsExternally then
Log.trace("Not opening script {} because feature not enabled.", activeScript)
return
end
if self.__status ~= Status.Connected then
Log.trace("Not opening script {} because session is not connected.", activeScript)
return
end
local scriptId = self.__instanceMap.fromInstances[activeScript]
if scriptId == nil then
Log.trace("Not opening script {} because it is not known by Rojo.", activeScript)
return
end
Log.debug("Trying to open script {} externally...", activeScript)
-- Force-close the script inside Studio
local existingParent = activeScript.Parent
activeScript.Parent = nil
activeScript.Parent = existingParent
-- Notify the Rojo server to open this script
self.__apiContext:open(scriptId)
end
function ServeSession:__onInstanceChanged(instance, propertyName)
if not DevSettings:twoWaySyncEnabled() then
if not self.__twoWaySync then
return
end
@@ -164,22 +215,36 @@ function ServeSession:__initialSync(rootInstanceId)
-- 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...")
-- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs")
self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game)
-- 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(
Log.trace("Computing changes that plugin needs to make to catch up to server...")
local success, catchUpPatch = self.__reconciler:diff(
readResponseBody.instances,
rootInstanceId,
game
)
Log.trace("Computed hydration patch: {:#?}", debugPatch(hydratePatch))
if not success then
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
end
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
-- TODO: Prompt user to notify them of this patch, since it's
-- effectively a conflict between the Rojo server and the client.
-- effectively a conflict between the Rojo server and the client. In
-- the future, we'll ask which changes the user wants to keep.
self.__reconciler:applyPatch(hydratePatch)
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end
end)
end
@@ -187,7 +252,12 @@ function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages()
:andThen(function(messages)
for _, message in ipairs(messages) do
self.__reconciler:applyPatch(message)
local unappliedPatch = self.__reconciler:applyPatch(message)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end
end
if self.__status ~= Status.Disconnected then
@@ -200,6 +270,11 @@ function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect()
self.__instanceMap:stop()
for _, connection in ipairs(self.__connections) do
connection:Disconnect()
end
self.__connections = {}
end
function ServeSession:__setStatus(status, detail)

View File

@@ -0,0 +1,53 @@
--[[
Create a new signal that can be connected to, disconnected from, and fired.
Usage:
local signal = createSignal()
local disconnect = signal:connect(function(...)
print("fired:", ...)
end)
signal:fire("a", "b", "c")
disconnect()
Avoids mutating listeners list directly to prevent iterator invalidation if
a listener is disconnected while the signal is firing.
]]
local function createSignal()
local listeners = {}
local function connect(newListener)
local nextListeners = {}
for listener in pairs(listeners) do
nextListeners[listener] = true
end
nextListeners[newListener] = true
listeners = nextListeners
return function()
local nextListeners = {}
for listener in pairs(listeners) do
if listener ~= newListener then
nextListeners[listener] = true
end
end
listeners = nextListeners
end
end
local function fire(...)
for listener in pairs(listeners) do
listener(...)
end
end
return {
connect = connect,
fire = fire,
}
end
return createSignal

View File

@@ -14,9 +14,17 @@ local Roact = require(script.Parent.Roact)
local Config = require(script.Config)
local App = require(script.Components.App)
local Theme = require(script.Components.Theme)
local PluginSettings = require(script.Components.PluginSettings)
local app = Roact.createElement(App, {
plugin = plugin,
local app = Roact.createElement(Theme.StudioProvider, nil, {
Roact.createElement(PluginSettings.StudioProvider, {
plugin = plugin,
}, {
RojoUI = Roact.createElement(App, {
plugin = plugin,
}),
})
})
local tree = Roact.mount(app, nil, "Rojo UI")

View File

@@ -1,37 +0,0 @@
local RbxDom = require(script.Parent.Parent.RbxDom)
--[[
Attempts to set a property on the given instance.
]]
local function setCanonicalProperty(instance, propertyName, value)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
--
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
return false, "unknown property"
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
return false, "unwritable property"
end
local success, err = descriptor:write(instance, value)
if not success then
-- If we don't have permission to write a property, we just silently
-- ignore it.
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, "permission error"
end
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
error(message, 2)
end
return true
end
return setCanonicalProperty

4
plugin/test Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
rojo build test-place.project.json -o TestPlace.rbxlx
run-in-roblox --script run-tests.server.lua --place TestPlace.rbxlx

View File

@@ -0,0 +1,28 @@
{
"name": "Rojo Test Place",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Rojo": {
"$path": "default.project.json"
},
"TestEZ": {
"$path": "modules/testez/lib"
}
},
"ServerScriptService": {
"RunTests": {
"$path": "run-tests.server.lua"
}
},
"Players": {
"$properties": {
"CharacterAutoLoads": false
}
}
}
}

View File

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

View File

@@ -1,29 +0,0 @@
[package]
name = "rojo-test"
version = "0.1.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
publish = false
[features]
default = []
unstable_glob_ignore_paths = []
[dependencies]
env_logger = "0.7.1"
insta = { version = "0.13.1", features = ["redactions"] }
log = "0.4.8"
paste = "0.1.5"
rbx_dom_weak = "1.9.0"
reqwest = "0.9.20"
serde = "1.0.99"
serde_json = "1.0.40"
serde_yaml = "0.8.9"
tempfile = "3.1.0"
walkdir = "2.2.9"
rojo-insta-ext = { path = "../rojo-insta-ext" }
# We execute Rojo via std::process::Command, so depend on it so it's built!
rojo = { path = ".." }

View File

@@ -1,8 +0,0 @@
# rojo-test
This project does end-to-end testing of Rojo by executing it and checking what side-effects it has.
rojo-test is meant to be run as a test with:
```bash
cargo test
```

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -0,0 +1,28 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">infer-service-name</string>
</Properties>
<Item class="HttpService" referent="1">
<Properties>
<string name="Name">HttpService</string>
<bool name="HttpEnabled">true</bool>
</Properties>
</Item>
<Item class="ReplicatedStorage" referent="2">
<Properties>
<string name="Name">ReplicatedStorage</string>
</Properties>
<Item class="ModuleScript" referent="3">
<Properties>
<string name="Name">Main</string>
<string name="Source">-- hello, from main</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,26 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">infer-service-name</string>
</Properties>
<Item class="StarterPlayer" referent="1">
<Properties>
<string name="Name">StarterPlayer</string>
</Properties>
<Item class="StarterCharacterScripts" referent="2">
<Properties>
<string name="Name">StarterCharacterScripts</string>
</Properties>
</Item>
<Item class="StarterPlayerScripts" referent="3">
<Properties>
<string name="Name">StarterPlayerScripts</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -0,0 +1,23 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="ModuleScript" referent="0">
<Properties>
<string name="Name">json_as_lua</string>
<string name="Source">return {
["1invalidident"] = "nice",
array = {1, 2, 3},
["false"] = false,
float = 1234.5452,
int = 1234,
null = nil,
object = {
hello = "world",
},
["true"] = true,
}</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

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