Skip to main content

roblox_studio_utils/
opener.rs

1use std::{
2    ffi::OsString,
3    fs, io,
4    net::Ipv4Addr,
5    path::Path,
6    process::{Command, Stdio},
7};
8
9use crate::paths::RobloxStudioPaths;
10use crate::result::{RobloxStudioError, RobloxStudioResult};
11use crate::task::RobloxStudioTask;
12
13const DEFAULT_SERVER_ADDR: Ipv4Addr = Ipv4Addr::LOCALHOST;
14const DEFAULT_SERVER_PORT: u16 = 50608;
15
16/**
17    A builder to open a Roblox Studio instance through the official binary,
18    while also properly handling its CLI arguments and intricacies.
19*/
20#[derive(Debug, Clone)]
21pub struct RobloxStudioOpener {
22    args: Vec<OsString>,
23    task: Option<RobloxStudioTask>,
24    server_addr: Ipv4Addr,
25    server_port: u16,
26}
27
28impl RobloxStudioOpener {
29    /**
30        Create a new Roblox Studio opener.
31    */
32    #[must_use]
33    pub fn new() -> Self {
34        Self {
35            args: Vec::new(),
36            task: None,
37            server_addr: DEFAULT_SERVER_ADDR,
38            server_port: DEFAULT_SERVER_PORT,
39        }
40    }
41
42    /**
43        Add a key-value argument pair to the Roblox Studio opener.
44
45        This should typically not be used - try to use the more specific
46        methods such as `edit_place` or `edit_file` instead when possible.
47    */
48    #[must_use]
49    #[doc(hidden)]
50    #[allow(clippy::needless_pass_by_value)]
51    pub fn with_arg<K, V>(mut self, key: K, value: V) -> Self
52    where
53        K: Into<OsString>,
54        V: Into<OsString>,
55    {
56        let key: OsString = key.into();
57        let value: OsString = value.into();
58        if key == "-task"
59            && let Some(task_str) = value.to_str()
60        {
61            self.task = RobloxStudioTask::parse(task_str);
62        }
63        self.args.push(key);
64        self.args.push(value);
65        self
66    }
67
68    /**
69        Adds creator, universe, and place id arguments, filled with zeros.
70    */
71    fn with_zeros(self) -> Self {
72        // Necessary for some commands even though they are
73        // unused - maybe these can be removed in the future?
74        self.with_arg("-creatorType", "0")
75            .with_arg("-creatorId", "0")
76            .with_arg("-universeId", "0")
77            .with_arg("-placeId", "0")
78    }
79
80    /**
81        Sets a custom server address to use with the `start_server`,
82        `start_server_with_place`, or `start_client` methods.
83
84        Defaults to localhost (`127.0.0.1`).
85    */
86    #[must_use]
87    #[allow(clippy::needless_pass_by_value)]
88    pub fn with_server_addr<A>(mut self, server_addr: A) -> Self
89    where
90        A: Into<Ipv4Addr>,
91    {
92        self.server_addr = server_addr.into();
93        self
94    }
95
96    /**
97        Sets a custom server port to use with the `start_server`,
98        `start_server_with_place`, or `start_client` methods.
99
100        Defaults to port `50608`.
101    */
102    #[must_use]
103    pub fn with_server_port(mut self, server_port: u16) -> Self {
104        self.server_port = server_port;
105        self
106    }
107
108    /**
109        Edit an online place in Roblox Studio.
110
111        This will open the place with the given `universe_id` and `place_id`.
112    */
113    #[must_use]
114    pub fn open_place(mut self, universe_id: u64, place_id: u64) -> Self {
115        self.task = Some(RobloxStudioTask::EditPlace);
116        self.with_arg("-task", RobloxStudioTask::EditPlace)
117            .with_arg("-universeId", universe_id.to_string())
118            .with_arg("-placeId", place_id.to_string())
119    }
120
121    /**
122        Edit a local place file in Roblox Studio.
123
124        This will open the place file at the given `file_path`.
125
126        # Errors
127
128        - If the given `file_path` cannot be canonicalized.
129        - If the given `file_path` cannot be converted to a string.
130    */
131    pub fn open_file<P>(mut self, file_path: P) -> RobloxStudioResult<Self>
132    where
133        P: AsRef<Path>,
134    {
135        self.task = Some(RobloxStudioTask::EditFile);
136        let file_path_full = file_path
137            .as_ref()
138            .canonicalize()
139            .map_err(|e| RobloxStudioError::PathCanonicalize(e.to_string()))?;
140        let file_path_str = file_path_full
141            .to_str()
142            .ok_or(RobloxStudioError::PathToString(file_path_full.clone()))?;
143        Ok(self
144            .with_arg("-task", RobloxStudioTask::EditFile)
145            .with_arg("-localPlaceFile", file_path_str))
146    }
147
148    /**
149        Start a server in Roblox Studio with the given place file.
150
151        This will copy the place file at the given `file_path`
152        to the Roblox server file, and then start the server.
153
154        # Errors
155
156        - If the local data directory cannot be found.
157        - If the given place file cannot be copied to the local data directory.
158    */
159    pub fn start_server<P>(mut self, file_path: P) -> RobloxStudioResult<Self>
160    where
161        P: AsRef<Path>,
162    {
163        self.task = Some(RobloxStudioTask::StartServer);
164        let file_path_source = file_path
165            .as_ref()
166            .canonicalize()
167            .map_err(|e| RobloxStudioError::PathCanonicalize(e.to_string()))?;
168        let file_path_target = dirs::data_local_dir()
169            .ok_or(RobloxStudioError::LocalDataDirMissing)?
170            .join("Roblox")
171            .join("server.rbxl");
172
173        fs::copy(file_path_source, file_path_target)
174            .map_err(|e| RobloxStudioError::LocalDataDirCopyPlace(e.to_string()))?;
175
176        let server_addr = self.server_addr.to_string();
177        let server_port = self.server_port.to_string();
178        Ok(self
179            .with_arg("-task", RobloxStudioTask::StartServer)
180            .with_arg("-server", server_addr)
181            .with_arg("-port", server_port)
182            .with_zeros())
183    }
184
185    /**
186        Start a server in Roblox Studio with the given place file and clients.
187
188        This will also automatically start the given number of clients.
189
190        See `start_server` for more information.
191    */
192    #[allow(clippy::missing_errors_doc)]
193    pub fn start_server_with_clients<P>(
194        self,
195        file_path: P,
196        num_clients: u8,
197    ) -> RobloxStudioResult<Self>
198    where
199        P: AsRef<Path>,
200    {
201        Ok(self
202            .start_server(file_path)?
203            .with_arg("-numtestserverplayersuponstartup", num_clients.to_string()))
204    }
205
206    /**
207        Starts a single client, connecting to an already launched server.
208
209        See `start_server` for more information.
210    */
211    #[must_use]
212    pub fn start_client(mut self) -> Self {
213        self.task = Some(RobloxStudioTask::StartClient);
214        let server_addr = self.server_addr.to_string();
215        let server_port = self.server_port.to_string();
216        self.with_arg("-task", RobloxStudioTask::StartClient)
217            .with_arg("-server", server_addr)
218            .with_arg("-port", server_port)
219            .with_zeros()
220    }
221
222    /**
223        Starts Roblox Studio with all of the given arguments.
224
225        Note that this will not wait for Roblox Studio to actually
226        open the file/server/client - it only guarantees that the process
227        has been spawned and that it has received the necessary arguments.
228
229        # Errors
230
231        - If the Roblox Studio executable cannot be found.
232    */
233    #[allow(clippy::zombie_processes)]
234    pub fn run(self) -> RobloxStudioResult<()> {
235        let paths = RobloxStudioPaths::new()?;
236        let exe = paths.exe_for_task(self.task);
237
238        spawn_studio_process(exe, &self.args)?;
239
240        Ok(())
241    }
242}
243
244impl Default for RobloxStudioOpener {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250fn create_studio_command(exe: &Path, args: &[OsString]) -> Command {
251    let mut cmd = Command::new(exe);
252    cmd.args(args);
253    cmd.stdin(Stdio::null());
254    cmd.stdout(Stdio::null());
255    cmd.stderr(Stdio::null());
256    cmd
257}
258
259#[cfg(not(target_os = "windows"))]
260#[allow(clippy::zombie_processes)]
261fn spawn_studio_process(exe: &Path, args: &[OsString]) -> RobloxStudioResult<()> {
262    let mut cmd = create_studio_command(exe, args);
263
264    /*
265        NOTE: Not waiting on the process here is intentional, we
266        are only trying to open Roblox Studio, not get its output,
267        and we intentionally don't want toolchain managers such as
268        Rokit/Aftman/Foreman to kill and clean up this process either
269    */
270    configure_detached_spawn(&mut cmd)?;
271
272    cmd.spawn()?;
273
274    Ok(())
275}
276
277#[cfg(target_os = "windows")]
278#[allow(clippy::zombie_processes)]
279fn spawn_studio_process(exe: &Path, args: &[OsString]) -> RobloxStudioResult<()> {
280    /*
281        Windows process creation flags & job objects:
282
283        https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
284        https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
285    */
286
287    const DETACHED_PROCESS: u32 = 0x0000_0008;
288    const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
289    const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
290
291    const WINDOWS_FLAGS_FULL: u32 =
292        DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB;
293    const WINDOWS_FLAGS_FALLBACK: u32 = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
294
295    /*
296        Break away from short-lived wrappers on Windows and
297        avoid tying Studio to the parent's console process group.
298
299        Some Windows parent processes run inside a job object that does not permit
300        `CREATE_BREAKAWAY_FROM_JOB`, which causes Studio launch to fail outright with
301        `ERROR_ACCESS_DENIED`. Retry without the breakaway flag so opening Studio still
302        works even when we cannot fully escape the parent job.
303    */
304    spawn_studio_process_with_flags(exe, args, WINDOWS_FLAGS_FULL).or_else(|error| match error {
305        RobloxStudioError::Io(io_error) if io_error.kind() == io::ErrorKind::PermissionDenied => {
306            spawn_studio_process_with_flags(exe, args, WINDOWS_FLAGS_FALLBACK)
307                .or_else(|_| spawn_studio_process_with_flags(exe, args, 0))
308        }
309        other => Err(other),
310    })
311}
312
313#[cfg(target_os = "windows")]
314#[allow(clippy::zombie_processes)]
315fn spawn_studio_process_with_flags(
316    exe: &Path,
317    args: &[OsString],
318    flags: u32,
319) -> RobloxStudioResult<()> {
320    let mut cmd = create_studio_command(exe, args);
321
322    configure_detached_spawn(&mut cmd, flags)?;
323
324    cmd.spawn()?;
325
326    Ok(())
327}
328
329#[cfg(target_family = "unix")]
330fn configure_detached_spawn(cmd: &mut Command) -> io::Result<()> {
331    use std::os::unix::process::CommandExt;
332
333    /*
334        Move Studio into a separate session so short-lived wrappers
335        and shell signals do not take it down with the parent process.
336
337        SAFETY: The closure only calls async-signal-safe `setsid` and returns
338        an OS error directly, which is the intended `pre_exec` usage.
339    */
340    unsafe {
341        cmd.pre_exec(|| {
342            if libc::setsid() == -1 {
343                Err(io::Error::last_os_error())
344            } else {
345                Ok(())
346            }
347        });
348    }
349
350    Ok(())
351}
352
353#[cfg(target_os = "windows")]
354fn configure_detached_spawn(cmd: &mut Command, flags: u32) -> io::Result<()> {
355    use std::os::windows::process::CommandExt;
356
357    if flags != 0 {
358        cmd.creation_flags(flags);
359    }
360
361    Ok(())
362}
363
364#[cfg(not(any(target_family = "unix", target_os = "windows")))]
365fn configure_detached_spawn(_cmd: &mut Command) -> io::Result<()> {
366    Ok(())
367}