Skip to main content

tauri_plugin_phyto/
runtime.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.
19///
20/// v2 (PHY-7aatxn Chunk 2): commands now carry a required `namespace`
21/// field and the harness routes by exact namespace match instead of
22/// running a probe loop across multiple adapters. The plugin itself is
23/// payload-agnostic — it just forwards JSON between driver and webview —
24/// so the version bump is here purely as the driver-plugin compatibility
25/// gate.
26pub const PROTOCOL_VERSION: u32 = 2;
27
28/// `Cargo.toml`-defined version of this crate. Returned alongside
29/// `protocol_version` on `GET /info` so the driver can surface it in error
30/// messages when versions mismatch.
31const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
32
33/// Configuration for the Phyto automation server.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PhytoConfig {
36    /// Port for the HTTP automation server (default: 9876)
37    #[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/// Response body for command results
54#[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/// Callback result received from the webview via Tauri IPC
64#[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/// Shared state for pending command callbacks.
74/// Wrapped in Arc so both the HTTP server thread and Tauri command can share it.
75#[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/// Shared readiness flag. Managed as Tauri state so both the HTTP server
104/// thread and the `signal_ready` IPC command can access it.
105#[derive(Clone)]
106struct ReadinessFlag(Arc<AtomicBool>);
107
108/// Per-condition probe state, used to produce a targeted remediation message
109/// when the readiness probe times out.
110///
111/// - `loc_ok` is updated from Rust each tick (via `webview.url()`), since the
112///   webview handle is held by the probe thread.
113/// - `tauri_ok` and `harness_ok` are reported from JS via the
114///   `report_probe_state` IPC command, since they can only be observed inside
115///   the page.
116/// - `failure_reason` is set once on timeout and read by `/health` so the
117///   external driver sees an actionable error instead of a perpetual
118///   `"loading"`.
119#[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: receives command results from the webview via IPC ───
139
140#[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 IPC command called by the readiness probe script.
154/// Uses Tauri IPC instead of HTTP fetch to avoid mixed-content blocks
155/// when the app runs under `tauri://localhost` (custom-protocol).
156#[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 IPC command called by the readiness probe script each tick to report
164/// the two JS-observable conditions. `loc_ok` is computed Rust-side from
165/// `webview.url()`, so it isn't an argument here.
166///
167/// This lets `start_server` format a targeted remediation message on probe
168/// timeout (e.g. "Phyto harness never loaded — confirm @phyto/vite-plugin is
169/// wired up") instead of a generic "probe timed out".
170#[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
181// ─── HTTP helpers ───────────────────────────────────────────────────
182
183fn 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
201/// Start the HTTP automation server in a background thread.
202///
203/// The server includes an IPC readiness probe: before `/health` returns 200,
204/// it verifies that the webview's Tauri IPC bridge is functional by sending
205/// a test eval and waiting for the callback. This prevents the harness from
206/// thinking the app is ready when the page hasn't fully loaded yet
207/// (common under QEMU + Xvfb where WebKitGTK init is slow).
208fn 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    // Readiness probe thread: injects JS into the webview that signals
232    // readiness via Tauri IPC (not HTTP fetch, which is blocked by
233    // mixed-content policies when the app uses custom-protocol / tauri://).
234    //
235    // The probe also tracks per-condition state so that on timeout we can
236    // surface a targeted remediation message (e.g. "webview stuck on
237    // about:blank — typically means tauri/custom-protocol isn't enabled")
238    // instead of a generic "timed out". `loc_ok` is observed Rust-side via
239    // `webview.url()`; `tauri_ok` and `harness_ok` are reported from JS via
240    // the `report_probe_state` IPC command.
241    {
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                // Observe loc_ok from Rust: cheaper and more direct than
252                // round-tripping through JS, and works even before the page
253                // can run script.
254                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                // JS reports the two conditions only it can see, then signals
262                // readiness when both the IPC bridge and the harness are up.
263                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                // Early warning: if we're still on about:blank at ~10s, log a
282                // targeted hint immediately so the failure mode is obvious in
283                // the log stream long before the 120s timeout fires.
284                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            // Timeout: pick a targeted remediation message from the last
306            // observed state and surface it via both the log and /health.
307            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 three flipped true but ipc_ready never did. Shouldn't
322                // happen in practice (JS invokes signal_ready in the same
323                // tick it sees both flags), but cover it anyway.
324                "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            // Handle CORS preflight
342            if method_str == "OPTIONS" {
343                let _ = request.respond(json_response(204, ""));
344                continue;
345            }
346
347            // POST /__ready_probe — legacy HTTP-based readiness signal (kept for
348            // apps that serve from http:// origins where fetch works fine).
349            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            // GET /health — only returns 200 once webview JS context is confirmed working.
363            // Once the probe has timed out and stored a `failure_reason`,
364            // `/health` switches from "loading" (which the driver would keep
365            // polling) to "failed" + reason, so callers get an actionable
366            // error instead of a perpetual loading state.
367            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            // GET /info — returns wire-protocol and plugin metadata so the
387            // external driver can verify compatibility before issuing any
388            // commands. Always responds (no readiness gate) — the protocol
389            // version doesn't change at runtime.
390            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            // POST /command — forward a declarative command to the in-page harness
402            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                // Validate it's valid JSON (the harness will handle the rest)
413                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                // Build a script that calls the harness's execute() method
426                // and sends the result back via the existing IPC callback channel
427                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            // 404 for everything else
495            let _ = request.respond(json_response(
496                404,
497                r#"{"ok":false,"error":"Not found"}"#,
498            ));
499        }
500    });
501}
502
503/// Initialize the Phyto plugin.
504///
505/// # Usage in your Tauri app
506///
507/// ```rust,ignore
508/// tauri::Builder::default()
509///     .plugin(tauri_plugin_phyto::init(Default::default()))
510/// ```
511pub 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            // Share state so Tauri commands and HTTP server can access it
524            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            // Start the HTTP server once the main window is ready.
534            // Poll for the window with retries to handle slow WebKitGTK init
535            // (e.g. inside QEMU + Xvfb where startup can take several seconds).
536            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                    // Fallback: try any available window
544                    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}