use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::Mutex;
use std::thread::JoinHandle;
pub use crate::stealth::{self, StealthProfile};
#[derive(Debug, Clone)]
pub struct PageSnapshot {
pub url: String,
pub title: String,
pub html: String,
pub text: String,
pub verdict: String,
pub is_challenge: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum HostError {
#[error("engine thread is no longer running")]
Disconnected,
#[error("no current page — call navigate() first")]
NoPage,
#[error("engine error: {0}")]
Engine(String),
}
enum Cmd {
Navigate {
url: String,
profile: Box<StealthProfile>,
max_iter: u8,
reply: Sender<Result<PageSnapshot, HostError>>,
},
Evaluate {
js: String,
reply: Sender<Result<String, HostError>>,
},
QueryText {
selector: String,
reply: Sender<Result<Option<String>, HostError>>,
},
Shutdown,
}
pub struct EngineHandle {
tx: Mutex<Sender<Cmd>>,
thread: Option<JoinHandle<()>>,
}
impl EngineHandle {
pub fn spawn() -> Self {
let (tx, rx) = channel::<Cmd>();
let thread = std::thread::Builder::new()
.name("browser-oxide-engine".into())
.spawn(move || engine_loop(rx))
.expect("failed to spawn browser_oxide engine thread");
EngineHandle {
tx: Mutex::new(tx),
thread: Some(thread),
}
}
pub fn navigate(
&self,
url: &str,
profile: StealthProfile,
max_iter: u8,
) -> Result<PageSnapshot, HostError> {
let (reply, rx) = channel();
self.tx
.lock()
.map_err(|_| HostError::Disconnected)?
.send(Cmd::Navigate {
url: url.to_string(),
profile: Box::new(profile),
max_iter,
reply,
})
.map_err(|_| HostError::Disconnected)?;
rx.recv().map_err(|_| HostError::Disconnected)?
}
pub fn evaluate(&self, js: &str) -> Result<String, HostError> {
let (reply, rx) = channel();
self.tx
.lock()
.map_err(|_| HostError::Disconnected)?
.send(Cmd::Evaluate {
js: js.to_string(),
reply,
})
.map_err(|_| HostError::Disconnected)?;
rx.recv().map_err(|_| HostError::Disconnected)?
}
pub fn query_text(&self, selector: &str) -> Result<Option<String>, HostError> {
let (reply, rx) = channel();
self.tx
.lock()
.map_err(|_| HostError::Disconnected)?
.send(Cmd::QueryText {
selector: selector.to_string(),
reply,
})
.map_err(|_| HostError::Disconnected)?;
rx.recv().map_err(|_| HostError::Disconnected)?
}
}
impl Drop for EngineHandle {
fn drop(&mut self) {
if let Ok(tx) = self.tx.lock() {
let _ = tx.send(Cmd::Shutdown);
}
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn engine_loop(rx: Receiver<Cmd>) {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build engine runtime");
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
let mut current: Option<crate::Page> = None;
while let Ok(cmd) = rx.recv() {
match cmd {
Cmd::Shutdown => break,
Cmd::Navigate {
url,
profile,
max_iter,
reply,
} => {
let res = match crate::Page::navigate(&url, *profile, max_iter).await {
Ok(mut page) => {
let verdict = page.challenge_verdict();
let snap = PageSnapshot {
url: page.url().to_string(),
title: page.title(),
html: page.content(),
text: page.text_content(),
verdict: verdict.as_str().to_string(),
is_challenge: verdict.is_challenge(),
};
current = Some(page);
Ok(snap)
}
Err(e) => Err(HostError::Engine(e.to_string())),
};
let _ = reply.send(res);
}
Cmd::Evaluate { js, reply } => {
let res = match current.as_mut() {
Some(p) => p
.evaluate(&js)
.map_err(|e| HostError::Engine(e.to_string())),
None => Err(HostError::NoPage),
};
let _ = reply.send(res);
}
Cmd::QueryText { selector, reply } => {
let res = match current.as_mut() {
Some(p) => Ok(p.text_of(&selector)),
None => Err(HostError::NoPage),
};
let _ = reply.send(res);
}
}
}
});
}