#[cxx::bridge(namespace = "oxyblink")]
pub mod ffi {
unsafe extern "C++" {
include!("oxyblink_bridge.h");
type Engine;
type Page;
fn new_engine() -> UniquePtr<Engine>;
fn new_page(self: &Engine) -> UniquePtr<Page>;
fn shutdown(self: &Engine);
fn goto_url(self: &Page, url: String) -> bool;
fn title(self: &Page) -> String;
fn text_content(self: &Page, selector: String) -> String;
fn inner_html(self: &Page, selector: String) -> String;
fn get_attribute(self: &Page, selector: String, attr: String) -> String;
fn eval(self: &Page, js: String) -> String;
fn close(self: &Page);
fn url(self: &Page) -> String;
fn content(self: &Page) -> String;
fn mouse_click(self: &Page, x: i32, y: i32, button: i32);
fn mouse_move(self: &Page, x: i32, y: i32);
fn key_press(self: &Page, key: String, modifiers: i32);
fn type_text(self: &Page, text: String);
fn screenshot(self: &Page) -> Vec<u8>;
fn screenshot_width(self: &Page) -> i32;
fn screenshot_height(self: &Page) -> i32;
fn set_viewport(self: &Page, width: i32, height: i32);
fn wait_for_load(self: &Page) -> bool;
fn wait_for_dom_content_loaded(self: &Page) -> bool;
fn network_request_count(self: &Page) -> i32;
fn network_request_url(self: &Page, index: i32) -> String;
fn network_request_method(self: &Page, index: i32) -> String;
fn network_request_status(self: &Page, index: i32) -> i32;
fn network_clear_requests(self: &Page);
fn set_user_agent(self: &Page, ua: String);
fn report_error(message: &CxxString);
}
}
pub struct RustEngine {
inner: cxx::UniquePtr<ffi::Engine>,
}
impl RustEngine {
pub fn new() -> Self {
Self {
inner: ffi::new_engine(),
}
}
pub fn new_page(&self) -> RustPage {
RustPage {
inner: self.inner.new_page(),
}
}
pub fn shutdown(&self) {
self.inner.shutdown();
}
}
unsafe impl Send for RustEngine {}
unsafe impl Sync for RustEngine {}
pub struct RustPage {
inner: cxx::UniquePtr<ffi::Page>,
}
impl RustPage {
pub fn goto(&self, url: &str) -> bool {
self.inner.goto_url(url.to_string())
}
pub fn title(&self) -> String {
self.inner.title()
}
pub fn text_content(&self, selector: &str) -> String {
self.inner.text_content(selector.to_string())
}
pub fn inner_html(&self, selector: &str) -> String {
self.inner.inner_html(selector.to_string())
}
pub fn get_attribute(&self, selector: &str, attr: &str) -> String {
self.inner.get_attribute(selector.to_string(), attr.to_string())
}
pub fn eval(&self, js: &str) -> String {
self.inner.eval(js.to_string())
}
pub fn close(&self) {
self.inner.close();
}
pub fn click(&self, x: i32, y: i32) {
self.inner.mouse_click(x, y, 0); }
pub fn click_button(&self, x: i32, y: i32, button: i32) {
self.inner.mouse_click(x, y, button);
}
pub fn mouse_move(&self, x: i32, y: i32) {
self.inner.mouse_move(x, y);
}
pub fn key_press(&self, key: &str) {
self.inner.key_press(key.to_string(), 0);
}
pub fn key_press_with_modifiers(&self, key: &str, modifiers: i32) {
self.inner.key_press(key.to_string(), modifiers);
}
pub fn type_text(&self, text: &str) {
self.inner.type_text(text.to_string());
}
pub fn screenshot_raw(&self) -> Vec<u8> {
self.inner.screenshot()
}
pub fn screenshot_width(&self) -> i32 {
self.inner.screenshot_width()
}
pub fn screenshot_height(&self) -> i32 {
self.inner.screenshot_height()
}
pub fn screenshot_png(&self) -> Option<Vec<u8>> {
let bgra = self.inner.screenshot();
let w = self.inner.screenshot_width();
let h = self.inner.screenshot_height();
if bgra.is_empty() || w <= 0 || h <= 0 {
return None;
}
let mut rgba = bgra;
for pixel in rgba.chunks_exact_mut(4) {
pixel.swap(0, 2); }
use image::ImageEncoder;
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
encoder
.write_image(&rgba, w as u32, h as u32, image::ColorType::Rgba8)
.ok()?;
Some(buf)
}
pub fn screenshot_jpeg(&self, quality: u8) -> Option<Vec<u8>> {
let bgra = self.inner.screenshot();
let w = self.inner.screenshot_width();
let h = self.inner.screenshot_height();
if bgra.is_empty() || w <= 0 || h <= 0 {
return None;
}
let mut rgb = Vec::with_capacity((w * h * 3) as usize);
for pixel in bgra.chunks_exact(4) {
rgb.push(pixel[2]); rgb.push(pixel[1]); rgb.push(pixel[0]); }
use image::ImageEncoder;
let mut buf = Vec::new();
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
encoder
.write_image(&rgb, w as u32, h as u32, image::ColorType::Rgb8)
.ok()?;
Some(buf)
}
pub fn set_viewport(&self, width: i32, height: i32) {
self.inner.set_viewport(width, height);
}
pub fn wait_for_load(&self) -> bool {
self.inner.wait_for_load()
}
pub fn wait_for_dom_content_loaded(&self) -> bool {
self.inner.wait_for_dom_content_loaded()
}
pub fn network_request_count(&self) -> i32 {
self.inner.network_request_count()
}
pub fn network_request_url(&self, index: i32) -> String {
self.inner.network_request_url(index)
}
pub fn network_request_method(&self, index: i32) -> String {
self.inner.network_request_method(index)
}
pub fn network_request_status(&self, index: i32) -> i32 {
self.inner.network_request_status(index)
}
pub fn network_clear_requests(&self) {
self.inner.network_clear_requests()
}
pub fn network_requests(&self) -> Vec<NetworkRequest> {
let count = self.network_request_count();
(0..count)
.map(|i| NetworkRequest {
url: self.inner.network_request_url(i),
method: self.inner.network_request_method(i),
status: self.inner.network_request_status(i),
})
.collect()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NetworkRequest {
pub url: String,
pub method: String,
pub status: i32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecordedSession {
pub timestamp: String,
pub url: String,
pub title: String,
pub dom_snapshot: String,
pub network_log: Vec<NetworkRequest>,
}
impl RustPage {
pub fn record_session(&self) -> RecordedSession {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
RecordedSession {
timestamp: now.to_string(),
url: self.inner.url(),
title: self.inner.title(),
dom_snapshot: self.inner.content(),
network_log: self.network_requests(),
}
}
pub fn record_session_json(&self) -> String {
let session = self.record_session();
serde_json::to_string_pretty(&session).unwrap_or_default()
}
pub fn replay_session(&self, session: &RecordedSession) {
self.inner.goto_url(session.url.clone());
let escaped = session.dom_snapshot.replace('\\', "\\\\").replace('`', "\\`");
self.inner.eval(format!(
"document.documentElement.innerHTML = `{}`;", escaped
));
}
}
pub use oxy_stealth::{StealthProfile, NavigatorOverrides, TlsProfile};
impl RustPage {
pub fn apply_stealth_profile(&self, profile: &StealthProfile) {
self.inner.set_user_agent(profile.navigator.user_agent.clone());
let tz = profile.spoof_timezone.as_deref();
let js = oxy_stealth::generate_stealth_js(
&profile.navigator,
profile.canvas_seed,
tz,
);
self.inner.eval(js);
}
pub fn apply_stealth_profile_named(&self, name: &str) {
let profile = match name {
"chrome_131_macos" => StealthProfile::chrome_131_macos(),
"chrome_131_windows" => StealthProfile::chrome_131_windows(),
"firefox_128_linux" => StealthProfile::firefox_128_linux(),
_ => {
eprintln!("OxyBlink: unknown stealth profile '{}', using chrome_131_macos", name);
StealthProfile::chrome_131_macos()
}
};
self.apply_stealth_profile(&profile);
}
}
unsafe impl Send for RustPage {}
unsafe impl Sync for RustPage {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_engine_lifecycle() {
let engine = RustEngine::new();
engine.shutdown();
}
#[test]
fn test_page_lifecycle() {
let engine = RustEngine::new();
let page = engine.new_page();
page.close();
}
#[test]
fn test_goto_url() {
let engine = RustEngine::new();
let page = engine.new_page();
let _result = page.goto("https://example.com");
}
#[test]
fn test_dom_queries_return_strings() {
let engine = RustEngine::new();
let page = engine.new_page();
let title = page.title();
assert!(!title.is_empty(), "title should return a non-empty string");
let content = page.text_content("body");
assert!(!content.is_empty(), "text_content should return a non-empty string");
}
#[test]
fn test_eval_returns_json() {
let engine = RustEngine::new();
let page = engine.new_page();
let result = page.eval("1 + 1");
assert!(result.starts_with('{'), "eval should return JSON-like string");
}
}