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()
166 .route("/health", get(health))
167 .merge(stateful)
168 .layer(DefaultBodyLimit::max(1_048_576))
170 .layer(middleware::from_fn(auth_middleware))
172 .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#[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 let token = generate_auth_token();
223 println!("debug-bridge auth token: {token}");
224 tracing::info!("debug-bridge auth token: {token}");
225
226 let (console_tx, _) = broadcast::channel(256);
228
229 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}