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
64const DISCOVERY_DIR: &str = "/tmp/tauri-debug-bridge";
66
67fn 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
85async fn auth_middleware(
88 req: Request<axum::body::Body>,
89 next: Next,
90) -> Result<Response, StatusCode> {
91 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#[derive(Clone)]
117struct AuthToken(String);
118
119#[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]
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
156fn build_router<R: Runtime>(state: Arc<BridgeState<R>>, token: String) -> Router {
158 let auth_token = AuthToken(token);
159
160 let stateful = Router::new()
162 .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 .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 .route("/events/emit", post(events::emit::<R>))
176 .route("/events/list", get(events::list::<R>))
177 .route("/events/listen", get(events::listen::<R>))
178 .route("/logs", get(logs::logs_ws::<R>))
180 .route("/console", get(logs::console_ws::<R>))
181 .with_state(state);
182
183 Router::new()
187 .route("/health", get(health))
188 .merge(stateful)
189 .layer(DefaultBodyLimit::max(1_048_576))
191 .layer(middleware::from_fn(auth_middleware))
193 .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#[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 let token = generate_auth_token();
244 println!("debug-bridge auth token: {token}");
245 tracing::info!("debug-bridge auth token: {token}");
246
247 let (console_tx, _) = broadcast::channel(256);
249
250 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 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}