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#[tauri::command]
104fn eval_callback(
105 state: tauri::State<'_, PendingCallbacks>,
106 id: String,
107 ok: bool,
108 value: Option<serde_json::Value>,
109 error: Option<String>,
110) -> Result<(), String> {
111 let result = CallbackResult { ok, value, error };
112 state.send(&id, result);
113 Ok(())
114}
115
116#[tauri::command]
120fn signal_ready(state: tauri::State<'_, ReadinessFlag>) -> Result<(), String> {
121 log::info!("[phyto] Readiness signal received via IPC — marking ready");
122 state.0.store(true, Ordering::SeqCst);
123 Ok(())
124}
125
126fn cors_headers() -> Vec<Header> {
129 vec![
130 Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(),
131 Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap(),
132 Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(),
133 Header::from_bytes("Content-Type", "application/json").unwrap(),
134 ]
135}
136
137fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
138 let data = body.as_bytes().to_vec();
139 let mut response = Response::from_data(data).with_status_code(status);
140 for header in cors_headers() {
141 response.add_header(header);
142 }
143 response
144}
145
146fn start_server<R: Runtime>(
154 ipc_ready: Arc<AtomicBool>,
155 pending: PendingCallbacks,
156 webview: WebviewWindow<R>,
157) {
158 let port = {
159 let config = webview.state::<PhytoConfig>();
160 config.port
161 };
162
163 let addr = format!("0.0.0.0:{}", port);
164 let server = match Server::http(&addr) {
165 Ok(s) => {
166 log::info!("[phyto] Automation server listening on http://localhost:{}", port);
167 s
168 }
169 Err(e) => {
170 log::error!("[phyto] Failed to start automation server on {}: {}", addr, e);
171 return;
172 }
173 };
174
175 {
179 let ipc_ready = ipc_ready.clone();
180 let webview = webview.clone();
181 thread::spawn(move || {
182 let deadline = Instant::now() + Duration::from_secs(120);
183 let mut probe_count = 0u32;
184 while Instant::now() < deadline {
185 let script = format!(
191 r#"try {{
192 var __phyto_loc = location.href !== 'about:blank';
193 var __phyto_tauri = !!window.__TAURI_INTERNALS__;
194 var __phyto_harness = !!window.__phyto_harness__;
195 var __phyto_loaded = __phyto_loc && __phyto_tauri && __phyto_harness;
196 if ({probe_count} % 10 === 0) console.log('[phyto-probe] loc=' + __phyto_loc + ' tauri=' + __phyto_tauri + ' harness=' + __phyto_harness + ' loaded=' + __phyto_loaded);
197 if (__phyto_loaded && __phyto_tauri) {{
198 window.__TAURI_INTERNALS__.invoke('plugin:phyto|signal_ready');
199 }}
200}} catch(e) {{ if ({probe_count} % 10 === 0) console.log('[phyto-probe] error: ' + e.message); }}"#,
201 probe_count = probe_count,
202 );
203
204 let _ = webview.eval(&script);
205 probe_count += 1;
206 thread::sleep(Duration::from_millis(500));
207
208 if ipc_ready.load(Ordering::SeqCst) {
209 log::info!("[phyto] Readiness probe succeeded after {} probes", probe_count);
210 return;
211 }
212 }
213 log::error!("[phyto] Readiness probe timed out after 120s ({} probes)", probe_count);
214 });
215 }
216
217 thread::spawn(move || {
218 for mut request in server.incoming_requests() {
219 let method_str = request.method().to_string();
220 let url = request.url().to_string();
221
222 if method_str == "OPTIONS" {
224 let _ = request.respond(json_response(204, ""));
225 continue;
226 }
227
228 if method_str == "POST" && url == "/__ready_probe" {
231 let mut probe_body = String::new();
232 let _ = request.as_reader().read_to_string(&mut probe_body);
233
234 let is_loaded = probe_body.contains("\"loaded\":true");
235 if is_loaded {
236 log::info!("[phyto] Readiness probe received loaded=true via HTTP — marking ready");
237 ipc_ready.store(true, Ordering::SeqCst);
238 }
239 let _ = request.respond(json_response(200, r#"{"ok":true}"#));
240 continue;
241 }
242
243 if method_str == "GET" && url == "/health" {
245 if ipc_ready.load(Ordering::SeqCst) {
246 let body = serde_json::json!({ "status": "ok" }).to_string();
247 let _ = request.respond(json_response(200, &body));
248 } else {
249 let body = serde_json::json!({ "status": "loading" }).to_string();
250 let _ = request.respond(json_response(503, &body));
251 }
252 continue;
253 }
254
255 if method_str == "GET" && url == "/info" {
260 let body = serde_json::json!({
261 "protocol_version": PROTOCOL_VERSION,
262 "plugin_version": PLUGIN_VERSION,
263 "plugin": "tauri-plugin-phyto",
264 })
265 .to_string();
266 let _ = request.respond(json_response(200, &body));
267 continue;
268 }
269
270 if method_str == "POST" && url == "/command" {
272 let mut body = String::new();
273 if request.as_reader().read_to_string(&mut body).is_err() {
274 let _ = request.respond(json_response(
275 400,
276 r#"{"ok":false,"error":"Failed to read request body"}"#,
277 ));
278 continue;
279 }
280
281 let command_json: serde_json::Value = match serde_json::from_str(&body) {
283 Ok(v) => v,
284 Err(e) => {
285 let err = serde_json::json!({
286 "ok": false,
287 "error": format!("Invalid JSON: {}", e)
288 });
289 let _ = request.respond(json_response(400, &err.to_string()));
290 continue;
291 }
292 };
293
294 let callback_id = Uuid::new_v4().to_string();
297 let (tx, rx) = std::sync::mpsc::channel::<CallbackResult>();
298 pending.insert(callback_id.clone(), tx);
299
300 let command_str = serde_json::to_string(&command_json).unwrap();
301 let wrapped_script = format!(
302 r#"(async () => {{
303 const __phyto_id = "{}";
304 try {{
305 if (!window.__phyto_harness__) {{
306 throw new Error("Phyto harness not available — is the vite plugin installed?");
307 }}
308 const __phyto_result = await window.__phyto_harness__.execute({});
309 await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
310 id: __phyto_id,
311 ok: true,
312 value: __phyto_result,
313 error: null
314 }});
315 }} catch (__phyto_err) {{
316 await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
317 id: __phyto_id,
318 ok: false,
319 value: null,
320 error: __phyto_err.message || String(__phyto_err)
321 }});
322 }}
323}})()"#,
324 callback_id, command_str
325 );
326
327 let webview_clone = webview.clone();
328 let eval_result = webview_clone.eval(&wrapped_script);
329
330 if let Err(e) = eval_result {
331 pending.remove(&callback_id);
332 let err = serde_json::json!({
333 "ok": false,
334 "error": format!("Failed to evaluate command in webview: {}", e)
335 });
336 let _ = request.respond(json_response(500, &err.to_string()));
337 continue;
338 }
339
340 match rx.recv_timeout(std::time::Duration::from_secs(30)) {
341 Ok(result) => {
342 pending.remove(&callback_id);
343 let response = CommandResponse {
344 ok: result.ok,
345 value: result.value,
346 error: result.error,
347 };
348 let body = serde_json::to_string(&response).unwrap();
349 let _ = request.respond(json_response(200, &body));
350 }
351 Err(_) => {
352 pending.remove(&callback_id);
353 let err = serde_json::json!({
354 "ok": false,
355 "error": "Command timed out after 30 seconds"
356 });
357 let _ = request.respond(json_response(500, &err.to_string()));
358 }
359 }
360 continue;
361 }
362
363 let _ = request.respond(json_response(
365 404,
366 r#"{"ok":false,"error":"Not found"}"#,
367 ));
368 }
369 });
370}
371
372pub fn init<R: Runtime>(config: PhytoConfig) -> TauriPlugin<R> {
381 Builder::new("phyto")
382 .invoke_handler(tauri::generate_handler![eval_callback, signal_ready])
383 .setup(move |app, _api| {
384 let pending = PendingCallbacks::new();
385 let ipc_ready = Arc::new(AtomicBool::new(false));
386
387 app.manage(pending.clone());
389 app.manage(ReadinessFlag(ipc_ready.clone()));
390 app.manage(config.clone());
391
392 let app_handle = app.clone();
393 let pending_clone = pending.clone();
394
395 thread::spawn(move || {
399 let deadline = Instant::now() + Duration::from_secs(10);
400 loop {
401 if let Some(window) = app_handle.get_webview_window("main") {
402 start_server(ipc_ready, pending_clone, window);
403 return;
404 }
405 let windows = app_handle.webview_windows();
407 if let Some((_label, window)) = windows.into_iter().next() {
408 start_server(ipc_ready, pending_clone, window);
409 return;
410 }
411 if Instant::now() >= deadline {
412 log::error!("[phyto] No webview window found after 10s — automation server not started");
413 return;
414 }
415 thread::sleep(Duration::from_millis(250));
416 }
417 });
418
419 Ok(())
420 })
421 .build()
422}