use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, oneshot};
#[path = "chrome/pending.rs"]
mod pending;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ChromePermissionMode {
Observe,
#[default]
Assist,
Control,
}
impl ChromePermissionMode {
pub fn as_str(self) -> &'static str {
match self {
ChromePermissionMode::Observe => "observe",
ChromePermissionMode::Assist => "assist",
ChromePermissionMode::Control => "control",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"observe" => Some(ChromePermissionMode::Observe),
"assist" => Some(ChromePermissionMode::Assist),
"control" => Some(ChromePermissionMode::Control),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChromeBrowser {
pub id: String,
pub name: String,
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChromeTab {
pub id: i64,
#[serde(default)]
pub window_id: Option<i64>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub fav_icon_url: Option<String>,
#[serde(default)]
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ChromeSitePermission {
pub origin: String,
#[serde(default)]
pub inspect: bool,
#[serde(default)]
pub interact: bool,
#[serde(default)]
pub eval: bool,
#[serde(default)]
pub debugger: bool,
#[serde(default)]
pub download: bool,
#[serde(default)]
pub upload: bool,
#[serde(default)]
pub recording: bool,
#[serde(default)]
pub schedule: bool,
#[serde(default)]
pub always_allow: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ChromeStatus {
pub connected: bool,
pub client_count: usize,
pub enabled: bool,
pub capabilities: Vec<String>,
pub mode: ChromePermissionMode,
#[serde(default)]
pub active_tab: Option<ChromeTab>,
#[serde(default)]
pub browser: Option<ChromeBrowser>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChromeCommand {
pub kind: String,
#[serde(default)]
pub params: serde_json::Value,
}
impl ChromeCommand {
pub fn new(kind: impl Into<String>) -> Self {
Self {
kind: kind.into(),
params: serde_json::Value::Null,
}
}
pub fn with_params(kind: impl Into<String>, params: serde_json::Value) -> Self {
Self {
kind: kind.into(),
params,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChromeError {
NotConnected,
Disabled,
Rejected(String),
Timeout,
Disconnected,
Remote(String),
}
impl std::fmt::Display for ChromeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChromeError::NotConnected => write!(f, "no Chrome extension is connected"),
ChromeError::Disabled => write!(f, "Chrome tools are not enabled for this session"),
ChromeError::Rejected(reason) => write!(f, "browser action rejected: {reason}"),
ChromeError::Timeout => write!(f, "Chrome extension did not respond in time"),
ChromeError::Disconnected => {
write!(f, "Chrome extension disconnected before responding")
}
ChromeError::Remote(message) => write!(f, "Chrome extension error: {message}"),
}
}
}
impl std::error::Error for ChromeError {}
pub const CHROME_DISPATCH_TIMEOUT: Duration = Duration::from_secs(30);
#[async_trait::async_trait]
pub trait ChromeController: Send + Sync + 'static {
fn status(&self) -> ChromeStatus;
fn is_enabled(&self) -> bool {
self.status().enabled
}
fn set_enabled(&self, enabled: bool);
fn set_mode(&self, mode: ChromePermissionMode);
async fn dispatch(&self, command: ChromeCommand) -> Result<serde_json::Value, ChromeError>;
}
pub struct ChromeClientRegistration {
pub client_id: u64,
pub commands: mpsc::UnboundedReceiver<serde_json::Value>,
}
struct ClientHandle {
commands: mpsc::UnboundedSender<serde_json::Value>,
capabilities: Vec<String>,
remote_addr: Option<String>,
active_tab: Option<ChromeTab>,
browser: Option<ChromeBrowser>,
}
#[derive(Default)]
struct BridgeState {
clients: HashMap<u64, ClientHandle>,
pending: HashMap<String, oneshot::Sender<Result<serde_json::Value, String>>>,
enabled: bool,
mode: ChromePermissionMode,
last_error: Option<String>,
}
impl BridgeState {
fn new() -> Self {
Self {
clients: HashMap::new(),
pending: HashMap::new(),
enabled: true,
mode: ChromePermissionMode::default(),
last_error: None,
}
}
}
pub struct ChromeBridge {
state: Mutex<BridgeState>,
next_client_id: AtomicU64,
next_corr_id: AtomicU64,
dispatch_timeout: Duration,
}
impl Default for ChromeBridge {
fn default() -> Self {
Self {
state: Mutex::new(BridgeState::new()),
next_client_id: AtomicU64::new(1),
next_corr_id: AtomicU64::new(1),
dispatch_timeout: CHROME_DISPATCH_TIMEOUT,
}
}
}
impl ChromeBridge {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
fn with_timeout(timeout: Duration) -> Self {
Self {
dispatch_timeout: timeout,
..Self::default()
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, BridgeState> {
self.state.lock().expect("chrome bridge mutex poisoned")
}
pub fn register_client(
&self,
remote_addr: Option<String>,
hello: &serde_json::Value,
) -> ChromeClientRegistration {
let client_id = self.next_client_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = mpsc::unbounded_channel();
let capabilities = string_array(hello.get("capabilities"));
let mut state = self.lock();
state.clients.insert(
client_id,
ClientHandle {
commands: tx,
capabilities,
remote_addr,
active_tab: None,
browser: None,
},
);
state.last_error = None;
ChromeClientRegistration {
client_id,
commands: rx,
}
}
pub fn unregister_client(&self, client_id: u64) {
let mut state = self.lock();
state.clients.remove(&client_id);
if state.clients.is_empty() {
for (_, tx) in state.pending.drain() {
let _ = tx.send(Err("extension disconnected".to_string()));
}
}
}
pub fn ingest_frame(&self, client_id: Option<u64>, frame: serde_json::Value) {
let kind = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
match kind {
"command/result" => {
let Some(id) = frame.get("id").and_then(|v| v.as_str()) else {
return;
};
let sender = {
let mut state = self.lock();
state.pending.remove(id)
};
if let Some(sender) = sender {
let ok = frame.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
let payload = if ok {
Ok(frame
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null))
} else {
Err(frame
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown extension error")
.to_string())
};
let _ = sender.send(payload);
}
}
"hello" => {
let caps = string_array(frame.get("capabilities"));
let mut state = self.lock();
if let Some(id) = client_id
&& let Some(handle) = state.clients.get_mut(&id)
{
handle.capabilities = caps;
}
}
"state" => {
let tab = frame
.get("state")
.and_then(|s| s.get("activeTab"))
.and_then(|t| serde_json::from_value::<ChromeTab>(t.clone()).ok());
let mut state = self.lock();
if let Some(id) = client_id
&& let Some(handle) = state.clients.get_mut(&id)
{
handle.active_tab = tab;
}
}
"tab/updated" => {
let tab = frame
.get("tab")
.and_then(|t| serde_json::from_value::<ChromeTab>(t.clone()).ok());
let mut state = self.lock();
if let Some(id) = client_id
&& let Some(handle) = state.clients.get_mut(&id)
{
handle.active_tab = tab;
}
}
_ => {}
}
}
fn next_correlation_id(&self) -> String {
format!("rc-{}", self.next_corr_id.fetch_add(1, Ordering::SeqCst))
}
}
#[async_trait::async_trait]
impl ChromeController for ChromeBridge {
fn status(&self) -> ChromeStatus {
let state = self.lock();
let primary = state.clients.values().next();
ChromeStatus {
connected: !state.clients.is_empty(),
client_count: state.clients.len(),
enabled: state.enabled,
capabilities: primary.map(|c| c.capabilities.clone()).unwrap_or_default(),
mode: state.mode,
active_tab: primary.and_then(|c| c.active_tab.clone()),
browser: primary.and_then(|c| c.browser.clone()),
last_error: state.last_error.clone(),
remote_addr: primary.and_then(|c| c.remote_addr.clone()),
}
}
fn set_enabled(&self, enabled: bool) {
self.lock().enabled = enabled;
}
fn set_mode(&self, mode: ChromePermissionMode) {
self.lock().mode = mode;
}
async fn dispatch(&self, command: ChromeCommand) -> Result<serde_json::Value, ChromeError> {
let corr = self.next_correlation_id();
let (res_tx, res_rx) = oneshot::channel();
let sender;
{
let mut state = self.lock();
if !state.enabled {
return Err(ChromeError::Disabled);
}
let Some(handle) = state.clients.values().next() else {
return Err(ChromeError::NotConnected);
};
let mut frame = serde_json::Map::new();
frame.insert("type".to_string(), serde_json::Value::String(command.kind));
frame.insert("id".to_string(), serde_json::Value::String(corr.clone()));
if let serde_json::Value::Object(params) = command.params {
for (key, value) in params {
if key != "type" && key != "id" {
frame.insert(key, value);
}
}
}
sender = handle.commands.clone();
if handle
.commands
.send(serde_json::Value::Object(frame))
.is_err()
{
return Err(ChromeError::Disconnected);
}
state.pending.insert(corr.clone(), res_tx);
}
let _pending = pending::Pending {
bridge: self,
corr: &corr,
sender,
};
match tokio::time::timeout(self.dispatch_timeout, res_rx).await {
Ok(Ok(Ok(result))) => Ok(result),
Ok(Ok(Err(message))) => Err(ChromeError::Remote(message)),
Ok(Err(_)) => Err(ChromeError::Disconnected),
Err(_) => Err(ChromeError::Timeout),
}
}
}
pub fn bridge() -> Arc<ChromeBridge> {
static BRIDGE: OnceLock<Arc<ChromeBridge>> = OnceLock::new();
BRIDGE.get_or_init(|| Arc::new(ChromeBridge::new())).clone()
}
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
#[path = "chrome/tests.rs"]
mod tests;