tauri_plugin_debug_bridge/
lib.rs1use 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#[derive(Debug, Deserialize, Default)]
26pub struct Config {
27 pub port: Option<u16>,
29}
30
31pub type PendingResults = Arc<Mutex<HashMap<String, oneshot::Sender<EvalResult>>>>;
33
34#[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
42pub struct BridgeState<R: Runtime> {
44 pub app: AppHandle<R>,
45 pub pending: PendingResults,
46 pub console_tx: broadcast::Sender<String>,
47}
48
49#[derive(Serialize)]
51struct HealthResponse {
52 status: &'static str,
53 plugin: &'static str,
54 version: &'static str,
55}
56
57fn 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
64async fn auth_middleware(
67 req: Request<axum::body::Body>,
68 next: Next,
69) -> Result<Response, StatusCode> {
70 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#[derive(Clone)]
96struct AuthToken(String);
97
98#[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]
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
135fn build_router<R: Runtime>(state: Arc<BridgeState<R>>, token: String) -> Router {
137 let auth_token = AuthToken(token);
138
139 let stateful = Router::new()
141 .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 .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 .route("/events/emit", post(events::emit::<R>))
155 .route("/events/list", get(events::list::<R>))
156 .route("/events/listen", get(events::listen::<R>))
157 .route("/logs", get(logs::logs_ws::<R>))
159 .route("/console", get(logs::console_ws::<R>))
160 .with_state(state);
161
162 Router::new()
164 .route("/health", get(health))
165 .merge(stateful)
166 .layer(DefaultBodyLimit::max(1_048_576))
168 .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#[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 let token = generate_auth_token();
220 println!("debug-bridge auth token: {token}");
221 tracing::info!("debug-bridge auth token: {token}");
222
223 let (console_tx, _) = broadcast::channel(256);
225
226 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}