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 = 1;
20
21const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PhytoConfig {
29 #[serde(default = "default_port")]
31 pub port: u16,
32}
33
34impl Default for PhytoConfig {
35 fn default() -> Self {
36 Self {
37 port: default_port(),
38 }
39 }
40}
41
42fn default_port() -> u16 {
43 9876
44}
45
46#[derive(Debug, Serialize)]
48struct CommandResponse {
49 ok: bool,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 value: Option<serde_json::Value>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 error: Option<String>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
58struct CallbackResult {
59 ok: bool,
60 #[serde(default)]
61 value: Option<serde_json::Value>,
62 #[serde(default)]
63 error: Option<String>,
64}
65
66#[derive(Clone)]
69pub struct PendingCallbacks {
70 inner: Arc<Mutex<HashMap<String, std::sync::mpsc::Sender<CallbackResult>>>>,
71}
72
73impl PendingCallbacks {
74 fn new() -> Self {
75 Self {
76 inner: Arc::new(Mutex::new(HashMap::new())),
77 }
78 }
79
80 fn insert(&self, id: String, sender: std::sync::mpsc::Sender<CallbackResult>) {
81 self.inner.lock().unwrap().insert(id, sender);
82 }
83
84 fn remove(&self, id: &str) {
85 self.inner.lock().unwrap().remove(id);
86 }
87
88 fn send(&self, id: &str, result: CallbackResult) {
89 let callbacks = self.inner.lock().unwrap();
90 if let Some(sender) = callbacks.get(id) {
91 let _ = sender.send(result);
92 }
93 }
94}
95
96#[derive(Clone)]
99struct ReadinessFlag(Arc<AtomicBool>);
100
101#[derive(Clone)]
113struct ProbeState {
114 loc_ok: Arc<AtomicBool>,
115 tauri_ok: Arc<AtomicBool>,
116 harness_ok: Arc<AtomicBool>,
117 failure_reason: Arc<Mutex<Option<String>>>,
118}
119
120impl ProbeState {
121 fn new() -> Self {
122 Self {
123 loc_ok: Arc::new(AtomicBool::new(false)),
124 tauri_ok: Arc::new(AtomicBool::new(false)),
125 harness_ok: Arc::new(AtomicBool::new(false)),
126 failure_reason: Arc::new(Mutex::new(None)),
127 }
128 }
129}
130
131#[tauri::command]
134fn eval_callback(
135 state: tauri::State<'_, PendingCallbacks>,
136 id: String,
137 ok: bool,
138 value: Option<serde_json::Value>,
139 error: Option<String>,
140) -> Result<(), String> {
141 let result = CallbackResult { ok, value, error };
142 state.send(&id, result);
143 Ok(())
144}
145
146#[tauri::command]
150fn signal_ready(state: tauri::State<'_, ReadinessFlag>) -> Result<(), String> {
151 log::info!("[phyto] Readiness signal received via IPC — marking ready");
152 state.0.store(true, Ordering::SeqCst);
153 Ok(())
154}
155
156#[tauri::command]
164fn report_probe_state(
165 state: tauri::State<'_, ProbeState>,
166 tauri_ok: bool,
167 harness_ok: bool,
168) -> Result<(), String> {
169 state.tauri_ok.store(tauri_ok, Ordering::SeqCst);
170 state.harness_ok.store(harness_ok, Ordering::SeqCst);
171 Ok(())
172}
173
174fn cors_headers() -> Vec<Header> {
177 vec![
178 Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(),
179 Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap(),
180 Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(),
181 Header::from_bytes("Content-Type", "application/json").unwrap(),
182 ]
183}
184
185fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
186 let data = body.as_bytes().to_vec();
187 let mut response = Response::from_data(data).with_status_code(status);
188 for header in cors_headers() {
189 response.add_header(header);
190 }
191 response
192}
193
194fn start_server<R: Runtime>(
202 ipc_ready: Arc<AtomicBool>,
203 pending: PendingCallbacks,
204 probe_state: ProbeState,
205 webview: WebviewWindow<R>,
206) {
207 let port = {
208 let config = webview.state::<PhytoConfig>();
209 config.port
210 };
211
212 let addr = format!("0.0.0.0:{}", port);
213 let server = match Server::http(&addr) {
214 Ok(s) => {
215 log::info!("[phyto] Automation server listening on http://localhost:{}", port);
216 s
217 }
218 Err(e) => {
219 log::error!("[phyto] Failed to start automation server on {}: {}", addr, e);
220 return;
221 }
222 };
223
224 {
235 let ipc_ready = ipc_ready.clone();
236 let webview = webview.clone();
237 let probe_state = probe_state.clone();
238 thread::spawn(move || {
239 let start = Instant::now();
240 let deadline = start + Duration::from_secs(120);
241 let mut probe_count = 0u32;
242 let mut warned_about_blank = false;
243 while Instant::now() < deadline {
244 let loc_ok = webview
248 .url()
249 .ok()
250 .map(|u| u.as_str() != "about:blank")
251 .unwrap_or(false);
252 probe_state.loc_ok.store(loc_ok, Ordering::SeqCst);
253
254 let script = format!(
257 r#"try {{
258 var __phyto_tauri = !!window.__TAURI_INTERNALS__;
259 var __phyto_harness = !!window.__phyto_harness__;
260 if ({probe_count} % 10 === 0) console.log('[phyto-probe] tauri=' + __phyto_tauri + ' harness=' + __phyto_harness);
261 if (__phyto_tauri) {{
262 window.__TAURI_INTERNALS__.invoke('plugin:phyto|report_probe_state', {{ tauri_ok: __phyto_tauri, harness_ok: __phyto_harness }});
263 if (__phyto_harness) {{
264 window.__TAURI_INTERNALS__.invoke('plugin:phyto|signal_ready');
265 }}
266 }}
267}} catch(e) {{ if ({probe_count} % 10 === 0) console.log('[phyto-probe] error: ' + e.message); }}"#,
268 probe_count = probe_count,
269 );
270
271 let _ = webview.eval(&script);
272 probe_count += 1;
273
274 if !warned_about_blank
278 && !loc_ok
279 && start.elapsed() >= Duration::from_secs(10)
280 {
281 log::warn!(
282 "[phyto] Readiness probe: webview still on about:blank after 10s — \
283 typically means tauri/custom-protocol isn't enabled in your test \
284 feature. Add it directly, or upgrade tauri-plugin-phyto to a version \
285 that pulls it in transitively."
286 );
287 warned_about_blank = true;
288 }
289
290 thread::sleep(Duration::from_millis(500));
291
292 if ipc_ready.load(Ordering::SeqCst) {
293 log::info!("[phyto] Readiness probe succeeded after {} probes", probe_count);
294 return;
295 }
296 }
297
298 let loc_ok = probe_state.loc_ok.load(Ordering::SeqCst);
301 let tauri_ok = probe_state.tauri_ok.load(Ordering::SeqCst);
302 let harness_ok = probe_state.harness_ok.load(Ordering::SeqCst);
303 let reason: &'static str = if !loc_ok {
304 "webview stuck on about:blank — typically means tauri/custom-protocol isn't \
305 enabled in your test feature. Add it directly, or upgrade tauri-plugin-phyto \
306 to a version that pulls it in transitively."
307 } else if !tauri_ok {
308 "Tauri IPC bridge never initialized — check that the main window finished \
309 loading."
310 } else if !harness_ok {
311 "Phyto harness never loaded — confirm @phyto/vite-plugin is wired up in your \
312 vite config."
313 } else {
314 "all probe conditions met but signal_ready never landed — Tauri IPC may be \
318 dropping invocations."
319 };
320 log::error!(
321 "[phyto] Readiness probe timed out after 120s ({} probes): {}",
322 probe_count,
323 reason
324 );
325 *probe_state.failure_reason.lock().unwrap() = Some(reason.to_string());
326 });
327 }
328
329 thread::spawn(move || {
330 for mut request in server.incoming_requests() {
331 let method_str = request.method().to_string();
332 let url = request.url().to_string();
333
334 if method_str == "OPTIONS" {
336 let _ = request.respond(json_response(204, ""));
337 continue;
338 }
339
340 if method_str == "POST" && url == "/__ready_probe" {
343 let mut probe_body = String::new();
344 let _ = request.as_reader().read_to_string(&mut probe_body);
345
346 let is_loaded = probe_body.contains("\"loaded\":true");
347 if is_loaded {
348 log::info!("[phyto] Readiness probe received loaded=true via HTTP — marking ready");
349 ipc_ready.store(true, Ordering::SeqCst);
350 }
351 let _ = request.respond(json_response(200, r#"{"ok":true}"#));
352 continue;
353 }
354
355 if method_str == "GET" && url == "/health" {
361 let failure = probe_state.failure_reason.lock().unwrap().clone();
362 if let Some(reason) = failure {
363 let body = serde_json::json!({
364 "status": "failed",
365 "reason": reason,
366 })
367 .to_string();
368 let _ = request.respond(json_response(503, &body));
369 } else if ipc_ready.load(Ordering::SeqCst) {
370 let body = serde_json::json!({ "status": "ok" }).to_string();
371 let _ = request.respond(json_response(200, &body));
372 } else {
373 let body = serde_json::json!({ "status": "loading" }).to_string();
374 let _ = request.respond(json_response(503, &body));
375 }
376 continue;
377 }
378
379 if method_str == "GET" && url == "/info" {
384 let body = serde_json::json!({
385 "protocol_version": PROTOCOL_VERSION,
386 "plugin_version": PLUGIN_VERSION,
387 "plugin": "tauri-plugin-phyto",
388 })
389 .to_string();
390 let _ = request.respond(json_response(200, &body));
391 continue;
392 }
393
394 if method_str == "POST" && url == "/command" {
396 let mut body = String::new();
397 if request.as_reader().read_to_string(&mut body).is_err() {
398 let _ = request.respond(json_response(
399 400,
400 r#"{"ok":false,"error":"Failed to read request body"}"#,
401 ));
402 continue;
403 }
404
405 let command_json: serde_json::Value = match serde_json::from_str(&body) {
407 Ok(v) => v,
408 Err(e) => {
409 let err = serde_json::json!({
410 "ok": false,
411 "error": format!("Invalid JSON: {}", e)
412 });
413 let _ = request.respond(json_response(400, &err.to_string()));
414 continue;
415 }
416 };
417
418 let callback_id = Uuid::new_v4().to_string();
421 let (tx, rx) = std::sync::mpsc::channel::<CallbackResult>();
422 pending.insert(callback_id.clone(), tx);
423
424 let command_str = serde_json::to_string(&command_json).unwrap();
425 let wrapped_script = format!(
426 r#"(async () => {{
427 const __phyto_id = "{}";
428 try {{
429 if (!window.__phyto_harness__) {{
430 throw new Error("Phyto harness not available — is the vite plugin installed?");
431 }}
432 const __phyto_result = await window.__phyto_harness__.execute({});
433 await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
434 id: __phyto_id,
435 ok: true,
436 value: __phyto_result,
437 error: null
438 }});
439 }} catch (__phyto_err) {{
440 await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
441 id: __phyto_id,
442 ok: false,
443 value: null,
444 error: __phyto_err.message || String(__phyto_err)
445 }});
446 }}
447}})()"#,
448 callback_id, command_str
449 );
450
451 let webview_clone = webview.clone();
452 let eval_result = webview_clone.eval(&wrapped_script);
453
454 if let Err(e) = eval_result {
455 pending.remove(&callback_id);
456 let err = serde_json::json!({
457 "ok": false,
458 "error": format!("Failed to evaluate command in webview: {}", e)
459 });
460 let _ = request.respond(json_response(500, &err.to_string()));
461 continue;
462 }
463
464 match rx.recv_timeout(std::time::Duration::from_secs(30)) {
465 Ok(result) => {
466 pending.remove(&callback_id);
467 let response = CommandResponse {
468 ok: result.ok,
469 value: result.value,
470 error: result.error,
471 };
472 let body = serde_json::to_string(&response).unwrap();
473 let _ = request.respond(json_response(200, &body));
474 }
475 Err(_) => {
476 pending.remove(&callback_id);
477 let err = serde_json::json!({
478 "ok": false,
479 "error": "Command timed out after 30 seconds"
480 });
481 let _ = request.respond(json_response(500, &err.to_string()));
482 }
483 }
484 continue;
485 }
486
487 let _ = request.respond(json_response(
489 404,
490 r#"{"ok":false,"error":"Not found"}"#,
491 ));
492 }
493 });
494}
495
496pub fn init<R: Runtime>(config: PhytoConfig) -> TauriPlugin<R> {
505 Builder::new("phyto")
506 .invoke_handler(tauri::generate_handler![
507 eval_callback,
508 signal_ready,
509 report_probe_state,
510 ])
511 .setup(move |app, _api| {
512 let pending = PendingCallbacks::new();
513 let ipc_ready = Arc::new(AtomicBool::new(false));
514 let probe_state = ProbeState::new();
515
516 app.manage(pending.clone());
518 app.manage(ReadinessFlag(ipc_ready.clone()));
519 app.manage(probe_state.clone());
520 app.manage(config.clone());
521
522 let app_handle = app.clone();
523 let pending_clone = pending.clone();
524 let probe_state_clone = probe_state.clone();
525
526 thread::spawn(move || {
530 let deadline = Instant::now() + Duration::from_secs(10);
531 loop {
532 if let Some(window) = app_handle.get_webview_window("main") {
533 start_server(ipc_ready, pending_clone, probe_state_clone, window);
534 return;
535 }
536 let windows = app_handle.webview_windows();
538 if let Some((_label, window)) = windows.into_iter().next() {
539 start_server(ipc_ready, pending_clone, probe_state_clone, window);
540 return;
541 }
542 if Instant::now() >= deadline {
543 log::error!("[phyto] No webview window found after 10s — automation server not started");
544 return;
545 }
546 thread::sleep(Duration::from_millis(250));
547 }
548 });
549
550 Ok(())
551 })
552 .build()
553}