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    // Layer order: outermost layer is the LAST .layer() call.
164    // Extension must be outer so auth_middleware can read it from request extensions.
165    Router::new()
166        .route("/health", get(health))
167        .merge(stateful)
168        // Security: 1 MB body size limit
169        .layer(DefaultBodyLimit::max(1_048_576))
170        // Security: auth token check (reads AuthToken from extensions)
171        .layer(middleware::from_fn(auth_middleware))
172        // Inject auth token into request extensions (must be outermost)
173        .layer(axum::Extension(auth_token))
174}
175
176async fn health() -> Json<HealthResponse> {
177    Json(HealthResponse {
178        status: "ok",
179        plugin: "tauri-plugin-debug-bridge",
180        version: env!("CARGO_PKG_VERSION"),
181    })
182}
183
184/// Initialize the debug bridge plugin.
185///
186/// ```rust,no_run
187/// // In your Tauri app's lib.rs:
188/// #[cfg(feature = "debug")]
189/// app.plugin(tauri_plugin_debug_bridge::init());
190/// ```
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn auth_token_format() {
197        let token = generate_auth_token();
198        assert_eq!(token.len(), 32, "token should be 32 hex chars");
199        assert!(
200            token.chars().all(|c| c.is_ascii_hexdigit()),
201            "token should only contain hex chars"
202        );
203    }
204
205    #[test]
206    fn auth_tokens_are_unique() {
207        let t1 = generate_auth_token();
208        let t2 = generate_auth_token();
209        assert_ne!(t1, t2, "consecutive tokens should differ");
210    }
211}
212
213pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
214    let pending: PendingResults = Arc::new(Mutex::new(HashMap::new()));
215
216    Builder::<R, Option<Config>>::new("debug-bridge")
217        .invoke_handler(tauri::generate_handler![eval_callback, console_callback])
218        .setup(move |app, api| {
219            let port = api.config().as_ref().and_then(|c| c.port).unwrap_or(9229);
220
221            // Generate auth token for this session.
222            let token = generate_auth_token();
223            println!("debug-bridge auth token: {token}");
224            tracing::info!("debug-bridge auth token: {token}");
225
226            // Broadcast channel for JS console messages.
227            let (console_tx, _) = broadcast::channel(256);
228
229            // Share state with both Tauri commands and axum handlers.
230            app.manage(pending.clone());
231            app.manage(console_tx.clone());
232
233            let state = Arc::new(BridgeState {
234                app: app.clone(),
235                pending,
236                console_tx,
237            });
238
239            let router = build_router(state, token);
240
241            tauri::async_runtime::spawn(async move {
242                let addr = format!("127.0.0.1:{port}");
243                tracing::info!("debug-bridge listening on http://{addr}");
244                let listener = match tokio::net::TcpListener::bind(&addr).await {
245                    Ok(l) => l,
246                    Err(e) => {
247                        tracing::error!("failed to bind debug-bridge on {addr}: {e}");
248                        return;
249                    }
250                };
251                if let Err(e) = axum::serve(listener, router).await {
252                    tracing::error!("debug-bridge server error: {e}");
253                }
254            });
255
256            Ok(())
257        })
258        .build()
259}