This commit is contained in:
Micah 2026-10-07 15:41:16 +00:00 • committed by GitHub
commit f8f4ef4e9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 230 additions and 17 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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...")

View file

@ -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<OutputKind> {
let extension = output.extension()?.to_str()?;
match extension {
"rbxlx" => Some(OutputKind::Rbxlx),
"rbxl" => Some(OutputKind::Rbxl),
_ => None,
}
}
fn from_plugin_path(output: &Path) -> Option<OutputKind> {
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,

View file

@ -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),

116
src/cli/open.rs Normal file
View file

@ -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<PathBuf>,
/// Build the project to this place file, then open it. Must end in `.rbxl`
/// or `.rbxlx`.
#[clap(long)]
pub output: Option<PathBuf>,
/// The ID of a published place to open. Requires `--universe-id`.
#[clap(long, requires = "universe-id")]
pub place_id: Option<u64>,
/// The universe ID of the place given with `--place-id`.
#[clap(long, requires = "place-id")]
pub universe_id: Option<u64>,
}
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(())
}
}

View file

@ -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<IpAddr>,
@ -41,14 +36,19 @@ pub struct ServeCommand {
pub allowed_hosts: Vec<String>,
}
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<String>) {
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);