diff --git a/CHANGELOG.md b/CHANGELOG.md index 3943ae5f..83f58410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,11 @@ Making a new release? Simply add the new header with the version and date undern ## Unreleased +* Added the `rojo open` command, which serves a project and opens a place in Roblox Studio that automatically connects to it. The place can be an existing place file (`--place`), a fresh build of the project (`--output`), or a published place (`--place-id` and `--universe-id`). ([#1326]) * LocalizationTables no longer report as changed when nothing changes. [(#1333)] [#1333]: https://github.com/rojo-rbx/rojo/pull/1333 +[#1326]: https://github.com/rojo-rbx/rojo/pull/1326 ## [7.7.1] (October 1st, 2026) diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index e6e4deee..848939e9 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -2,6 +2,7 @@ local ChangeHistoryService = game:GetService("ChangeHistoryService") local Players = game:GetService("Players") local ServerStorage = game:GetService("ServerStorage") local RunService = game:GetService("RunService") +local StudioService = game:GetService("StudioService") local Rojo = script:FindFirstAncestor("Rojo") local Plugin = Rojo.Plugin @@ -139,7 +140,7 @@ function App:init() if RunService:IsEdit() then self:checkForUpdates() - + self:checkForRojoOpen() self:startSyncReminderPolling() self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled) if enabled then @@ -173,12 +174,65 @@ function App:init() end) end +function App:checkForRojoOpen() + local rojoOpenName = `ROJO_OPEN_{StudioService:GetUserId()}` + + local function tryRojoOpen(instance: Instance) + if instance.Name ~= rojoOpenName then + return + end + + local host = instance:GetAttribute("Host") + if host == "127.0.0.1" then + -- The server default is 127.0.0.1 but the plugin's is localhost. + -- Change it to localhost so the address box shows the default value. + host = "localhost" + end + + local port = instance:GetAttribute("Port") + local sessionId = instance:GetAttribute("SessionId") + + instance:Destroy() + + if instance.Archivable then + -- Protect against malicious ROJO_OPEN saved to the place. + return + elseif typeof(sessionId) ~= "string" then + return + end + + if self.rojoOpenConnection then + self.rojoOpenConnection:Disconnect() + self.rojoOpenConnection = nil + end + + if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then + self.setHost(if host ~= Config.defaultHost then host else "") + self.setPort(if port ~= Config.defaultPort then port else "") + + self:startSession(sessionId) + end + end + + self.rojoOpenConnection = game.ChildAdded:Connect(tryRojoOpen) + + local existing = game:FindFirstChild(rojoOpenName) + if existing then + tryRojoOpen(existing) + end +end + function App:willUnmount() self:endSession() self.waypointConnection:Disconnect() self.confirmationBindable:Destroy() + if self.rojoOpenConnection then + self.rojoOpenConnection:Disconnect() + self.rojoOpenConnection = nil + end + self.disconnectUpdatesCheckChanged() self.disconnectPrereleasesCheckChanged() if self.disconnectSyncReminderPollingChanged then @@ -600,7 +654,7 @@ function App:useRunningConnectionInfo() self.setPort(port) end -function App:startSession() +function App:startSession(expectedSessionId: string?) local claimedLock, priorOwner = self:claimSyncLock() if not claimedLock then local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner)) @@ -629,6 +683,7 @@ function App:startSession() local serveSession = ServeSession.new({ apiContext = apiContext, twoWaySync = Settings:get("twoWaySync"), + expectedSessionId = expectedSessionId, }) serveSession:setUpdateLoadingTextCallback(function(text: string) diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index 7e9fc742..c7437a55 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -63,6 +63,7 @@ ServeSession.Status = Status local validateServeOptions = t.strictInterface({ apiContext = t.table, twoWaySync = t.boolean, + expectedSessionId = t.optional(t.string), }) function ServeSession.new(options) @@ -101,6 +102,7 @@ function ServeSession.new(options) __status = Status.NotStarted, __apiContext = options.apiContext, __twoWaySync = options.twoWaySync, + __expectedSessionId = options.expectedSessionId, __reconciler = reconciler, __instanceMap = instanceMap, __changeBatcher = changeBatcher, @@ -196,6 +198,10 @@ function ServeSession:start() self.__apiContext :connect() :andThen(function(serverInfo) + if self.__expectedSessionId ~= nil and serverInfo.sessionId ~= self.__expectedSessionId then + return Promise.reject("Connected to a different Rojo server than the one that opened this place") + end + self:setLoadingText("Loading initial data from server...") return self:__initialSync(serverInfo):andThen(function() self:setLoadingText("Starting sync loop...") diff --git a/src/cli/build.rs b/src/cli/build.rs index 82e81387..e8ebacd6 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -17,6 +17,8 @@ use super::resolve_path; const UNKNOWN_OUTPUT_KIND_ERR: &str = "Could not detect what kind of file to build. \ Expected output file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx."; +pub(super) const UNKNOWN_PLACE_KIND_ERR: &str = "Could not detect what kind of file to build. \ + Expected place file to end in .rbxl or .rbxlx."; const UNKNOWN_PLUGIN_KIND_ERR: &str = "Could not detect what kind of file to build. \ Expected plugin file to end in .rbxm or .rbxmx."; @@ -113,7 +115,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, @@ -140,6 +142,16 @@ 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_plugin_path(output: &Path) -> Option { let extension = output.extension()?.to_str()?; @@ -156,7 +168,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 caef95aa..a6c8bc21 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; @@ -20,6 +21,7 @@ pub use self::build::BuildCommand; pub use self::doc::DocCommand; pub use self::fmt_project::FmtProjectCommand; pub use self::init::{InitCommand, InitKind}; +pub use self::open::OpenCommand; pub use self::plugin::{PluginCommand, PluginSubcommand}; pub use self::serve::ServeCommand; pub use self::sourcemap::SourcemapCommand; @@ -44,6 +46,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(), @@ -118,6 +121,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 00000000..f02cb31c --- /dev/null +++ b/src/cli/open.rs @@ -0,0 +1,116 @@ +use std::{path::PathBuf, process::Command, sync::Arc}; + +use anyhow::Context; +use clap::{ArgGroup, Parser}; +use memofs::Vfs; +use roblox_install::RobloxStudio; + +use crate::{ + cli::{ + build::{write_model, OutputKind, UNKNOWN_PLACE_KIND_ERR}, + resolve_path, + serve::{show_start_message, ServerOptions}, + GlobalOptions, + }, + serve_session::ServeSession, + web::LiveServer, +}; + +/// Serve a project and open a place in Roblox Studio that connects to it. +#[derive(Debug, Parser)] +#[clap(group( + ArgGroup::new("target").required(true).args(&["place", "output", "place-id"]) +))] +pub struct OpenCommand { + /// Path to the project to serve. Defaults to the current directory. + #[clap(default_value = "")] + pub project: PathBuf, + + #[clap(flatten)] + pub server: ServerOptions, + + /// An existing place file to open. Must end in `.rbxl` or `.rbxlx`. + #[clap(long)] + pub place: Option, + + /// Build the project to this place file, then open it. Must end in `.rbxl` + /// or `.rbxlx`. + #[clap(long)] + pub output: Option, + + /// The ID of a published place to open. Requires `--universe-id`. + #[clap(long, requires = "universe-id")] + pub place_id: Option, + + /// The universe ID of the place given with `--place-id`. + #[clap(long, requires = "place-id")] + pub universe_id: Option, +} + +impl OpenCommand { + pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { + let project_path = resolve_path(&self.project)?; + let studio = RobloxStudio::locate()?; + let vfs = Vfs::new_default()?; + let session = Arc::new(ServeSession::new(vfs, project_path)?); + let (ip, port, allowed_hosts) = self.server.resolve_options(&session); + let script_path = + std::env::temp_dir().join(format!("rojo-open-{}.luau", std::process::id())); + + let mut command = Command::new(studio.application_path()); + command + .arg("--task") + .arg("RunScript") + .arg("--runScriptFile") + .arg(&script_path); + + if let Some(output) = self.output { + let output_kind = + OutputKind::from_place_path(&output).context(UNKNOWN_PLACE_KIND_ERR)?; + + write_model(&session, &output, output_kind)?; + + command.arg("--localPlaceFile").arg(output); + } else if let Some(place) = self.place { + OutputKind::from_place_path(&place).context(UNKNOWN_PLACE_KIND_ERR)?; + + let place = dunce::canonicalize(&place) + .with_context(|| format!("Could not find place file {}", place.display()))?; + + command.arg("--localPlaceFile").arg(place); + } else if let (Some(place_id), Some(universe_id)) = (self.place_id, self.universe_id) { + command + .arg("--universeId") + .arg(universe_id.to_string()) + .arg("--placeId") + .arg(place_id.to_string()); + } + + let session_id = session.session_id(); + fs_err::write( + &script_path, + format!( + r#"local value = Instance.new("Configuration") +value.Name = `ROJO_OPEN_{{game:GetService("StudioService"):GetUserId()}}` +value.Archivable = false +value:SetAttribute("Host", "{ip}") +value:SetAttribute("Port", "{port}") +value:SetAttribute("SessionId", "{session_id}") +value.Parent = game"#, + ), + )?; + + let server = LiveServer::new(session); + + server.start((ip, port).into(), allowed_hosts, || { + let _ = show_start_message(ip, port, global.color.into()); + + // Only launch Studio if the server successfully started. + if let Err(err) = command.spawn() { + log::error!("Could not launch Roblox Studio: {err}"); + } + })?; + + Ok(()) + } +} diff --git a/src/cli/serve.rs b/src/cli/serve.rs index b22beb69..b7a03c78 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -16,13 +16,8 @@ use super::{resolve_path, GlobalOptions}; const DEFAULT_BIND_ADDRESS: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1); const DEFAULT_PORT: u16 = 34872; -/// Expose a Rojo project to the Rojo Studio plugin. #[derive(Debug, Parser)] -pub struct ServeCommand { - /// Path to the project to serve. Defaults to the current directory. - #[clap(default_value = "")] - pub project: PathBuf, - +pub struct ServerOptions { /// The IP address to listen on. Defaults to `127.0.0.1`. #[clap(long)] pub address: Option, @@ -41,14 +36,19 @@ pub struct ServeCommand { pub allowed_hosts: Vec, } -impl ServeCommand { - pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { - let project_path = resolve_path(&self.project)?; +/// Expose a Rojo project to the Rojo Studio plugin. +#[derive(Debug, Parser)] +pub struct ServeCommand { + /// Path to the project to serve. Defaults to the current directory. + #[clap(default_value = "")] + pub project: PathBuf, - let vfs = Vfs::new_default()?; - - let session = Arc::new(ServeSession::new(vfs, project_path)?); + #[clap(flatten)] + pub server: ServerOptions, +} +impl ServerOptions { + pub(super) fn resolve_options(self, session: &ServeSession) -> (IpAddr, u16, Vec) { let ip = self .address .or_else(|| session.serve_address()) @@ -67,6 +67,20 @@ impl ServeCommand { self.allowed_hosts }; + (ip, port, allowed_hosts) + } +} + +impl ServeCommand { + pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { + let project_path = resolve_path(&self.project)?; + + let vfs = Vfs::new_default()?; + + let session = Arc::new(ServeSession::new(vfs, project_path)?); + + let (ip, port, allowed_hosts) = self.server.resolve_options(&session); + let server = LiveServer::new(session); server.start((ip, port).into(), allowed_hosts, || { @@ -77,7 +91,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);