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.
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/// Per-condition probe state, used to produce a targeted remediation message
102/// when the readiness probe times out.
103///
104/// - `loc_ok` is updated from Rust each tick (via `webview.url()`), since the
105///   webview handle is held by the probe thread.
106/// - `tauri_ok` and `harness_ok` are reported from JS via the
107///   `report_probe_state` IPC command, since they can only be observed inside
108///   the page.
109/// - `failure_reason` is set once on timeout and read by `/health` so the
110///   external driver sees an actionable error instead of a perpetual
111///   `"loading"`.
112#[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: receives command results from the webview via IPC ───
132
133#[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 IPC command called by the readiness probe script.
147/// Uses Tauri IPC instead of HTTP fetch to avoid mixed-content blocks
148/// when the app runs under `tauri://localhost` (custom-protocol).
149#[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 IPC command called by the readiness probe script each tick to report
157/// the two JS-observable conditions. `loc_ok` is computed Rust-side from
158/// `webview.url()`, so it isn't an argument here.
159///
160/// This lets `start_server` format a targeted remediation message on probe
161/// timeout (e.g. "Phyto harness never loaded — confirm @phyto/vite-plugin is
162/// wired up") instead of a generic "probe timed out".
163#[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
174// ─── HTTP helpers ───────────────────────────────────────────────────
175
176fn 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
194/// Start the HTTP automation server in a background thread.
195///
196/// The server includes an IPC readiness probe: before `/health` returns 200,
197/// it verifies that the webview's Tauri IPC bridge is functional by sending
198/// a test eval and waiting for the callback. This prevents the harness from
199/// thinking the app is ready when the page hasn't fully loaded yet
200/// (common under QEMU + Xvfb where WebKitGTK init is slow).
201fn 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    // Readiness probe thread: injects JS into the webview that signals
225    // readiness via Tauri IPC (not HTTP fetch, which is blocked by
226    // mixed-content policies when the app uses custom-protocol / tauri://).
227    //
228    // The probe also tracks per-condition state so that on timeout we can
229    // surface a targeted remediation message (e.g. "webview stuck on
230    // about:blank — typically means tauri/custom-protocol isn't enabled")
231    // instead of a generic "timed out". `loc_ok` is observed Rust-side via
232    // `webview.url()`; `tauri_ok` and `harness_ok` are reported from JS via
233    // the `report_probe_state` IPC command.
234    {
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                // Observe loc_ok from Rust: cheaper and more direct than
245                // round-tripping through JS, and works even before the page
246                // can run script.
247                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                // JS reports the two conditions only it can see, then signals
255                // readiness when both the IPC bridge and the harness are up.
256                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                // Early warning: if we're still on about:blank at ~10s, log a
275                // targeted hint immediately so the failure mode is obvious in
276                // the log stream long before the 120s timeout fires.
277                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            // Timeout: pick a targeted remediation message from the last
299            // observed state and surface it via both the log and /health.
300            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 three flipped true but ipc_ready never did. Shouldn't
315                // happen in practice (JS invokes signal_ready in the same
316                // tick it sees both flags), but cover it anyway.
317                "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            // Handle CORS preflight
335            if method_str == "OPTIONS" {
336                let _ = request.respond(json_response(204, ""));
337                continue;
338            }
339
340            // POST /__ready_probe — legacy HTTP-based readiness signal (kept for
341            // apps that serve from http:// origins where fetch works fine).
342            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            // GET /health — only returns 200 once webview JS context is confirmed working.
356            // Once the probe has timed out and stored a `failure_reason`,
357            // `/health` switches from "loading" (which the driver would keep
358            // polling) to "failed" + reason, so callers get an actionable
359            // error instead of a perpetual loading state.
360            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            // GET /info — returns wire-protocol and plugin metadata so the
380            // external driver can verify compatibility before issuing any
381            // commands. Always responds (no readiness gate) — the protocol
382            // version doesn't change at runtime.
383            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            // POST /command — forward a declarative command to the in-page harness
395            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                // Validate it's valid JSON (the harness will handle the rest)
406                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                // Build a script that calls the harness's execute() method
419                // and sends the result back via the existing IPC callback channel
420                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            // 404 for everything else
488            let _ = request.respond(json_response(
489                404,
490                r#"{"ok":false,"error":"Not found"}"#,
491            ));
492        }
493    });
494}
495
496/// Initialize the Phyto plugin.
497///
498/// # Usage in your Tauri app
499///
500/// ```rust,ignore
501/// tauri::Builder::default()
502///     .plugin(tauri_plugin_phyto::init(Default::default()))
503/// ```
504pub 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            // Share state so Tauri commands and HTTP server can access it
517            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            // Start the HTTP server once the main window is ready.
527            // Poll for the window with retries to handle slow WebKitGTK init
528            // (e.g. inside QEMU + Xvfb where startup can take several seconds).
529            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                    // Fallback: try any available window
537                    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}