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}