Skip to main content

allframe_tauri/
boot.rs

1//! Async boot lifecycle for Tauri plugins.
2//!
3//! Tauri 2's `setup()` closure is synchronous and runs on the macOS main
4//! thread with no Tokio reactor. This module provides [`BootBuilder`] and
5//! [`BootContext`] to run async initialization (event stores, projections,
6//! command buses) correctly — creating a dedicated Tokio runtime, blocking
7//! until boot completes, and emitting progress events to the frontend.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! allframe_tauri::builder(router)
13//!     .on_boot(2, |ctx| async move {
14//!         let store = open_store(&ctx.data_dir()?).await
15//!             .map_err(|e| BootError::Failed(e.to_string()))?;
16//!         ctx.inject_state(store);
17//!         ctx.emit_progress("Event store opened");
18//!
19//!         let registry = init_projections().await
20//!             .map_err(|e| BootError::Failed(e.to_string()))?;
21//!         ctx.inject_state(registry);
22//!         ctx.emit_progress("Projections ready");
23//!
24//!         Ok(())
25//!     })
26//!     .build()
27//! ```
28
29use 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// ─── Types ──────────────────────────────────────────────────────────────────
44
45/// Progress event payload emitted during boot as `allframe-tauri:boot-progress`.
46///
47/// Frontend can listen for this to render a splash/loading screen:
48///
49/// ```typescript
50/// import { listen } from "@tauri-apps/api/event";
51/// await listen("allframe-tauri:boot-progress", (e) => {
52///     // e.payload: { step: 2, total: 3, label: "Projections ready" }
53/// });
54/// ```
55#[derive(Debug, Clone, Serialize)]
56pub struct BootProgress {
57    /// Current step number (1-indexed).
58    pub step: u32,
59    /// Total number of steps declared in `on_boot`.
60    pub total: u32,
61    /// Human-readable label for this step.
62    pub label: String,
63}
64
65/// Errors that can occur during the boot phase.
66#[derive(Debug, thiserror::Error, Serialize)]
67pub enum BootError {
68    /// Generic boot failure with a descriptive message.
69    #[error("Boot failed: {0}")]
70    Failed(String),
71
72    /// Could not resolve the app data directory.
73    #[error("Could not resolve data directory: {0}")]
74    DataDir(String),
75
76    /// Failed to create the boot Tokio runtime.
77    #[error("Failed to create boot runtime: {0}")]
78    Runtime(String),
79}
80
81// ─── BootContext ─────────────────────────────────────────────────────────────
82
83/// Context passed to the async boot closure.
84///
85/// Provides access to the Tauri `AppHandle`, state injection into the
86/// Router's shared state map, and progress event emission.
87pub 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    /// Access the Tauri `AppHandle`.
96    pub fn app_handle(&self) -> &tauri::AppHandle<R> {
97        &self.app_handle
98    }
99
100    /// Convenience: resolve the app data directory.
101    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    /// Inject state into the Router so handlers can access it via
109    /// `State<Arc<S>>`.
110    ///
111    /// State is immediately visible to handlers at call time because
112    /// the Router resolves state lazily from the shared map.
113    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    /// Emit a progress event to the frontend.
119    ///
120    /// Increments the internal step counter and emits an
121    /// `allframe-tauri:boot-progress` event with the step number, total, and label.
122    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
136// ─── BootBuilder ────────────────────────────────────────────────────────────
137
138/// Type-erased boot closure.
139type BoxedBootFn<R> =
140    Box<dyn FnOnce(BootContext<R>) -> Pin<Box<dyn Future<Output = Result<(), BootError>>>> + Send>;
141
142/// Builder for creating a Tauri plugin with an optional async boot phase.
143///
144/// # Example
145///
146/// ```rust,ignore
147/// allframe_tauri::builder(router)
148///     .on_boot(2, |ctx| async move {
149///         // async initialization...
150///         Ok(())
151///     })
152///     .build()
153/// ```
154pub 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    /// Returns `true` if an async boot closure has been registered.
162    pub fn has_boot(&self) -> bool {
163        self.boot_fn.is_some()
164    }
165
166    /// Returns the number of progress steps declared via `on_boot`.
167    pub fn step_count(&self) -> u32 {
168        self.step_count
169    }
170}
171
172impl<R: Runtime> BootBuilder<R> {
173    /// Create a new boot builder with the given router.
174    pub fn new(router: Router) -> Self {
175        Self {
176            router,
177            boot_fn: None,
178            step_count: 0,
179        }
180    }
181
182    /// Set the async boot closure.
183    ///
184    /// `steps` is the total number of progress steps (for the denominator
185    /// in `BootProgress`). The closure receives a [`BootContext`] for state
186    /// injection and progress reporting.
187    ///
188    /// The boot closure runs on a dedicated current-thread Tokio runtime
189    /// inside Tauri's synchronous `setup()` hook. It blocks until completion,
190    /// ensuring all state is ready before the UI renders.
191    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    /// Build the Tauri plugin.
202    ///
203    /// If [`on_boot`](Self::on_boot) was called, a current-thread Tokio
204    /// runtime is created inside `setup()` to drive the async boot to
205    /// completion before the app renders.
206    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                // Create a current-thread Tokio runtime for the boot phase.
226                // This handles the "no reactor on macOS main thread" problem.
227                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                // rt drops here — boot runtime is ephemeral
239            }
240
241            app_handle.manage(TauriServer::new(router));
242            app_handle.manage(Arc::new(ActiveStreams::new()));
243            Ok(())
244        })
245    }
246}
247
248// ─── Tests ──────────────────────────────────────────────────────────────────
249
250#[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        // Register handler BEFORE state is injected (the whole point)
296        router.register_with_state_only::<BootState, _, _>(
297            "get_name",
298            |state: State<Arc<BootState>>| async move { state.name.clone() },
299        );
300
301        // Simulate boot: inject state into the shared map
302        {
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        // Handler should see the injected state
314        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    /// Integration test: exercises the full boot lifecycle without Tauri.
337    ///
338    /// Simulates what `BootBuilder::build()` does internally:
339    /// 1. Register handlers before state exists
340    /// 2. Create a current-thread runtime (same as build() does)
341    /// 3. Run boot closure that injects state via SharedStateMap
342    /// 4. Verify handlers can access the injected state
343    #[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        // Step 1: Register handlers BEFORE state is injected
355        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        // Step 2: Create a current-thread runtime (same as BootBuilder::build)
365        let rt = tokio::runtime::Builder::new_current_thread()
366            .enable_all()
367            .build()
368            .unwrap();
369
370        // Step 3: Run async boot that injects state
371        let states = router.shared_states();
372        rt.block_on(async {
373            // Simulate async work (e.g., opening a database)
374            let pool = DbPool {
375                url: "sqlite://app.db".to_string(),
376            };
377            let config = AppConfig { version: 42 };
378
379            // Inject through SharedStateMap (same path as BootContext::inject_state)
380            {
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        // rt drops here — ephemeral, same as in BootBuilder::build
393
394        // Step 4: Verify handlers see the boot-injected state
395        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}