Skip to main content

allframe_tauri/
plugin.rs

1//! Tauri 2.x plugin builder
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use allframe_core::router::Router;
7use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
8use tauri::{Emitter, Manager, Runtime};
9use tokio::sync::Mutex;
10
11use crate::error::TauriServerError;
12use crate::server::TauriServer;
13use crate::types::{CallResponse, HandlerInfo, StreamStartResponse};
14
15/// The plugin identifier used for Tauri 2 ACL resolution and IPC routing.
16///
17/// This **must** match the identifier that `tauri_plugin::Builder` in `build.rs`
18/// derives from the crate name (`allframe-tauri`). A mismatch causes:
19///
20/// ```text
21/// allframe.allframe_call not allowed. Plugin not found
22/// ```
23///
24/// Frontend invocations use this identifier:
25///
26/// ```typescript
27/// invoke("plugin:allframe-tauri|allframe_call", { handler: "greet", args: {} });
28/// ```
29pub const PLUGIN_NAME: &str = "allframe-tauri";
30
31/// Format a stream event name for a given handler and stream ID.
32///
33/// Returns `allframe-tauri:stream:{handler}:{stream_id}`.
34pub(crate) fn stream_event(handler: &str, stream_id: &str) -> String {
35    format!("{PLUGIN_NAME}:stream:{handler}:{stream_id}")
36}
37
38/// Format a boot-progress event name.
39///
40/// Returns `allframe-tauri:boot-progress`.
41pub(crate) fn boot_progress_event() -> String {
42    format!("{PLUGIN_NAME}:boot-progress")
43}
44
45/// Managed state for tracking active streams (for cancellation).
46pub(crate) struct ActiveStreams {
47    /// Maps stream_id -> JoinHandle abort handle
48    handles: Mutex<HashMap<String, tokio::task::AbortHandle>>,
49}
50
51impl ActiveStreams {
52    pub(crate) fn new() -> Self {
53        Self {
54            handles: Mutex::new(HashMap::new()),
55        }
56    }
57}
58
59/// List all registered AllFrame handlers.
60#[tauri::command]
61pub(crate) async fn allframe_list(
62    server: tauri::State<'_, TauriServer>,
63) -> Result<Vec<HandlerInfo>, TauriServerError> {
64    Ok(server.list_handlers().to_vec())
65}
66
67/// Call a handler by name with JSON arguments.
68#[tauri::command]
69pub(crate) async fn allframe_call(
70    handler: String,
71    args: serde_json::Value,
72    server: tauri::State<'_, TauriServer>,
73) -> Result<CallResponse, TauriServerError> {
74    let args_str = args.to_string();
75    server.call_handler(&handler, &args_str).await
76}
77
78/// Start a streaming handler. Returns a stream_id immediately.
79/// Stream items are emitted as Tauri events:
80/// - `allframe-tauri:stream:{handler}:{stream_id}` — each item
81/// - `allframe-tauri:stream:{handler}:{stream_id}:complete` — final result
82/// - `allframe-tauri:stream:{handler}:{stream_id}:error` — handler error
83#[tauri::command]
84pub(crate) async fn allframe_stream<R: Runtime>(
85    handler: String,
86    args: serde_json::Value,
87    app: tauri::AppHandle<R>,
88    server: tauri::State<'_, TauriServer>,
89    active: tauri::State<'_, Arc<ActiveStreams>>,
90) -> Result<StreamStartResponse, TauriServerError> {
91    let stream_id = uuid::Uuid::new_v4().to_string();
92    let args_str = args.to_string();
93
94    let (mut rx, join_handle) = server.call_streaming_handler(&handler, &args_str)?;
95
96    let sid = stream_id.clone();
97    let handler_name = handler.clone();
98    let app_clone = app.clone();
99    let active_inner: Arc<ActiveStreams> = (*active).clone();
100
101    let task = tokio::spawn(async move {
102        let event_base = stream_event(&handler_name, &sid);
103
104        // Forward stream items as events
105        while let Some(item) = rx.recv().await {
106            let _ = app_clone.emit(&event_base, &item);
107        }
108
109        // Wait for the handler to complete and emit final event
110        match join_handle.await {
111            Ok(Ok(response)) => {
112                let _ = app_clone.emit(&format!("{event_base}:complete"), &response.result);
113            }
114            Ok(Err(e)) => {
115                let _ = app_clone.emit(&format!("{event_base}:error"), &e.to_string());
116            }
117            Err(e) => {
118                let _ = app_clone.emit(
119                    &format!("{event_base}:error"),
120                    &format!("Handler task panicked: {e}"),
121                );
122            }
123        }
124
125        // Cleanup from active streams map
126        active_inner.handles.lock().await.remove(&sid);
127    });
128
129    // Store abort handle for cancellation
130    active
131        .handles
132        .lock()
133        .await
134        .insert(stream_id.clone(), task.abort_handle());
135
136    Ok(StreamStartResponse { stream_id })
137}
138
139/// Cancel an active stream by stream_id.
140#[tauri::command]
141pub(crate) async fn allframe_stream_cancel<R: Runtime>(
142    stream_id: String,
143    app: tauri::AppHandle<R>,
144    active: tauri::State<'_, Arc<ActiveStreams>>,
145) -> Result<(), TauriServerError> {
146    let mut handles = active.handles.lock().await;
147    match handles.remove(&stream_id) {
148        Some(abort_handle) => {
149            abort_handle.abort();
150            // The stream's StreamReceiver will be dropped when the task is aborted,
151            // which auto-cancels the CancellationToken.
152            let _ = app.emit(
153                &format!("{}:cancelled", stream_event("unknown", &stream_id)),
154                &(),
155            );
156            Ok(())
157        }
158        None => Err(TauriServerError::ExecutionFailed(format!(
159            "Stream not found or already completed: {stream_id}"
160        ))),
161    }
162}
163
164/// Create a Tauri 2.x plugin that exposes AllFrame handlers via IPC.
165///
166/// The Tauri `AppHandle<R>` is automatically injected as state during plugin
167/// setup, so handlers registered with `register_with_state::<AppHandle<R>, …>`
168/// (or any `*_with_state*` variant) can access it directly.
169///
170/// # Example
171///
172/// ```rust,ignore
173/// use allframe_core::router::{Router, State};
174/// use std::sync::Arc;
175/// use tauri::AppHandle;
176///
177/// fn main() {
178///     let mut router = Router::new();
179///     router.register("greet", || async { "Hello!".to_string() });
180///
181///     // AppHandle<tauri::Wry> is auto-injected — handlers can request it:
182///     router.register_with_state_only::<AppHandle<tauri::Wry>, _, _>(
183///         "send_notification",
184///         |app: State<Arc<AppHandle<tauri::Wry>>>| async move {
185///             // app.emit("event", &payload).unwrap();
186///             "sent".to_string()
187///         },
188///     );
189///
190///     tauri::Builder::default()
191///         .plugin(allframe_tauri::init(router))
192///         .run(tauri::generate_context!())
193///         .unwrap();
194/// }
195/// ```
196pub fn init<R: Runtime>(router: Router) -> TauriPlugin<R> {
197    build_plugin(move |app| {
198        let mut router = router;
199        router.inject_state(app.app_handle().clone());
200        app.manage(TauriServer::new(router));
201        app.manage(Arc::new(ActiveStreams::new()));
202        Ok(())
203    })
204}
205
206/// Internal helper: creates the plugin with invoke_handler and a custom setup closure.
207/// Used by both `init()` and `BootBuilder::build()`.
208pub(crate) fn build_plugin<R, F>(setup: F) -> TauriPlugin<R>
209where
210    R: Runtime,
211    F: FnOnce(&tauri::AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> + Send + 'static,
212{
213    PluginBuilder::new(PLUGIN_NAME)
214        .invoke_handler(tauri::generate_handler![
215            allframe_list,
216            allframe_call,
217            allframe_stream,
218            allframe_stream_cancel,
219        ])
220        .setup(move |app, _api| {
221            setup(app.app_handle())?;
222            Ok(())
223        })
224        .build()
225}
226
227/// Create a Tauri 2.x plugin with shared state for dependency injection.
228///
229/// Convenience wrapper that calls `router.with_state(state)` before
230/// constructing the plugin. The Tauri `AppHandle<R>` is also automatically
231/// injected (see [`init`]).
232///
233/// # Example
234///
235/// ```rust,ignore
236/// use allframe_core::router::Router;
237///
238/// struct AppState { db: DbPool }
239///
240/// fn main() {
241///     let state = AppState { db: pool };
242///     let mut router = Router::new().with_state(state);
243///     router.register_with_state_only::<AppState, _, _>("health", |s| async move {
244///         format!("ok")
245///     });
246///
247///     tauri::Builder::default()
248///         .plugin(allframe_tauri::init_with_state(router))
249///         .run(tauri::generate_context!())
250///         .unwrap();
251/// }
252/// ```
253pub fn init_with_state<R: Runtime, S: Send + Sync + 'static>(
254    router: Router,
255    state: S,
256) -> TauriPlugin<R> {
257    let router = router.with_state(state);
258    init(router)
259}
260
261/// Create a [`BootBuilder`](crate::boot::BootBuilder) for configuring
262/// an async boot phase before the app renders.
263///
264/// # Example
265///
266/// ```rust,ignore
267/// tauri::Builder::default()
268///     .plugin(
269///         allframe_tauri::builder(router)
270///             .on_boot(2, |ctx| async move {
271///                 let store = open_store(&ctx.data_dir()?).await
272///                     .map_err(|e| BootError::Failed(e.to_string()))?;
273///                 ctx.inject_state(store);
274///                 ctx.emit_progress("Store ready");
275///                 Ok(())
276///             })
277///             .build(),
278///     )
279///     .run(tauri::generate_context!())
280///     .unwrap();
281/// ```
282pub fn builder<R: Runtime>(router: Router) -> crate::boot::BootBuilder<R> {
283    crate::boot::BootBuilder::new(router)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    // ─── Plugin name identity ──────────────────────────────────────────
291
292    #[test]
293    fn plugin_name_matches_crate_name() {
294        // The Tauri 2 ACL system resolves permissions by the crate name
295        // (allframe-tauri). The runtime plugin name must match exactly.
296        assert_eq!(PLUGIN_NAME, "allframe-tauri");
297    }
298
299    #[test]
300    fn plugin_name_contains_hyphen() {
301        // Regression: the old value "allframe" (no hyphen) caused ACL
302        // mismatch because build.rs generates permissions under "allframe-tauri".
303        assert!(
304            PLUGIN_NAME.contains('-'),
305            "Plugin name must include hyphen to match crate-derived ACL identifier"
306        );
307    }
308
309    // ─── Event name formatting ─────────────────────────────────────────
310
311    #[test]
312    fn stream_event_uses_plugin_name_prefix() {
313        let event = stream_event("my_handler", "abc-123");
314        assert_eq!(event, "allframe-tauri:stream:my_handler:abc-123");
315    }
316
317    #[test]
318    fn stream_event_complete_suffix() {
319        let base = stream_event("handler", "id1");
320        let complete = format!("{base}:complete");
321        assert_eq!(complete, "allframe-tauri:stream:handler:id1:complete");
322    }
323
324    #[test]
325    fn stream_event_error_suffix() {
326        let base = stream_event("handler", "id1");
327        let error = format!("{base}:error");
328        assert_eq!(error, "allframe-tauri:stream:handler:id1:error");
329    }
330
331    #[test]
332    fn boot_progress_event_uses_plugin_name() {
333        assert_eq!(boot_progress_event(), "allframe-tauri:boot-progress");
334    }
335
336    #[test]
337    fn stream_event_does_not_use_old_prefix() {
338        // Regression: events previously used "allframe:" prefix
339        let event = stream_event("handler", "id");
340        assert!(
341            !event.starts_with("allframe:"),
342            "Event must not use the old 'allframe:' prefix (should be 'allframe-tauri:')"
343        );
344    }
345
346    #[test]
347    fn boot_progress_event_does_not_use_old_prefix() {
348        let event = boot_progress_event();
349        assert!(
350            !event.starts_with("allframe:"),
351            "Boot progress event must not use the old 'allframe:' prefix"
352        );
353    }
354}