1#![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
53fn 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
83const SAFE_MODE_MARKER: &str = "safe-mode-next-launch";
85
86pub 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
96fn 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
103fn 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#[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}