Skip to main content

tauri_plugin_debug_bridge/
lib.rs

1use std::{collections::HashMap, sync::Arc};
2
3use axum::{
4    Router,
5    extract::DefaultBodyLimit,
6    http::{Request, StatusCode},
7    middleware::{self, Next},
8    response::{Json, Response},
9    routing::{get, post},
10};
11use rand::Rng;
12use serde::{Deserialize, Serialize};
13use tauri::{
14    AppHandle, Manager, Runtime,
15    plugin::{Builder, TauriPlugin},
16};
17use tokio::sync::{Mutex, broadcast, oneshot};
18
19mod backend;
20mod events;
21mod logs;
22mod webview;
23
24/// Plugin configuration, read from tauri.conf.json plugin section.
25#[derive(Debug, Deserialize, Default)]
26pub struct Config {
27    /// Port for the debug HTTP/WS server. Defaults to 9229.
28    pub port: Option<u16>,
29}
30
31/// Pending JS evaluation results, keyed by request ID.
32pub type PendingResults = Arc<Mutex<HashMap<String, oneshot::Sender<EvalResult>>>>;
33
34/// Result from a JS evaluation in the webview.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct EvalResult {
37    pub success: bool,
38    pub value: Option<serde_json::Value>,
39    pub error: Option<String>,
40}
41
42/// Shared state accessible to all axum route handlers.
43pub struct BridgeState<R: Runtime> {
44    pub app: AppHandle<R>,
45    pub pending: PendingResults,
46    pub console_tx: broadcast::Sender<String>,
47}
48
49/// Health check response.
50#[derive(Serialize)]
51struct HealthResponse {
52    status: &'static str,
53    plugin: &'static str,
54    version: &'static str,
55}
56
57/// Generate a random 32-character hex token for auth.
58fn generate_auth_token() -> String {
59    let mut rng = rand::thread_rng();
60    let bytes: [u8; 16] = Rng::r#gen(&mut rng);
61    bytes.iter().map(|b| format!("{b:02x}")).collect()
62}
63
64/// Middleware that checks the `X-Debug-Bridge-Token` header on every request
65/// except `/health`.
66async fn auth_middleware(
67    req: Request<axum::body::Body>,
68    next: Next,
69) -> Result<Response, StatusCode> {
70    // Skip auth for health check endpoint.
71    if req.uri().path() == "/health" {
72        return Ok(next.run(req).await);
73    }
74
75    let expected = req
76        .extensions()
77        .get::<AuthToken>()
78        .map(|t| t.0.clone())
79        .unwrap_or_default();
80
81    let provided = req
82        .headers()
83        .get("X-Debug-Bridge-Token")
84        .and_then(|v| v.to_str().ok())
85        .unwrap_or("");
86
87    if provided != expected {
88        return Err(StatusCode::UNAUTHORIZED);
89    }
90
91    Ok(next.run(req).await)
92}
93
94/// Wrapper to store the auth token in request extensions.
95#[derive(Clone)]
96struct AuthToken(String);
97
98/// Tauri command: receives JS eval results from the webview.
99/// Called by injected JS via `window.__TAURI__.invoke('plugin:debug-bridge|eval_callback', ...)`.
100#[tauri::command]
101async fn eval_callback(
102    pending: tauri::State<'_, PendingResults>,
103    id: String,
104    success: bool,
105    value: Option<serde_json::Value>,
106    error: Option<String>,
107) -> Result<(), String> {
108    let mut map = pending.lock().await;
109    if let Some(tx) = map.remove(&id) {
110        let _ = tx.send(EvalResult {
111            success,
112            value,
113            error,
114        });
115    }
116    Ok(())
117}
118
119/// Tauri command: receives JS console messages from the webview.
120/// Called by the injected console hook via `__TAURI_INTERNALS__.invoke`.
121#[tauri::command]
122async fn console_callback(
123    console_tx: tauri::State<'_, broadcast::Sender<String>>,
124    level: String,
125    message: String,
126) -> Result<(), String> {
127    let msg = serde_json::json!({
128        "level": level,
129        "message": message,
130    });
131    let _ = console_tx.send(msg.to_string());
132    Ok(())
133}
134
135/// Build the axum router with all debug bridge routes.
136fn build_router<R: Runtime>(state: Arc<BridgeState<R>>, token: String) -> Router {
137    let auth_token = AuthToken(token);
138
139    // Stateful routes (require BridgeState via axum State extractor).
140    let stateful = Router::new()
141        // Webview
142        .route("/eval", post(webview::webview_eval::<R>))
143        .route("/screenshot", get(webview::screenshot::<R>))
144        .route("/snapshot", get(webview::snapshot::<R>))
145        .route("/click", post(webview::click::<R>))
146        .route("/fill", post(webview::fill::<R>))
147        // Backend
148        .route("/invoke", post(backend::invoke::<R>))
149        .route("/commands", get(backend::commands::<R>))
150        .route("/state", get(backend::state::<R>))
151        .route("/windows", get(backend::windows::<R>))
152        .route("/config", get(backend::config::<R>))
153        // Events
154        .route("/events/emit", post(events::emit::<R>))
155        .route("/events/list", get(events::list::<R>))
156        .route("/events/listen", get(events::listen::<R>))
157        // Logs (WebSocket)
158        .route("/logs", get(logs::logs_ws::<R>))
159        .route("/console", get(logs::console_ws::<R>))
160        .with_state(state);
161
162    // Combine stateless health route with stateful routes, then apply security layers.
163    Router::new()
164        .route("/health", get(health))
165        .merge(stateful)
166        // Security: 1 MB body size limit
167        .layer(DefaultBodyLimit::max(1_048_576))
168        // Security: auth token middleware
169        .layer(axum::Extension(auth_token))
170        .layer(middleware::from_fn(auth_middleware))
171}
172
173async fn health() -> Json<HealthResponse> {
174    Json(HealthResponse {
175        status: "ok",
176        plugin: "tauri-plugin-debug-bridge",
177        version: env!("CARGO_PKG_VERSION"),
178    })
179}
180
181/// Initialize the debug bridge plugin.
182///
183/// ```rust,no_run
184/// // In your Tauri app's lib.rs:
185/// #[cfg(feature = "debug")]
186/// app.plugin(tauri_plugin_debug_bridge::init());
187/// ```
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn auth_token_format() {
194        let token = generate_auth_token();
195        assert_eq!(token.len(), 32, "token should be 32 hex chars");
196        assert!(
197            token.chars().all(|c| c.is_ascii_hexdigit()),
198            "token should only contain hex chars"
199        );
200    }
201
202    #[test]
203    fn auth_tokens_are_unique() {
204        let t1 = generate_auth_token();
205        let t2 = generate_auth_token();
206        assert_ne!(t1, t2, "consecutive tokens should differ");
207    }
208}
209
210pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
211    let pending: PendingResults = Arc::new(Mutex::new(HashMap::new()));
212
213    Builder::<R, Option<Config>>::new("debug-bridge")
214        .invoke_handler(tauri::generate_handler![eval_callback, console_callback])
215        .setup(move |app, api| {
216            let port = api.config().as_ref().and_then(|c| c.port).unwrap_or(9229);
217
218            // Generate auth token for this session.
219            let token = generate_auth_token();
220            println!("debug-bridge auth token: {token}");
221            tracing::info!("debug-bridge auth token: {token}");
222
223            // Broadcast channel for JS console messages.
224            let (console_tx, _) = broadcast::channel(256);
225
226            // Share state with both Tauri commands and axum handlers.
227            app.manage(pending.clone());
228            app.manage(console_tx.clone());
229
230            let state = Arc::new(BridgeState {
231                app: app.clone(),
232                pending,
233                console_tx,
234            });
235
236            let router = build_router(state, token);
237
238            tauri::async_runtime::spawn(async move {
239                let addr = format!("127.0.0.1:{port}");
240                tracing::info!("debug-bridge listening on http://{addr}");
241                let listener = match tokio::net::TcpListener::bind(&addr).await {
242                    Ok(l) => l,
243                    Err(e) => {
244                        tracing::error!("failed to bind debug-bridge on {addr}: {e}");
245                        return;
246                    }
247                };
248                if let Err(e) = axum::serve(listener, router).await {
249                    tracing::error!("debug-bridge server error: {e}");
250                }
251            });
252
253            Ok(())
254        })
255        .build()
256}