mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-20 17:41:37 +00:00
Merge 188f49cd7b into 7e60406373
This commit is contained in:
commit
f8f4ef4e9e
7 changed files with 230 additions and 17 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
116
src/cli/open.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue