1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex};
5use std::thread;
6use std::time::{Duration, Instant};
7use tauri::{
8 plugin::{Builder, TauriPlugin},
9 Manager, Runtime, WebviewWindow,
10};
11use tiny_http::{Header, Response, Server};
12use uuid::Uuid;
13
14pub const PROTOCOL_VERSION: u32 = 2;
27
28const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PhytoConfig {
36 #[serde(default = "default_port")]
38 pub port: u16,
39}
40
41impl Default for PhytoConfig {
42 fn default() -> Self {
43 Self {
44 port: default_port(),
45 }
46 }
47}
48
49fn default_port() -> u16 {
50 9876
51}
52
53#[derive(Debug, Serialize)]
55struct CommandResponse {
56 ok: bool,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 value: Option<serde_json::Value>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 error: Option<String>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
65struct CallbackResult {
66 ok: bool,
67 #[serde(default)]
68 value: Option<serde_json::Value>,
69 #[serde(default)]
70 error: Option<String>,
71}
72
73#[derive(Clone)]
76pub struct PendingCallbacks {
77 inner: Arc<Mutex<HashMap<String, std::sync::mpsc::Sender<CallbackResult>>>>,
78}
79
80impl PendingCallbacks {
81 fn new() -> Self {
82 Self {
83 inner: Arc::new(Mutex::new(HashMap::new())),
84 }
85 }
86
87 fn insert(&self, id: String, sender: std::sync::mpsc::Sender<CallbackResult>) {
88 self.inner.lock().unwrap().insert(id, sender);
89 }
90
91 fn remove(&self, id: &str) {
92 self.inner.lock().unwrap().remove(id);
93 }
94
95 fn send(&self, id: &str, result: CallbackResult) {
96 let callbacks = self.inner.lock().unwrap();
97 if let Some(sender) = callbacks.get(id) {
98 let _ = sender.send(result);
99 }
100 }
101}
102
103#[derive(Clone)]
106struct ReadinessFlag(Arc<AtomicBool>);
107
108#[derive(Clone)]
120struct ProbeState {
121 loc_ok: Arc<AtomicBool>,
122 tauri_ok: Arc<AtomicBool>,
123 harness_ok: Arc<AtomicBool>,
124 failure_reason: Arc<Mutex<Option<String>>>,
125}
126
127impl ProbeState {
128 fn new() -> Self {
129 Self {
130 loc_ok: Arc::new(AtomicBool::new(false)),
131 tauri_ok: Arc::new(AtomicBool::new(false)),
132 harness_ok: Arc::new(AtomicBool::new(false)),
133 failure_reason: Arc::new(Mutex::new(None)),
134 }
135 }
136}
137
138#[tauri::command]
141fn eval_callback(
142 state: tauri::State<'_, PendingCallbacks>,
143 id: String,
144 ok: bool,
145 value: Option<serde_json::Value>,
146 error: Option<String>,
147) -> Result<(), String> {
148 let result = CallbackResult { ok, value, error };
149 state.send(&id, result);
150 Ok(())
151}
152
153#[tauri::command]
157fn signal_ready(state: tauri::State<'_, ReadinessFlag>) -> Result<(), String> {
158 log::info!("[phyto] Readiness signal received via IPC — marking ready");
159 state.0.store(true, Ordering::SeqCst);
160 Ok(())
161}
162
163#[tauri::command]
171fn report_probe_state(
172 state: tauri::State<'_, ProbeState>,
173 tauri_ok: bool,
174 harness_ok: bool,
175) -> Result<(), String> {
176 state.tauri_ok.store(tauri_ok, Ordering::SeqCst);
177 state.harness_ok.store(harness_ok, Ordering::SeqCst);
178 Ok(())
179}
180
181fn cors_headers() -> Vec<Header> {
184 vec![
185 Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(),
186 Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap(),
187 Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(),
188 Header::from_bytes("Content-Type", "application/json").unwrap(),
189 ]
190}
191
192fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
193 let data = body.as_bytes().to_vec();
194 let mut response = Response::from_data(data).with_status_code(status);
195 for header in cors_headers() {
196 response.add_header(header);
197 }
198 response
199}
200
201fn start_server<R: Runtime>(
209 ipc_ready: Arc<AtomicBool>,
210 pending: PendingCallbacks,
211 probe_state: ProbeState,
212 webview: WebviewWindow<R>,
213) {
214 let port = {
215 let config = webview.state::<PhytoConfig>();
216 config.port
217 };
218
219 let addr = format!("0.0.0.0:{}", port);
220 let server = match Server::http(&addr) {
221 Ok(s) => {
222 log::info!("[phyto] Automation server listening on http://localhost:{}", port);
223 s
224 }
225 Err(e) => {
226 log::error!("[phyto] Failed to start automation server on {}: {}", addr, e);
227 return;
228 }
229 };
230
231 {
242 let ipc_ready = ipc_ready.clone();
243 let webview = webview.clone();
244 let probe_state = probe_state.clone();
245 thread::spawn(move || {
246 let start = Instant::now();
247 let deadline = start + Duration::from_secs(120);
248 let mut probe_count = 0u32;
249 let mut warned_about_blank = false;
250 while Instant::now() < deadline {
251 let loc_ok = webview
255 .url()
256 .ok()
257 .map(|u| u.as_str() != "about:blank")
258 .unwrap_or(false);
259 probe_state.loc_ok.store(loc_ok, Ordering::SeqCst);
260
261 let script = format!(
264 r#"try {{
265 var __phyto_tauri = !!window.__TAURI_INTERNALS__;
266 var __phyto_harness = !!window.__phyto_harness__;
267 if ({probe_count} % 10 === 0) console.log('[phyto-probe] tauri=' + __phyto_tauri + ' harness=' + __phyto_harness);
268 if (__phyto_tauri) {{
269 window.__TAURI_INTERNALS__.invoke('plugin:phyto|report_probe_state', {{ tauri_ok: __phyto_tauri, harness_ok: __phyto_harness }});
270 if (__phyto_harness) {{
271 window.__TAURI_INTERNALS__.invoke('plugin:phyto|signal_ready');
272 }}
273 }}
274}} catch(e) {{ if ({probe_count} % 10 === 0) console.log('[phyto-probe] error: ' + e.message); }}"#,
275 probe_count = probe_count,
276 );
277
278 let _ = webview.eval(&script);
279 probe_count += 1;
280
281 if !warned_about_blank
285 && !loc_ok
286 && start.elapsed() >= Duration::from_secs(10)
287 {
288 log::warn!(
289 "[phyto] Readiness probe: webview still on about:blank after 10s — \
290 typically means tauri/custom-protocol isn't enabled in your test \
291 feature. Add it directly, or upgrade tauri-plugin-phyto to a version \
292 that pulls it in transitively."
293 );
294 warned_about_blank = true;
295 }
296
297 thread::sleep(Duration::from_millis(500));
298
299 if ipc_ready.load(Ordering::SeqCst) {
300 log::info!("[phyto] Readiness probe succeeded after {} probes", probe_count);
301 return;
302 }
303 }
304
305 let loc_ok = probe_state.loc_ok.load(Ordering::SeqCst);
308 let tauri_ok = probe_state.tauri_ok.load(Ordering::SeqCst);
309 let harness_ok = probe_state.harness_ok.load(Ordering::SeqCst);
310 let reason: &'static str = if !loc_ok {
311 "webview stuck on about:blank — typically means tauri/custom-protocol isn't \
312 enabled in your test feature. Add it directly, or upgrade tauri-plugin-phyto \
313 to a version that pulls it in transitively."
314 } else if !tauri_ok {
315 "Tauri IPC bridge never initialized — check that the main window finished \
316 loading."
317 } else if !harness_ok {
318 "Phyto harness never loaded — confirm @phyto/vite-plugin is wired up in your \
319 vite config."
320 } else {
321 "all probe conditions met but signal_ready never landed — Tauri IPC may be \
325 dropping invocations."
326 };
327 log::error!(
328 "[phyto] Readiness probe timed out after 120s ({} probes): {}",
329 probe_count,
330 reason
331 );
332 *probe_state.failure_reason.lock().unwrap() = Some(reason.to_string());
333 });
334 }
335
336 thread::spawn(move || {
337 for mut request in server.incoming_requests() {
338 let method_str = request.method().to_string();
339 let url = request.url().to_string();
340
341 if method_str == "OPTIONS" {
343 let _ = request.respond(json_response(204, ""));
344 continue;
345 }
346
347 if method_str == "POST" && url == "/__ready_probe" {
350 let mut probe_body = String::new();
351 let _ = request.as_reader().read_to_string(&mut probe_body);
352
353 let is_loaded = probe_body.contains("\"loaded\":true");
354 if is_loaded {
355 log::info!("[phyto] Readiness probe received loaded=true via HTTP — marking ready");
356 ipc_ready.store(true, Ordering::SeqCst);
357 }
358 let _ = request.respond(json_response(200, r#"{"ok":true}"#));
359 continue;
360 }
361
362 if method_str == "GET" && url == "/health" {
368 let failure = probe_state.failure_reason.lock().unwrap().clone();
369 if let Some(reason) = failure {
370 let body = serde_json::json!({
371 "status": "failed",
372 "reason": reason,
373 })
374 .to_string();
375 let _ = request.respond(json_response(503, &body));
376 } else if ipc_ready.load(Ordering::SeqCst) {
377 let body = serde_json::json!({ "status": "ok" }).to_string();
378 let _ = request.respond(json_response(200, &body));
379 } else {
380 let body = serde_json::json!({ "status": "loading" }).to_string();
381 let _ = request.respond(json_response(503, &body));
382 }
383 continue;
384 }
385
386 if method_str == "GET" && url == "/info" {
391 let body = serde_json::json!({
392 "protocol_version": PROTOCOL_VERSION,
393 "plugin_version": PLUGIN_VERSION,
394 "plugin": "tauri-plugin-phyto",
395 })
396 .to_string();
397 let _ = request.respond(json_response(200, &body));
398 continue;
399 }
400
401 if method_str == "POST" && url == "/command" {
403 let mut body = String::new();
404 if request.as_reader().read_to_string(&mut body).is_err() {
405 let _ = request.respond(json_response(
406 400,
407 r#"{"ok":false,"error":"Failed to read request body"}"#,
408 ));
409 continue;
410 }
411
412 let command_json: serde_json::Value = match serde_json::from_str(&body) {
414 Ok(v) => v,
415 Err(e) => {
416 let err = serde_json::json!({
417 "ok": false,
418 "error": format!("Invalid JSON: {}", e)
419 });
420 let _ = request.respond(json_response(400, &err.to_string()));
421 continue;
422 }
423 };
424
425 let callback_id = Uuid::new_v4().to_string();
428 let (tx, rx) = std::sync::mpsc::channel::<CallbackResult>();
429 pending.insert(callback_id.clone(), tx);
430
431 let command_str = serde_json::to_string(&command_json).unwrap();
432 let wrapped_script = format!(
433 r#"(async () => {{
434 const __phyto_id = "{}";
435 try {{
436 if (!window.__phyto_harness__) {{
437 throw new Error("Phyto harness not available — is the vite plugin installed?");
438 }}
439 const __phyto_result = await window.__phyto_harness__.execute({});
440 await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
441 id: __phyto_id,
442 ok: true,
443 value: __phyto_result,
444 error: null
445 }});
446 }} catch (__phyto_err) {{
447 await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
448 id: __phyto_id,
449 ok: false,
450 value: null,
451 error: __phyto_err.message || String(__phyto_err)
452 }});
453 }}
454}})()"#,
455 callback_id, command_str
456 );
457
458 let webview_clone = webview.clone();
459 let eval_result = webview_clone.eval(&wrapped_script);
460
461 if let Err(e) = eval_result {
462 pending.remove(&callback_id);
463 let err = serde_json::json!({
464 "ok": false,
465 "error": format!("Failed to evaluate command in webview: {}", e)
466 });
467 let _ = request.respond(json_response(500, &err.to_string()));
468 continue;
469 }
470
471 match rx.recv_timeout(std::time::Duration::from_secs(30)) {
472 Ok(result) => {
473 pending.remove(&callback_id);
474 let response = CommandResponse {
475 ok: result.ok,
476 value: result.value,
477 error: result.error,
478 };
479 let body = serde_json::to_string(&response).unwrap();
480 let _ = request.respond(json_response(200, &body));
481 }
482 Err(_) => {
483 pending.remove(&callback_id);
484 let err = serde_json::json!({
485 "ok": false,
486 "error": "Command timed out after 30 seconds"
487 });
488 let _ = request.respond(json_response(500, &err.to_string()));
489 }
490 }
491 continue;
492 }
493
494 let _ = request.respond(json_response(
496 404,
497 r#"{"ok":false,"error":"Not found"}"#,
498 ));
499 }
500 });
501}
502
503pub fn init<R: Runtime>(config: PhytoConfig) -> TauriPlugin<R> {
512 Builder::new("phyto")
513 .invoke_handler(tauri::generate_handler![
514 eval_callback,
515 signal_ready,
516 report_probe_state,
517 ])
518 .setup(move |app, _api| {
519 let pending = PendingCallbacks::new();
520 let ipc_ready = Arc::new(AtomicBool::new(false));
521 let probe_state = ProbeState::new();
522
523 app.manage(pending.clone());
525 app.manage(ReadinessFlag(ipc_ready.clone()));
526 app.manage(probe_state.clone());
527 app.manage(config.clone());
528
529 let app_handle = app.clone();
530 let pending_clone = pending.clone();
531 let probe_state_clone = probe_state.clone();
532
533 thread::spawn(move || {
537 let deadline = Instant::now() + Duration::from_secs(10);
538 loop {
539 if let Some(window) = app_handle.get_webview_window("main") {
540 start_server(ipc_ready, pending_clone, probe_state_clone, window);
541 return;
542 }
543 let windows = app_handle.webview_windows();
545 if let Some((_label, window)) = windows.into_iter().next() {
546 start_server(ipc_ready, pending_clone, probe_state_clone, window);
547 return;
548 }
549 if Instant::now() >= deadline {
550 log::error!("[phyto] No webview window found after 10s — automation server not started");
551 return;
552 }
553 thread::sleep(Duration::from_millis(250));
554 }
555 });
556
557 Ok(())
558 })
559 .build()
560}