diff --git a/aftman.toml b/aftman.toml index 5273b6d07..2d584a53e 100644 --- a/aftman.toml +++ b/aftman.toml @@ -1,5 +1,5 @@ [tools] -rojo = "rojo-rbx/rojo@7.3.0" +rojo = "rojo-rbx/rojo@7.4.1" selene = "Kampfkarren/selene@0.26.1" -stylua = "JohnnyMorganz/stylua@0.18.2" +stylua = "JohnnyMorganz/stylua@0.20.0" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index ecb807f05..6cfa6e50a 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -131,6 +131,23 @@ function App:init() toolbarIcon = Assets.Images.PluginButton, }) + local rojoOpenValue = game:FindFirstChild("ROJO_OPEN") + if rojoOpenValue and rojoOpenValue:IsA("StringValue") then + local host, port = unpack(rojoOpenValue.Value:split(",")) + + if host ~= Config.defaultHost then + self.setHost(host) + end + + if port ~= Config.defaultPort then + self.setPort(port) + end + + rojoOpenValue:Destroy() + + self:startSession(true) + end + if RunService:IsEdit() and self.serveSession == nil @@ -407,7 +424,7 @@ function App:useRunningConnectionInfo() self.setPort(port) end -function App:startSession() +function App:startSession(skipInitialSync) local claimedLock, priorOwner = self:claimSyncLock() if not claimedLock then local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner)) @@ -623,7 +640,7 @@ function App:startSession() return self.confirmationEvent:Wait() end) - serveSession:start() + serveSession:start(not not skipInitialSync) self.serveSession = serveSession end diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index 82d8fd42f..eb5af5e2f 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -134,7 +134,7 @@ function ServeSession:hookPostcommit(callback) return self.__reconciler:hookPostcommit(callback) end -function ServeSession:start() +function ServeSession:start(skipInitialSync) self:__setStatus(Status.Connecting) self.__apiContext @@ -142,7 +142,7 @@ function ServeSession:start() :andThen(function(serverInfo) self:__applyGameAndPlaceId(serverInfo) - return self:__initialSync(serverInfo):andThen(function() + return self:__initialSync(serverInfo, skipInitialSync):andThen(function() self:__setStatus(Status.Connected, serverInfo.projectName) return self:__mainSyncLoop() @@ -208,7 +208,7 @@ function ServeSession:__onActiveScriptChanged(activeScript) self.__apiContext:open(scriptId) end -function ServeSession:__initialSync(serverInfo) +function ServeSession:__initialSync(serverInfo, skipInitialSync) return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody) -- Tell the API Context that we're up-to-date with the version of -- the tree defined in this response. @@ -219,6 +219,10 @@ function ServeSession:__initialSync(serverInfo) Log.trace("Matching existing Roblox instances to Rojo IDs") self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game) + if skipInitialSync then + return Promise.resolve() + end + -- Calculate the initial patch to apply to the DataModel to catch us -- up to what Rojo thinks the place should look like. Log.trace("Computing changes that plugin needs to make to catch up to server...") diff --git a/src/cli/build.rs b/src/cli/build.rs index 2dd3b8543..4975f9df8 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -108,7 +108,7 @@ impl BuildCommand { /// The different kinds of output that Rojo can build to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OutputKind { +pub(super) enum OutputKind { /// An XML model file. Rbxmx, @@ -123,6 +123,16 @@ enum OutputKind { } impl OutputKind { + pub(super) fn from_place_path(output: &Path) -> Option { + let extension = output.extension()?.to_str()?; + + match extension { + "rbxlx" => Some(OutputKind::Rbxlx), + "rbxl" => Some(OutputKind::Rbxl), + _ => None, + } + } + fn from_output_path(output: &Path) -> Option { let extension = output.extension()?.to_str()?; @@ -151,7 +161,7 @@ fn xml_encode_config() -> rbx_xml::EncodeOptions<'static> { } #[profiling::function] -fn write_model( +pub(super) fn write_model( session: &ServeSession, output: &Path, output_kind: OutputKind, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 065128fb6..55b7e4f1c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,6 +4,7 @@ mod build; mod doc; mod fmt_project; mod init; +mod open; mod plugin; mod serve; mod sourcemap; @@ -18,6 +19,7 @@ pub use self::build::BuildCommand; pub use self::doc::DocCommand; pub use self::fmt_project::FmtProjectCommand; pub use self::init::{InitCommand, InitKind}; +use self::open::OpenCommand; pub use self::plugin::{PluginCommand, PluginSubcommand}; pub use self::serve::ServeCommand; pub use self::sourcemap::SourcemapCommand; @@ -41,6 +43,7 @@ impl Options { Subcommand::Init(subcommand) => subcommand.run(), Subcommand::Serve(subcommand) => subcommand.run(self.global), Subcommand::Build(subcommand) => subcommand.run(), + Subcommand::Open(subcommand) => subcommand.run(self.global), Subcommand::Upload(subcommand) => subcommand.run(), Subcommand::Sourcemap(subcommand) => subcommand.run(), Subcommand::FmtProject(subcommand) => subcommand.run(), @@ -114,6 +117,7 @@ pub enum Subcommand { Init(InitCommand), Serve(ServeCommand), Build(BuildCommand), + Open(OpenCommand), Upload(UploadCommand), Sourcemap(SourcemapCommand), FmtProject(FmtProjectCommand), diff --git a/src/cli/open.rs b/src/cli/open.rs new file mode 100644 index 000000000..149de34e8 --- /dev/null +++ b/src/cli/open.rs @@ -0,0 +1,172 @@ +use std::{ + fs::File, + io::{BufReader, BufWriter}, + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::Context; +use clap::{IntoApp, Parser}; +use memofs::Vfs; +use rbx_dom_weak::InstanceBuilder; + +use crate::{serve_session::ServeSession, web::LiveServer}; + +use super::{ + build::{write_model, OutputKind}, + resolve_path, + serve::show_start_message, + GlobalOptions, +}; + +const UNKNOWN_OUTPUT_KIND_ERR: &str = "Could not detect what kind of file to build. \ + Expected output file to end in .rbxl or .rbxlx."; +const DEFAULT_BIND_ADDRESS: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1); +const DEFAULT_PORT: u16 = 34872; + +/// TODO +#[derive(Debug, Parser)] +pub struct OpenCommand { + /// Path to the project to serve. Defaults to the current directory. + #[clap(default_value = "")] + pub project: PathBuf, + + /// Where to output the result. + /// + /// Should end in .rbxm, .rbxl. + #[clap(long, short, conflicts_with = "place", conflicts_with = "cloud")] + pub output: Option, + + /// TODO + #[clap(long, short, conflicts_with = "output", conflicts_with = "cloud")] + pub place: Option, + + /// TODO + #[clap(long, short, conflicts_with = "output", conflicts_with = "place")] + pub cloud: Option, + + /// The IP address to listen on. Defaults to `127.0.0.1`. + #[clap(long)] + pub address: Option, + + /// The port to listen on. Defaults to the project's preference, or a random usable port if + /// it has none. + #[clap(long)] + pub port: Option, +} + +// TODO: Support existing places. +impl OpenCommand { + pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { + if self.output.is_none() && self.place.is_none() && self.cloud.is_none() { + OpenCommand::command() + .error( + clap::ErrorKind::MissingRequiredArgument, + "one of the following arguments must be provided: \n --output \n --place \n --cloud ", + ) + .exit(); + } + + let project = resolve_path(&self.project); + + let vfs = Vfs::new_default(); + let session = ServeSession::new(vfs, project)?; + + let ip = self + .address + .or_else(|| session.serve_address()) + .unwrap_or(DEFAULT_BIND_ADDRESS.into()); + + let port = self + .port + .or_else(|| session.project_port()) + .or_else(|| { + if self.cloud.is_some() { + None + } else { + random_port(ip) + } + }) + .unwrap_or(DEFAULT_PORT); + + match self.cloud { + Some(place_id) => { + opener::open(format!( + "roblox-studio:1+task:EditPlace+universeId:0+placeId:{place_id}" + ))?; + } + None => { + let is_existing_place = self.place.is_some(); + let path = self.output.unwrap_or_else(|| self.place.unwrap()); + let output_kind = + OutputKind::from_place_path(&path).context(UNKNOWN_OUTPUT_KIND_ERR)?; + + if !is_existing_place { + write_model(&session, &path, OutputKind::Rbxl)?; + } + + inject_rojo_open_string_value(&path, output_kind, ip, port)?; + + opener::open(path)?; + } + } + + let server = LiveServer::new(Arc::new(session)); + + let _ = show_start_message(ip, port, global.color.into()); + server.start((ip, port).into()); + + Ok(()) + } +} + +fn random_port(ip: IpAddr) -> Option { + Some( + TcpListener::bind(SocketAddr::new(ip, 0)) + .ok()? + .local_addr() + .ok()? + .port(), + ) +} + +fn inject_rojo_open_string_value( + path: &Path, + output_kind: OutputKind, + ip: IpAddr, + port: u16, +) -> anyhow::Result<()> { + let file = File::open(path).unwrap(); + + let mut dom = match output_kind { + OutputKind::Rbxl => rbx_binary::from_reader(BufReader::new(file)).unwrap(), + OutputKind::Rbxlx => rbx_xml::from_reader_default(BufReader::new(file)).unwrap(), + _ => unreachable!(), + }; + + let ip = if ip.is_loopback() { + "localhost".to_string() + } else { + ip.to_string() + }; + + dom.insert( + dom.root_ref(), + InstanceBuilder::new("StringValue") + .with_name("ROJO_OPEN") + .with_property("Value", format!("{ip},{port}",)), + ); + + let root_instance = dom.root(); + let top_level_ids = root_instance.children(); + let output = BufWriter::new(File::create(path).unwrap()); + + match output_kind { + OutputKind::Rbxl => rbx_binary::to_writer(output, &dom, top_level_ids)?, + OutputKind::Rbxlx => rbx_xml::to_writer_default(output, &dom, top_level_ids)?, + _ => unreachable!(), + } + + Ok(()) +} diff --git a/src/cli/serve.rs b/src/cli/serve.rs index f89e43875..c0095566d 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -60,7 +60,11 @@ impl ServeCommand { } } -fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io::Result<()> { +pub(super) fn show_start_message( + bind_address: IpAddr, + port: u16, + color: ColorChoice, +) -> io::Result<()> { let mut green = ColorSpec::new(); green.set_fg(Some(Color::Green)).set_bold(true);