Skip to main content

tauri_plugin_phyto/
lib.rs

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
14/// Wire protocol version. Kept manually in lockstep with the
15/// `PROTOCOL_VERSION` constants in `crates/phyto-core/src/protocol.rs` and
16/// the TS packages. The driver fetches this via `GET /info` and exits cleanly
17/// on mismatch — see `packages/driver-tauri/src/index.ts`. Bump only on
18/// breaking wire-format changes.
19pub const PROTOCOL_VERSION: u32 = 1;
20
21/// `Cargo.toml`-defined version of this crate. Returned alongside
22/// `protocol_version` on `GET /info` so the driver can surface it in error
23/// messages when versions mismatch.
24const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
25
26/// Configuration for the Phyto automation server.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PhytoConfig {
29    /// Port for the HTTP automation server (default: 9876)
30    #[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/// Response body for command results
47#[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/// Callback result received from the webview via Tauri IPC
57#[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/// Shared state for pending command callbacks.
67/// Wrapped in Arc so both the HTTP server thread and Tauri command can share it.
68#[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/// Shared readiness flag. Managed as Tauri state so both the HTTP server
97/// thread and the `signal_ready` IPC command can access it.
98#[derive(Clone)]
99struct ReadinessFlag(Arc<AtomicBool>);
100
101// ─── Tauri command: receives command results from the webview via IPC ───
102
103#[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 IPC command called by the readiness probe script.
117/// Uses Tauri IPC instead of HTTP fetch to avoid mixed-content blocks
118/// when the app runs under `tauri://localhost` (custom-protocol).
119#[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
126// ─── HTTP helpers ───────────────────────────────────────────────────
127
128fn 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
146/// Start the HTTP automation server in a background thread.
147///
148/// The server includes an IPC readiness probe: before `/health` returns 200,
149/// it verifies that the webview's Tauri IPC bridge is functional by sending
150/// a test eval and waiting for the callback. This prevents the harness from
151/// thinking the app is ready when the page hasn't fully loaded yet
152/// (common under QEMU + Xvfb where WebKitGTK init is slow).
153fn 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    // Readiness probe thread: injects JS into the webview that signals
176    // readiness via Tauri IPC (not HTTP fetch, which is blocked by
177    // mixed-content policies when the app uses custom-protocol / tauri://).
178    {
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                // The probe checks three conditions:
186                //  1. Page navigated away from about:blank
187                //  2. Tauri IPC bridge available
188                //  3. Phyto harness loaded
189                // When all are true, signals readiness via Tauri IPC.
190                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            // Handle CORS preflight
223            if method_str == "OPTIONS" {
224                let _ = request.respond(json_response(204, ""));
225                continue;
226            }
227
228            // POST /__ready_probe — legacy HTTP-based readiness signal (kept for
229            // apps that serve from http:// origins where fetch works fine).
230            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            // GET /health — only returns 200 once webview JS context is confirmed working
244            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            // GET /info — returns wire-protocol and plugin metadata so the
256            // external driver can verify compatibility before issuing any
257            // commands. Always responds (no readiness gate) — the protocol
258            // version doesn't change at runtime.
259            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            // POST /command — forward a declarative command to the in-page harness
271            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                // Validate it's valid JSON (the harness will handle the rest)
282                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                // Build a script that calls the harness's execute() method
295                // and sends the result back via the existing IPC callback channel
296                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            // 404 for everything else
364            let _ = request.respond(json_response(
365                404,
366                r#"{"ok":false,"error":"Not found"}"#,
367            ));
368        }
369    });
370}
371
372/// Initialize the Phyto plugin.
373///
374/// # Usage in your Tauri app
375///
376/// ```rust,ignore
377/// tauri::Builder::default()
378///     .plugin(tauri_plugin_phyto::init(Default::default()))
379/// ```
380pub 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            // Share state so Tauri commands and HTTP server can access it
388            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            // Start the HTTP server once the main window is ready.
396            // Poll for the window with retries to handle slow WebKitGTK init
397            // (e.g. inside QEMU + Xvfb where startup can take several seconds).
398            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                    // Fallback: try any available window
406                    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}