1use std::any::TypeId;
30use std::future::Future;
31use std::pin::Pin;
32use std::sync::atomic::{AtomicU32, Ordering};
33use std::sync::Arc;
34
35use allframe_core::router::{Router, SharedStateMap};
36use serde::Serialize;
37use tauri::plugin::TauriPlugin;
38use tauri::{Emitter, Manager, Runtime};
39
40use crate::plugin::{boot_progress_event, build_plugin, ActiveStreams};
41use crate::server::TauriServer;
42
43#[derive(Debug, Clone, Serialize)]
56pub struct BootProgress {
57 pub step: u32,
59 pub total: u32,
61 pub label: String,
63}
64
65#[derive(Debug, thiserror::Error, Serialize)]
67pub enum BootError {
68 #[error("Boot failed: {0}")]
70 Failed(String),
71
72 #[error("Could not resolve data directory: {0}")]
74 DataDir(String),
75
76 #[error("Failed to create boot runtime: {0}")]
78 Runtime(String),
79}
80
81pub struct BootContext<R: Runtime> {
88 app_handle: tauri::AppHandle<R>,
89 states: SharedStateMap,
90 total_steps: u32,
91 current_step: AtomicU32,
92}
93
94impl<R: Runtime> BootContext<R> {
95 pub fn app_handle(&self) -> &tauri::AppHandle<R> {
97 &self.app_handle
98 }
99
100 pub fn data_dir(&self) -> Result<std::path::PathBuf, BootError> {
102 self.app_handle
103 .path()
104 .app_data_dir()
105 .map_err(|e| BootError::DataDir(e.to_string()))
106 }
107
108 pub fn inject_state<S: Send + Sync + 'static>(&self, state: S) {
114 let mut map = self.states.write().expect("state lock poisoned");
115 map.insert(TypeId::of::<S>(), Arc::new(state));
116 }
117
118 pub fn emit_progress(&self, label: &str) {
123 let step = self.current_step.fetch_add(1, Ordering::Relaxed) + 1;
124 let payload = BootProgress {
125 step,
126 total: self.total_steps,
127 label: label.to_string(),
128 };
129 if let Err(_e) = self.app_handle.emit(&boot_progress_event(), &payload) {
130 #[cfg(debug_assertions)]
131 eprintln!("allframe: failed to emit boot progress event: {_e}");
132 }
133 }
134}
135
136type BoxedBootFn<R> =
140 Box<dyn FnOnce(BootContext<R>) -> Pin<Box<dyn Future<Output = Result<(), BootError>>>> + Send>;
141
142pub struct BootBuilder<R: Runtime> {
155 router: Router,
156 boot_fn: Option<BoxedBootFn<R>>,
157 step_count: u32,
158}
159
160impl<R: Runtime> BootBuilder<R> {
161 pub fn has_boot(&self) -> bool {
163 self.boot_fn.is_some()
164 }
165
166 pub fn step_count(&self) -> u32 {
168 self.step_count
169 }
170}
171
172impl<R: Runtime> BootBuilder<R> {
173 pub fn new(router: Router) -> Self {
175 Self {
176 router,
177 boot_fn: None,
178 step_count: 0,
179 }
180 }
181
182 pub fn on_boot<F, Fut>(mut self, steps: u32, f: F) -> Self
192 where
193 F: FnOnce(BootContext<R>) -> Fut + Send + 'static,
194 Fut: Future<Output = Result<(), BootError>> + Send + 'static,
195 {
196 self.step_count = steps;
197 self.boot_fn = Some(Box::new(move |ctx| Box::pin(f(ctx))));
198 self
199 }
200
201 pub fn build(self) -> TauriPlugin<R> {
207 let BootBuilder {
208 router,
209 boot_fn,
210 step_count,
211 } = self;
212
213 build_plugin(move |app_handle| {
214 let mut router = router;
215 router.inject_state(app_handle.clone());
216
217 if let Some(boot) = boot_fn {
218 let ctx = BootContext {
219 app_handle: app_handle.clone(),
220 states: router.shared_states(),
221 total_steps: step_count,
222 current_step: AtomicU32::new(0),
223 };
224
225 let rt = tokio::runtime::Builder::new_current_thread()
228 .enable_all()
229 .build()
230 .map_err(|e| {
231 Box::new(BootError::Runtime(e.to_string()))
232 as Box<dyn std::error::Error>
233 })?;
234
235 rt.block_on(boot(ctx)).map_err(|e| {
236 Box::new(e) as Box<dyn std::error::Error>
237 })?;
238 }
240
241 app_handle.manage(TauriServer::new(router));
242 app_handle.manage(Arc::new(ActiveStreams::new()));
243 Ok(())
244 })
245 }
246}
247
248#[cfg(test)]
251mod tests {
252 use super::*;
253 use allframe_core::router::{Router, State};
254
255 #[test]
256 fn test_boot_progress_serialization() {
257 let progress = BootProgress {
258 step: 2,
259 total: 3,
260 label: "Projections ready".to_string(),
261 };
262 let json = serde_json::to_string(&progress).unwrap();
263 assert!(json.contains("\"step\":2"));
264 assert!(json.contains("\"total\":3"));
265 assert!(json.contains("Projections ready"));
266 }
267
268 #[test]
269 fn test_boot_error_serialization() {
270 let err = BootError::Failed("store open failed".to_string());
271 let json = serde_json::to_string(&err).unwrap();
272 assert!(json.contains("store open failed"));
273 }
274
275 #[test]
276 fn test_boot_error_display() {
277 let err = BootError::Failed("oops".to_string());
278 assert_eq!(err.to_string(), "Boot failed: oops");
279
280 let err = BootError::DataDir("not found".to_string());
281 assert_eq!(err.to_string(), "Could not resolve data directory: not found");
282
283 let err = BootError::Runtime("failed".to_string());
284 assert_eq!(err.to_string(), "Failed to create boot runtime: failed");
285 }
286
287 #[tokio::test]
288 async fn test_boot_state_visible_to_handlers() {
289 struct BootState {
290 name: String,
291 }
292
293 let mut router = Router::new();
294
295 router.register_with_state_only::<BootState, _, _>(
297 "get_name",
298 |state: State<Arc<BootState>>| async move { state.name.clone() },
299 );
300
301 {
303 let states = router.shared_states();
304 let mut map = states.write().unwrap();
305 map.insert(
306 TypeId::of::<BootState>(),
307 Arc::new(BootState {
308 name: "booted".to_string(),
309 }) as Arc<dyn std::any::Any + Send + Sync>,
310 );
311 }
312
313 let server = TauriServer::new(router);
315 let result = server.call_handler("get_name", "{}").await.unwrap();
316 assert_eq!(result.result, "booted");
317 }
318
319 #[test]
320 fn test_boot_builder_defaults() {
321 let router = Router::new();
322 let builder: BootBuilder<tauri::Wry> = BootBuilder::new(router);
323 assert!(!builder.has_boot());
324 assert_eq!(builder.step_count(), 0);
325 }
326
327 #[test]
328 fn test_boot_builder_on_boot_configures() {
329 let router = Router::new();
330 let builder: BootBuilder<tauri::Wry> = BootBuilder::new(router)
331 .on_boot(3, |_ctx| async move { Ok(()) });
332 assert!(builder.has_boot());
333 assert_eq!(builder.step_count(), 3);
334 }
335
336 #[test]
344 fn test_full_boot_lifecycle_without_tauri() {
345 struct DbPool {
346 url: String,
347 }
348 struct AppConfig {
349 version: u32,
350 }
351
352 let mut router = Router::new();
353
354 router.register_with_state_only::<DbPool, _, _>(
356 "db_url",
357 |db: State<Arc<DbPool>>| async move { db.url.clone() },
358 );
359 router.register_with_state_only::<AppConfig, _, _>(
360 "version",
361 |cfg: State<Arc<AppConfig>>| async move { format!("{}", cfg.version) },
362 );
363
364 let rt = tokio::runtime::Builder::new_current_thread()
366 .enable_all()
367 .build()
368 .unwrap();
369
370 let states = router.shared_states();
372 rt.block_on(async {
373 let pool = DbPool {
375 url: "sqlite://app.db".to_string(),
376 };
377 let config = AppConfig { version: 42 };
378
379 {
381 let mut map = states.write().unwrap();
382 map.insert(
383 TypeId::of::<DbPool>(),
384 Arc::new(pool) as Arc<dyn std::any::Any + Send + Sync>,
385 );
386 map.insert(
387 TypeId::of::<AppConfig>(),
388 Arc::new(config) as Arc<dyn std::any::Any + Send + Sync>,
389 );
390 }
391 });
392 let server = TauriServer::new(router);
396 let rt2 = tokio::runtime::Builder::new_current_thread()
397 .enable_all()
398 .build()
399 .unwrap();
400 let db_result = rt2.block_on(server.call_handler("db_url", "{}")).unwrap();
401 assert_eq!(db_result.result, "sqlite://app.db");
402
403 let ver_result = rt2.block_on(server.call_handler("version", "{}")).unwrap();
404 assert_eq!(ver_result.result, "42");
405 }
406}