use std::sync::Arc;
use anyhow::{Result, bail};
use serde_json::{Value, json};
use tokio::sync::{Mutex, broadcast};
use tracing::{debug, warn};
use crate::browser::BrowserSession;
use crate::channel::OutboundMessage;
#[derive(Clone)]
pub struct HostMethodRegistry {
pub notify_tx: Option<broadcast::Sender<OutboundMessage>>,
pub browser: Arc<Mutex<Option<BrowserSession>>>,
}
impl HostMethodRegistry {
pub fn new(
notify_tx: Option<broadcast::Sender<OutboundMessage>>,
browser: Arc<Mutex<Option<BrowserSession>>>,
) -> Self {
Self { notify_tx, browser }
}
pub async fn handle(&self, method: &str, params: Value) -> Result<Value> {
debug!(method, "host method dispatch");
match method {
"notify" => self.host_notify(params).await,
"notify_with_image" => self.host_notify_with_image(params).await,
"log" => self.host_log(params).await,
"browser_open" => self.host_browser_open(params).await,
"browser_eval" => self.host_browser_eval(params).await,
"browser_eval_with_args" => self.host_browser_eval_with_args(params).await,
"browser_click" => self.host_browser_click(params).await,
"browser_click_at" => self.host_browser_click_at(params).await,
"browser_fill" => self.host_browser_fill(params).await,
"browser_snapshot" => self.host_browser_snapshot(params).await,
"browser_screenshot" => self.host_browser_screenshot(params).await,
"browser_download" => self.host_browser_download(params).await,
"sleep" => self.host_sleep(params).await,
"storage_allocate_artifact" => self.host_storage_allocate_artifact(params).await,
other => bail!("unknown host method: {other}"),
}
}
async fn host_notify(&self, params: Value) -> Result<Value> {
let text = params["text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("notify: `text` required"))?
.to_owned();
let ctx = params
.get("_ctx")
.ok_or_else(|| anyhow::anyhow!("notify: `_ctx` required"))?;
let target_id = ctx["target_id"].as_str().unwrap_or("").to_owned();
let channel = ctx["channel"].as_str().unwrap_or("").to_owned();
tracing::info!(target: "shell_plugin_notify", "{text}");
let Some(tx) = &self.notify_tx else {
warn!(
"notify called but notify_tx is not configured (plugin not in agent ctx); logged only"
);
return Ok(json!({ "status": "logged_only" }));
};
let msg = OutboundMessage {
target_id,
text,
channel: if channel.is_empty() {
None
} else {
Some(channel)
},
..Default::default()
};
match tx.send(msg) {
Ok(_) => Ok(json!({ "status": "dispatched" })),
Err(_) => Ok(json!({ "status": "no_receivers" })),
}
}
async fn host_notify_with_image(&self, params: Value) -> Result<Value> {
let text = params["text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("notify_with_image: `text` required"))?
.to_owned();
let image = params["image_data_uri"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("notify_with_image: `image_data_uri` required"))?
.to_owned();
let ctx = params
.get("_ctx")
.ok_or_else(|| anyhow::anyhow!("notify_with_image: `_ctx` required"))?;
let target_id = ctx["target_id"].as_str().unwrap_or("").to_owned();
let channel = ctx["channel"].as_str().unwrap_or("").to_owned();
tracing::info!(target: "shell_plugin_notify", "{text}");
let Some(tx) = &self.notify_tx else {
warn!(
"notify_with_image called but notify_tx is not configured (plugin not in agent ctx); logged only"
);
return Ok(json!({ "status": "logged_only" }));
};
let msg = OutboundMessage {
target_id,
text,
channel: if channel.is_empty() {
None
} else {
Some(channel)
},
images: vec![image],
..Default::default()
};
match tx.send(msg) {
Ok(_) => Ok(json!({ "status": "dispatched" })),
Err(_) => Ok(json!({ "status": "no_receivers" })),
}
}
async fn host_log(&self, params: Value) -> Result<Value> {
let level = params["level"].as_str().unwrap_or("info");
let text = params["text"].as_str().unwrap_or("");
match level {
"error" => tracing::error!(target: "shell_plugin", plugin_log = true, "{text}"),
"warn" => tracing::warn!(target: "shell_plugin", plugin_log = true, "{text}"),
"debug" => tracing::debug!(target: "shell_plugin", plugin_log = true, "{text}"),
_ => tracing::info!(target: "shell_plugin", plugin_log = true, "{text}"),
}
Ok(Value::Null)
}
async fn browser_call_raw(&self, action: &str, args: Value) -> Result<Value> {
const PROFILE: &str = "rsclaw";
let mut guard = self.browser.lock().await;
if guard.is_none() {
tracing::info!("shell plugin: auto-starting browser session");
let chrome_path = crate::agent::platform::ensure_chrome()
.await
.map_err(|e| anyhow::anyhow!("failed to obtain Chrome: {e:#}"))?;
let session = BrowserSession::start(&chrome_path, true, Some(PROFILE))
.await
.map_err(|e| anyhow::anyhow!("failed to start Chrome: {e:#}"))?;
*guard = Some(session);
}
let session = guard.as_mut().expect("browser session just initialized");
session
.execute(action, &args)
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))
}
async fn browser_call(&self, action: &str, args: Value) -> Result<Value> {
let val = self.browser_call_raw(action, args).await?;
for field in &["text", "image", "data", "url", "result"] {
if let Some(s) = val.get(field).and_then(|v| v.as_str()) {
return Ok(Value::String(s.to_string()));
}
}
Ok(val)
}
async fn host_browser_open(&self, params: Value) -> Result<Value> {
let url = params["url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_open: `url` required"))?;
self.browser_call("open", json!({"url": url})).await
}
async fn host_browser_eval(&self, params: Value) -> Result<Value> {
let code = params["script"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_eval: `script` required"))?;
self.browser_call("evaluate", json!({"js": code})).await
}
async fn host_browser_eval_with_args(&self, params: Value) -> Result<Value> {
let code = params["fn"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_eval_with_args: `fn` required"))?;
let args = params.get("args").cloned().unwrap_or(Value::Null);
let args_literal = serde_json::to_string(&args).unwrap_or_else(|_| "null".to_string());
let wrapped = format!(
r#"(async function() {{
const __args = ({args_literal});
const __fn = ({code});
const __out = await __fn(__args);
return typeof __out === "string" ? __out : JSON.stringify(__out);
}})()"#
);
self.browser_call("evaluate", json!({"js": wrapped})).await
}
async fn host_browser_click(&self, params: Value) -> Result<Value> {
let element_ref = params["ref"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_click: `ref` required"))?;
self.browser_call("click", json!({"ref": element_ref}))
.await
}
async fn host_browser_click_at(&self, params: Value) -> Result<Value> {
let x = params["x"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("browser_click_at: `x` required"))?;
let y = params["y"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("browser_click_at: `y` required"))?;
self.browser_call("click_at", json!({"x": x, "y": y})).await
}
async fn host_browser_fill(&self, params: Value) -> Result<Value> {
let element_ref = params["ref"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_fill: `ref` required"))?;
let text = params["text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_fill: `text` required"))?;
self.browser_call("fill", json!({"ref": element_ref, "text": text}))
.await
}
async fn host_browser_snapshot(&self, _params: Value) -> Result<Value> {
self.browser_call("snapshot", json!({})).await
}
async fn host_browser_screenshot(&self, _params: Value) -> Result<Value> {
self.browser_call_raw("screenshot", json!({})).await
}
async fn host_browser_download(&self, params: Value) -> Result<Value> {
let url = params["url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_download: `url` required"))?;
let dest = params["dest_path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("browser_download: `dest_path` required"))?;
let mut args = json!({"ref": url, "path": dest});
if let Some(referer) = params.get("referer").and_then(|v| v.as_str()) {
args["referer"] = json!(referer);
}
self.browser_call("download", args).await
}
async fn host_sleep(&self, params: Value) -> Result<Value> {
let ms = params["ms"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("sleep: `ms` required"))?;
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
Ok(Value::Null)
}
async fn host_storage_allocate_artifact(&self, params: Value) -> Result<Value> {
let filename = params["filename"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("storage_allocate_artifact: `filename` required"))?;
let count = params
.get("count")
.and_then(|v| v.as_u64())
.unwrap_or(1)
.max(1) as usize;
match crate::plugin::wasm_runtime::allocate_dl_paths(filename, count) {
Ok(paths) => {
if count == 1 {
Ok(serde_json::json!({ "path": paths.into_iter().next().unwrap_or_default() }))
} else {
Ok(serde_json::json!({ "paths": paths }))
}
}
Err(e) => Err(anyhow::anyhow!("{e}")),
}
}
}