Skip to main content

toolblox_lib/
lib.rs

1//! Toolblox native host.
2//!
3//! Toolblox is a Tauri 2 desktop app for managing and launching Roblox accounts. This crate owns
4//! everything that touches secrets, the network, the file system, or other processes. The React
5//! interface receives display data and opaque IDs only.
6//!
7//! # Modules
8//!
9//! - [`app_commands`]: the Tauri commands the interface calls, and the shared [`Runtime`].
10//! - [`startup`] and [`update`]: the fail-closed update gate that runs before the app opens.
11//! - [`storage`] and [`models`]: validated, atomic JSON persistence for settings and accounts.
12//! - [`credentials`]: Roblox sessions in Windows Credential Manager or the macOS Keychain.
13//! - [`login`]: the isolated Roblox sign-in window.
14//! - [`roblox`]: Roblox web APIs for profiles, presence, games, and join tickets.
15//! - [`auto_rejoin`]: presence monitoring for drop notifications and auto-rejoin.
16//! - [`multi_instance`]: running several Roblox clients on Windows.
17//! - [`migration`]: one-time import from the legacy Flet app.
18//! - [`lifecycle`]: window, tray, and single-instance behavior.
19//! - [`widgets`], [`widget_protocol`], [`widget_ipc`], and [`widget_process`]: signed widget
20//!   packages, their assets, permission-checked messages, and sidecar processes.
21//!
22//! # Data
23//!
24//! Current data lives in the `v2` folder inside the Toolblox data folder: `%LOCALAPPDATA%\Toolblox`
25//! on Windows and `~/Library/Application Support/Toolblox` on macOS.
26
27#![allow(rustdoc::private_intra_doc_links)]
28
29mod app_commands;
30mod auto_rejoin;
31mod credentials;
32mod lifecycle;
33mod login;
34mod migration;
35mod models;
36mod multi_instance;
37mod roblox;
38mod startup;
39mod storage;
40mod update;
41mod widget_ipc;
42mod widget_process;
43mod widget_protocol;
44mod widgets;
45
46use std::path::PathBuf;
47use std::sync::{Arc, Mutex};
48
49use app_commands::Runtime;
50use lifecycle::LifecycleState;
51use tauri::Manager;
52
53/// Returns the legacy Toolblox data folder, whose `v2` subfolder holds current data.
54///
55/// This is `%LOCALAPPDATA%\Toolblox` on Windows and `~/Library/Application Support/Toolblox` on
56/// macOS. Debug builds use `TOOLBLOX_DATA_DIR` instead when it's set.
57fn toolblox_data_root() -> Result<PathBuf, std::io::Error> {
58    #[cfg(debug_assertions)]
59    if let Some(path) = std::env::var_os("TOOLBLOX_DATA_DIR") {
60        return Ok(PathBuf::from(path));
61    }
62    #[cfg(windows)]
63    let base = std::env::var_os("LOCALAPPDATA")
64        .map(PathBuf::from)
65        .or_else(|| {
66            std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join("AppData/Local"))
67        });
68    #[cfg(target_os = "macos")]
69    let base = std::env::var_os("HOME")
70        .map(|home| PathBuf::from(home).join("Library/Application Support"));
71    #[cfg(not(any(windows, target_os = "macos")))]
72    let base = std::env::var_os("XDG_DATA_HOME")
73        .map(PathBuf::from)
74        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")));
75    base.map(|path| path.join("Toolblox")).ok_or_else(|| {
76        std::io::Error::new(
77            std::io::ErrorKind::NotFound,
78            "Toolblox couldn't locate its data folder. Is the user profile available?",
79        )
80    })
81}
82
83/// Name of the marker file that requests safe mode for the next launch.
84const SAFE_MODE_MARKER: &str = "safe-mode-next-launch";
85
86/// Records whether the next launch skips widgets. Failures only lose the one-time request.
87pub fn set_safe_mode_marker(data_root: &std::path::Path, enabled: bool) {
88    let marker = data_root.join(SAFE_MODE_MARKER);
89    let _ = if enabled {
90        std::fs::create_dir_all(data_root).and_then(|_| std::fs::write(marker, b""))
91    } else {
92        std::fs::remove_file(marker)
93    };
94}
95
96/// Safe mode applies for this launch when requested with `--safe-mode` or by the marker, which is
97/// consumed so the following launch loads widgets again.
98fn take_safe_mode(data_root: &std::path::Path) -> bool {
99    let marker = std::fs::remove_file(data_root.join(SAFE_MODE_MARKER)).is_ok();
100    marker || std::env::args().any(|argument| argument == "--safe-mode")
101}
102
103/// Starts the processes of widgets marked to start on launch, unless safe mode is active.
104fn start_widgets_on_launch(runtime: &Runtime) {
105    if runtime.safe_mode {
106        return;
107    }
108    let Ok(settings) = runtime.storage.load_settings() else {
109        return;
110    };
111    let Ok(mut processes) = runtime.processes.lock() else {
112        return;
113    };
114    for widget in widgets::discover_installed(&runtime.widgets_root)
115        .into_iter()
116        .filter_map(Result::ok)
117        .filter(|widget| {
118            settings.widgets_start_on_launch.contains(&widget.id)
119                && !settings.disabled_widgets.contains(&widget.id)
120        })
121    {
122        let root = runtime.widgets_root.join(&widget.id);
123        for declaration in &widget.manifest.processes {
124            let _ = processes.start(&widget.id, &root, declaration, &[]);
125        }
126    }
127}
128
129/// Builds and runs the Tauri application.
130#[cfg_attr(mobile, tauri::mobile_entry_point)]
131pub fn run() {
132    let lifecycle_state = Arc::new(LifecycleState::default());
133    let single_instance_state = Arc::clone(&lifecycle_state);
134    tauri::Builder::default()
135        .plugin(tauri_plugin_single_instance::init(move |app, _, _| {
136            lifecycle::activate_or_defer(app, &single_instance_state);
137        }))
138        .plugin(tauri_plugin_opener::init())
139        .plugin(tauri_plugin_notification::init())
140        .plugin(tauri_plugin_updater::Builder::new().build())
141        .register_uri_scheme_protocol("widget", widget_protocol::response)
142        .setup(move |app| {
143            app.handle().plugin(tauri_plugin_autostart::init(
144                tauri_plugin_autostart::MacosLauncher::LaunchAgent,
145                None,
146            ))?;
147
148            let legacy_root = toolblox_data_root()?;
149            let data_root = legacy_root.join("v2");
150            let widgets_root = data_root.join("widgets");
151            let safe_mode = take_safe_mode(&data_root);
152            let storage = Arc::new(storage::Storage::new(data_root.clone()));
153            let credentials = Arc::new(credentials::OsCredentialStore);
154            let client = reqwest::Client::builder()
155                .https_only(true)
156                .redirect(reqwest::redirect::Policy::none())
157                .timeout(std::time::Duration::from_secs(20))
158                .user_agent(concat!("Toolblox/", env!("CARGO_PKG_VERSION")))
159                .build()?;
160            let processes = Arc::new(Mutex::new(widget_process::WidgetProcessManager::default()));
161            let runtime = Runtime {
162                legacy_root,
163                storage,
164                credentials,
165                client,
166                widgets_root,
167                catalogue: Mutex::new(None),
168                catalogue_error: Mutex::new(None),
169                processes: Arc::clone(&processes),
170                widget_sessions: Mutex::new(widget_ipc::WidgetSessions::default()),
171                multi_instance_helper: Some(
172                    app.path()
173                        .resource_dir()?
174                        .join("resources/multi-instance/multi_instance_helper.exe"),
175                ),
176                cleared_roblox_pids: Mutex::new(Default::default()),
177                safe_mode,
178            };
179            app.manage(runtime);
180            app.manage(Arc::clone(&lifecycle_state));
181            app.manage(startup::StartupState::new());
182
183            let before_quit = Arc::new(move || {
184                if let Ok(mut processes) = processes.lock() {
185                    processes.stop_all();
186                }
187            });
188            let safe_mode_root = data_root.clone();
189            lifecycle::install_tray(
190                app.handle(),
191                Arc::clone(&lifecycle_state),
192                before_quit,
193                Arc::new(move || set_safe_mode_marker(&safe_mode_root, true)),
194            )?;
195
196            if let Some(window) = app.get_webview_window(lifecycle::MAIN_WINDOW_LABEL) {
197                let close_state = Arc::clone(&lifecycle_state);
198                let close_window = window.clone();
199                window.on_window_event(move |event| {
200                    if let tauri::WindowEvent::CloseRequested { api, .. } = event {
201                        let app = close_window.app_handle();
202                        let startup_ready = matches!(
203                            app.state::<startup::StartupState>().view(),
204                            startup::StartupView::Ready
205                        );
206                        let run_in_background = startup_ready
207                            && app
208                                .state::<Runtime>()
209                                .storage
210                                .load_settings()
211                                .map(|settings| settings.run_in_background)
212                                .unwrap_or(false);
213                        if matches!(
214                            lifecycle::handle_close_request(
215                                &close_window,
216                                api,
217                                &close_state,
218                                run_in_background,
219                            ),
220                            Ok(lifecycle::CloseDecision::Exit)
221                        ) {
222                            app.exit(0);
223                        }
224                    }
225                });
226            }
227
228            if let Some(window) = app.get_webview_window(lifecycle::MAIN_WINDOW_LABEL) {
229                window.show()?;
230                lifecycle::apply_pending_activation(app.handle(), &lifecycle_state)?;
231            }
232            tauri::async_runtime::spawn(startup::run(app.handle().clone()));
233            let services_app = app.handle().clone();
234            tauri::async_runtime::spawn(async move {
235                startup::wait_until_ready(&services_app).await;
236                #[cfg(not(debug_assertions))]
237                {
238                    use tauri_plugin_autostart::ManagerExt;
239
240                    let runtime = services_app.state::<Runtime>();
241                    if let Ok(settings) = runtime.storage.load_settings() {
242                        let autostart = services_app.autolaunch();
243                        if autostart.is_enabled().ok() != Some(settings.open_on_launch) {
244                            let _ = if settings.open_on_launch {
245                                autostart.enable()
246                            } else {
247                                autostart.disable()
248                            };
249                        }
250                    }
251                }
252                let always_on_top = services_app
253                    .state::<Runtime>()
254                    .storage
255                    .load_settings()
256                    .is_ok_and(|settings| settings.always_on_top);
257                if always_on_top
258                    && let Some(window) =
259                        services_app.get_webview_window(lifecycle::MAIN_WINDOW_LABEL)
260                {
261                    let _ = window.set_always_on_top(true);
262                }
263                app_commands::backfill_profiles(&services_app.state::<Runtime>()).await;
264                start_widgets_on_launch(&services_app.state::<Runtime>());
265                auto_rejoin::run(services_app).await;
266            });
267            Ok(())
268        })
269        .invoke_handler(tauri::generate_handler![
270            app_commands::get_app_state,
271            app_commands::begin_roblox_login,
272            app_commands::update_account_notes,
273            app_commands::set_account_place,
274            app_commands::place_exists,
275            app_commands::get_widget_store,
276            app_commands::set_widget_store,
277            app_commands::restart_app,
278            app_commands::remove_account,
279            app_commands::join_accounts,
280            app_commands::reorder_accounts,
281            app_commands::save_settings,
282            app_commands::install_widget,
283            app_commands::set_widget_enabled,
284            app_commands::set_widget_start_on_launch,
285            app_commands::update_widget,
286            app_commands::uninstall_widget,
287            app_commands::retry_catalogue,
288            app_commands::check_for_updates,
289            app_commands::open_widget,
290            app_commands::close_widget_session,
291            app_commands::widget_request,
292            startup::startup_status,
293            startup::retry_startup_update,
294            startup::quit_startup,
295        ])
296        .build(tauri::generate_context!())
297        .expect("Toolblox couldn't start")
298        .run(|app, event| {
299            #[cfg(target_os = "macos")]
300            if matches!(event, tauri::RunEvent::Reopen { .. }) {
301                lifecycle::activate_or_defer(
302                    app,
303                    app.state::<Arc<LifecycleState>>().inner().as_ref(),
304                );
305            }
306            if matches!(event, tauri::RunEvent::Exit)
307                && let Ok(mut processes) = app.state::<Runtime>().processes.lock()
308            {
309                processes.stop_all();
310            }
311        });
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn safe_mode_marker_applies_to_one_launch() {
320        let root = tempfile::tempdir().unwrap();
321        set_safe_mode_marker(root.path(), true);
322        assert!(take_safe_mode(root.path()));
323        assert!(!take_safe_mode(root.path()));
324        set_safe_mode_marker(root.path(), true);
325        set_safe_mode_marker(root.path(), false);
326        assert!(!take_safe_mode(root.path()));
327    }
328}