use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime, WebviewWindow,
};
use tiny_http::{Header, Response, Server};
use uuid::Uuid;
pub const PROTOCOL_VERSION: u32 = 1;
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhytoConfig {
#[serde(default = "default_port")]
pub port: u16,
}
impl Default for PhytoConfig {
fn default() -> Self {
Self {
port: default_port(),
}
}
}
fn default_port() -> u16 {
9876
}
#[derive(Debug, Serialize)]
struct CommandResponse {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct CallbackResult {
ok: bool,
#[serde(default)]
value: Option<serde_json::Value>,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone)]
pub struct PendingCallbacks {
inner: Arc<Mutex<HashMap<String, std::sync::mpsc::Sender<CallbackResult>>>>,
}
impl PendingCallbacks {
fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
fn insert(&self, id: String, sender: std::sync::mpsc::Sender<CallbackResult>) {
self.inner.lock().unwrap().insert(id, sender);
}
fn remove(&self, id: &str) {
self.inner.lock().unwrap().remove(id);
}
fn send(&self, id: &str, result: CallbackResult) {
let callbacks = self.inner.lock().unwrap();
if let Some(sender) = callbacks.get(id) {
let _ = sender.send(result);
}
}
}
#[derive(Clone)]
struct ReadinessFlag(Arc<AtomicBool>);
#[tauri::command]
fn eval_callback(
state: tauri::State<'_, PendingCallbacks>,
id: String,
ok: bool,
value: Option<serde_json::Value>,
error: Option<String>,
) -> Result<(), String> {
let result = CallbackResult { ok, value, error };
state.send(&id, result);
Ok(())
}
#[tauri::command]
fn signal_ready(state: tauri::State<'_, ReadinessFlag>) -> Result<(), String> {
log::info!("[phyto] Readiness signal received via IPC — marking ready");
state.0.store(true, Ordering::SeqCst);
Ok(())
}
fn cors_headers() -> Vec<Header> {
vec![
Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(),
Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap(),
Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(),
Header::from_bytes("Content-Type", "application/json").unwrap(),
]
}
fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let data = body.as_bytes().to_vec();
let mut response = Response::from_data(data).with_status_code(status);
for header in cors_headers() {
response.add_header(header);
}
response
}
fn start_server<R: Runtime>(
ipc_ready: Arc<AtomicBool>,
pending: PendingCallbacks,
webview: WebviewWindow<R>,
) {
let port = {
let config = webview.state::<PhytoConfig>();
config.port
};
let addr = format!("0.0.0.0:{}", port);
let server = match Server::http(&addr) {
Ok(s) => {
log::info!("[phyto] Automation server listening on http://localhost:{}", port);
s
}
Err(e) => {
log::error!("[phyto] Failed to start automation server on {}: {}", addr, e);
return;
}
};
{
let ipc_ready = ipc_ready.clone();
let webview = webview.clone();
thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(120);
let mut probe_count = 0u32;
while Instant::now() < deadline {
let script = format!(
r#"try {{
var __phyto_loc = location.href !== 'about:blank';
var __phyto_tauri = !!window.__TAURI_INTERNALS__;
var __phyto_harness = !!window.__phyto_harness__;
var __phyto_loaded = __phyto_loc && __phyto_tauri && __phyto_harness;
if ({probe_count} % 10 === 0) console.log('[phyto-probe] loc=' + __phyto_loc + ' tauri=' + __phyto_tauri + ' harness=' + __phyto_harness + ' loaded=' + __phyto_loaded);
if (__phyto_loaded && __phyto_tauri) {{
window.__TAURI_INTERNALS__.invoke('plugin:phyto|signal_ready');
}}
}} catch(e) {{ if ({probe_count} % 10 === 0) console.log('[phyto-probe] error: ' + e.message); }}"#,
probe_count = probe_count,
);
let _ = webview.eval(&script);
probe_count += 1;
thread::sleep(Duration::from_millis(500));
if ipc_ready.load(Ordering::SeqCst) {
log::info!("[phyto] Readiness probe succeeded after {} probes", probe_count);
return;
}
}
log::error!("[phyto] Readiness probe timed out after 120s ({} probes)", probe_count);
});
}
thread::spawn(move || {
for mut request in server.incoming_requests() {
let method_str = request.method().to_string();
let url = request.url().to_string();
if method_str == "OPTIONS" {
let _ = request.respond(json_response(204, ""));
continue;
}
if method_str == "POST" && url == "/__ready_probe" {
let mut probe_body = String::new();
let _ = request.as_reader().read_to_string(&mut probe_body);
let is_loaded = probe_body.contains("\"loaded\":true");
if is_loaded {
log::info!("[phyto] Readiness probe received loaded=true via HTTP — marking ready");
ipc_ready.store(true, Ordering::SeqCst);
}
let _ = request.respond(json_response(200, r#"{"ok":true}"#));
continue;
}
if method_str == "GET" && url == "/health" {
if ipc_ready.load(Ordering::SeqCst) {
let body = serde_json::json!({ "status": "ok" }).to_string();
let _ = request.respond(json_response(200, &body));
} else {
let body = serde_json::json!({ "status": "loading" }).to_string();
let _ = request.respond(json_response(503, &body));
}
continue;
}
if method_str == "GET" && url == "/info" {
let body = serde_json::json!({
"protocol_version": PROTOCOL_VERSION,
"plugin_version": PLUGIN_VERSION,
"plugin": "tauri-plugin-phyto",
})
.to_string();
let _ = request.respond(json_response(200, &body));
continue;
}
if method_str == "POST" && url == "/command" {
let mut body = String::new();
if request.as_reader().read_to_string(&mut body).is_err() {
let _ = request.respond(json_response(
400,
r#"{"ok":false,"error":"Failed to read request body"}"#,
));
continue;
}
let command_json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
let err = serde_json::json!({
"ok": false,
"error": format!("Invalid JSON: {}", e)
});
let _ = request.respond(json_response(400, &err.to_string()));
continue;
}
};
let callback_id = Uuid::new_v4().to_string();
let (tx, rx) = std::sync::mpsc::channel::<CallbackResult>();
pending.insert(callback_id.clone(), tx);
let command_str = serde_json::to_string(&command_json).unwrap();
let wrapped_script = format!(
r#"(async () => {{
const __phyto_id = "{}";
try {{
if (!window.__phyto_harness__) {{
throw new Error("Phyto harness not available — is the vite plugin installed?");
}}
const __phyto_result = await window.__phyto_harness__.execute({});
await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
id: __phyto_id,
ok: true,
value: __phyto_result,
error: null
}});
}} catch (__phyto_err) {{
await window.__TAURI_INTERNALS__.invoke('plugin:phyto|eval_callback', {{
id: __phyto_id,
ok: false,
value: null,
error: __phyto_err.message || String(__phyto_err)
}});
}}
}})()"#,
callback_id, command_str
);
let webview_clone = webview.clone();
let eval_result = webview_clone.eval(&wrapped_script);
if let Err(e) = eval_result {
pending.remove(&callback_id);
let err = serde_json::json!({
"ok": false,
"error": format!("Failed to evaluate command in webview: {}", e)
});
let _ = request.respond(json_response(500, &err.to_string()));
continue;
}
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(result) => {
pending.remove(&callback_id);
let response = CommandResponse {
ok: result.ok,
value: result.value,
error: result.error,
};
let body = serde_json::to_string(&response).unwrap();
let _ = request.respond(json_response(200, &body));
}
Err(_) => {
pending.remove(&callback_id);
let err = serde_json::json!({
"ok": false,
"error": "Command timed out after 30 seconds"
});
let _ = request.respond(json_response(500, &err.to_string()));
}
}
continue;
}
let _ = request.respond(json_response(
404,
r#"{"ok":false,"error":"Not found"}"#,
));
}
});
}
pub fn init<R: Runtime>(config: PhytoConfig) -> TauriPlugin<R> {
Builder::new("phyto")
.invoke_handler(tauri::generate_handler![eval_callback, signal_ready])
.setup(move |app, _api| {
let pending = PendingCallbacks::new();
let ipc_ready = Arc::new(AtomicBool::new(false));
app.manage(pending.clone());
app.manage(ReadinessFlag(ipc_ready.clone()));
app.manage(config.clone());
let app_handle = app.clone();
let pending_clone = pending.clone();
thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(window) = app_handle.get_webview_window("main") {
start_server(ipc_ready, pending_clone, window);
return;
}
let windows = app_handle.webview_windows();
if let Some((_label, window)) = windows.into_iter().next() {
start_server(ipc_ready, pending_clone, window);
return;
}
if Instant::now() >= deadline {
log::error!("[phyto] No webview window found after 10s — automation server not started");
return;
}
thread::sleep(Duration::from_millis(250));
}
});
Ok(())
})
.build()
}