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/// Well-known directory for discovery files.
65const DISCOVERY_DIR: &str = "/tmp/tauri-debug-bridge";
66
67/// Write a discovery file so the CLI can auto-find this app's port and token.
68fn write_discovery_file(identifier: &str, port: u16, token: &str) -> std::io::Result<()> {
69    let dir = std::path::Path::new(DISCOVERY_DIR);
70    std::fs::create_dir_all(dir)?;
71
72    let file_path = dir.join(format!("{identifier}.json"));
73    let content = serde_json::json!({ "port": port, "token": token });
74    std::fs::write(&file_path, content.to_string())?;
75
76    #[cfg(unix)]
77    {
78        use std::os::unix::fs::PermissionsExt;
79        std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))?;
80    }
81
82    Ok(())
83}
84
85/// Middleware that checks the `X-Debug-Bridge-Token` header on every request
86/// except `/health`.
87async fn auth_middleware(
88    req: Request<axum::body::Body>,
89    next: Next,
90) -> Result<Response, StatusCode> {
91    // Skip auth for health check endpoint.
92    if req.uri().path() == "/health" {
93        return Ok(next.run(req).await);
94    }
95
96    let expected = req
97        .extensions()
98        .get::<AuthToken>()
99        .map(|t| t.0.clone())
100        .unwrap_or_default();
101
102    let provided = req
103        .headers()
104        .get("X-Debug-Bridge-Token")
105        .and_then(|v| v.to_str().ok())
106        .unwrap_or("");
107
108    if provided != expected {
109        return Err(StatusCode::UNAUTHORIZED);
110    }
111
112    Ok(next.run(req).await)
113}
114
115/// Wrapper to store the auth token in request extensions.
116#[derive(Clone)]
117struct AuthToken(String);
118
119/// Tauri command: receives JS eval results from the webview.
120/// Called by injected JS via `window.__TAURI__.invoke('plugin:debug-bridge|eval_callback', ...)`.
121#[tauri::command]
122async fn eval_callback(
123    pending: tauri::State<'_, PendingResults>,
124    id: String,
125    success: bool,
126    value: Option<serde_json::Value>,
127    error: Option<String>,
128) -> Result<(), String> {
129    let mut map = pending.lock().await;
130    if let Some(tx) = map.remove(&id) {
131        let _ = tx.send(EvalResult {
132            success,
133            value,
134            error,
135        });
136    }
137    Ok(())
138}
139
140/// Tauri command: receives JS console messages from the webview.
141/// Called by the injected console hook via `__TAURI_INTERNALS__.invoke`.
142#[tauri::command]
143async fn console_callback(
144    console_tx: tauri::State<'_, broadcast::Sender<String>>,
145    level: String,
146    message: String,
147) -> Result<(), String> {
148    let msg = serde_json::json!({
149        "level": level,
150        "message": message,
151    });
152    let _ = console_tx.send(msg.to_string());
153    Ok(())
154}
155
156/// Build the axum router with all debug bridge routes.
157fn build_router<R: Runtime>(state: Arc<BridgeState<R>>, token: String) -> Router {
158    let auth_token = AuthToken(token);
159
160    // Stateful routes (require BridgeState via axum State extractor).
161    let stateful = Router::new()
162        // Webview
163        .route("/eval", post(webview::webview_eval::<R>))
164        .route("/screenshot", get(webview::screenshot::<R>))
165        .route("/snapshot", get(webview::snapshot::<R>))
166        .route("/click", post(webview::click::<R>))
167        .route("/fill", post(webview::fill::<R>))
168        // Backend
169        .route("/invoke", post(backend::invoke::<R>))
170        .route("/commands", get(backend::commands::<R>))
171        .route("/state", get(backend::state::<R>))
172        .route("/windows", get(backend::windows::<R>))
173        .route("/config", get(backend::config::<R>))
174        // Events
175        .route("/events/emit", post(events::emit::<R>))
176        .route("/events/list", get(events::list::<R>))
177        .route("/events/listen", get(events::listen::<R>))
178        // Logs (WebSocket)
179        .route("/logs", get(logs::logs_ws::<R>))
180        .route("/console", get(logs::console_ws::<R>))
181        .with_state(state);
182
183    // Combine stateless health route with stateful routes, then apply security layers.
184    // Layer order: outermost layer is the LAST .layer() call.
185    // Extension must be outer so auth_middleware can read it from request extensions.
186    Router::new()
187        .route("/health", get(health))
188        .merge(stateful)
189        // Security: 1 MB body size limit
190        .layer(DefaultBodyLimit::max(1_048_576))
191        // Security: auth token check (reads AuthToken from extensions)
192        .layer(middleware::from_fn(auth_middleware))
193        // Inject auth token into request extensions (must be outermost)
194        .layer(axum::Extension(auth_token))
195}
196
197async fn health() -> Json<HealthResponse> {
198    Json(HealthResponse {
199        status: "ok",
200        plugin: "tauri-plugin-debug-bridge",
201        version: env!("CARGO_PKG_VERSION"),
202    })
203}
204
205/// Initialize the debug bridge plugin.
206///
207/// ```rust,no_run
208/// // In your Tauri app's lib.rs:
209/// #[cfg(feature = "debug")]
210/// app.plugin(tauri_plugin_debug_bridge::init());
211/// ```
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn auth_token_format() {
218        let token = generate_auth_token();
219        assert_eq!(token.len(), 32, "token should be 32 hex chars");
220        assert!(
221            token.chars().all(|c| c.is_ascii_hexdigit()),
222            "token should only contain hex chars"
223        );
224    }
225
226    #[test]
227    fn auth_tokens_are_unique() {
228        let t1 = generate_auth_token();
229        let t2 = generate_auth_token();
230        assert_ne!(t1, t2, "consecutive tokens should differ");
231    }
232}
233
234pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
235    let pending: PendingResults = Arc::new(Mutex::new(HashMap::new()));
236
237    Builder::<R, Option<Config>>::new("debug-bridge")
238        .invoke_handler(tauri::generate_handler![eval_callback, console_callback])
239        .setup(move |app, api| {
240            let port = api.config().as_ref().and_then(|c| c.port).unwrap_or(9229);
241
242            // Generate auth token for this session.
243            let token = generate_auth_token();
244            println!("debug-bridge auth token: {token}");
245            tracing::info!("debug-bridge auth token: {token}");
246
247            // Broadcast channel for JS console messages.
248            let (console_tx, _) = broadcast::channel(256);
249
250            // Share state with both Tauri commands and axum handlers.
251            app.manage(pending.clone());
252            app.manage(console_tx.clone());
253
254            let state = Arc::new(BridgeState {
255                app: app.clone(),
256                pending,
257                console_tx,
258            });
259
260            let router = build_router(state, token.clone());
261            let identifier = app.config().identifier.clone();
262
263            tauri::async_runtime::spawn(async move {
264                let addr = format!("127.0.0.1:{port}");
265                let listener = match tokio::net::TcpListener::bind(&addr).await {
266                    Ok(l) => l,
267                    Err(e) => {
268                        tracing::error!("failed to bind debug-bridge on {addr}: {e}");
269                        return;
270                    }
271                };
272
273                let actual_port = listener.local_addr().unwrap().port();
274                tracing::info!("debug-bridge listening on http://127.0.0.1:{actual_port}");
275
276                // Write discovery file after binding so we have the real port
277                // (important when configured port is 0 = OS-assigned).
278                if let Err(e) = write_discovery_file(&identifier, actual_port, &token) {
279                    tracing::warn!("failed to write discovery file: {e}");
280                } else {
281                    tracing::info!("debug-bridge discovery: {DISCOVERY_DIR}/{identifier}.json");
282                }
283
284                if let Err(e) = axum::serve(listener, router).await {
285                    tracing::error!("debug-bridge server error: {e}");
286                }
287            });
288
289            Ok(())
290        })
291        .build()
292}