oxy-bridge 0.1.0

Rust-to-CEF CXX bridge for OxyBlink — safe FFI layer between Rust and Chromium Embedded Framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Bridge definitions communicating with C++ `cxx::bridge`.

#[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);

        // Phase 1
        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;

        // Phase 2 — Input
        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);

        // Phase 2 — Screenshot & Viewport
        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);

        // Phase 2 — Wait strategies
        fn wait_for_load(self: &Page) -> bool;
        fn wait_for_dom_content_loaded(self: &Page) -> bool;

        // Phase 2 — Network interception
        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);

        // Phase 3 — Stealth
        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 {
    // Phase 1
    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();
    }

    // Phase 2 — Input
    pub fn click(&self, x: i32, y: i32) {
        self.inner.mouse_click(x, y, 0); // 0 = left button
    }

    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());
    }

    // Phase 2 — Screenshot & Viewport

    /// Returns the raw BGRA pixel buffer from the last OnPaint call.
    pub fn screenshot_raw(&self) -> Vec<u8> {
        self.inner.screenshot()
    }

    /// Returns the width of the last rendered frame.
    pub fn screenshot_width(&self) -> i32 {
        self.inner.screenshot_width()
    }

    /// Returns the height of the last rendered frame.
    pub fn screenshot_height(&self) -> i32 {
        self.inner.screenshot_height()
    }

    /// Captures a screenshot and encodes it as PNG.
    /// Returns `None` if no frame has been rendered yet.
    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;
        }

        // Convert BGRA → RGBA in-place
        let mut rgba = bgra;
        for pixel in rgba.chunks_exact_mut(4) {
            pixel.swap(0, 2); // B ↔ R
        }

        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)
    }

    /// Captures a screenshot and encodes it as JPEG with a given quality (0-100).
    /// Returns `None` if no frame has been rendered yet.
    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;
        }

        // Convert BGRA → RGB (drop alpha)
        let mut rgb = Vec::with_capacity((w * h * 3) as usize);
        for pixel in bgra.chunks_exact(4) {
            rgb.push(pixel[2]); // R
            rgb.push(pixel[1]); // G
            rgb.push(pixel[0]); // B
        }

        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);
    }

    // Phase 2 — Wait strategies
    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()
    }

    // Phase 2 — Network interception

    /// Returns the number of intercepted network requests.
    pub fn network_request_count(&self) -> i32 {
        self.inner.network_request_count()
    }

    /// Returns the URL of the request at the given index.
    pub fn network_request_url(&self, index: i32) -> String {
        self.inner.network_request_url(index)
    }

    /// Returns the HTTP method of the request at the given index.
    pub fn network_request_method(&self, index: i32) -> String {
        self.inner.network_request_method(index)
    }

    /// Returns the HTTP status code of the request at the given index.
    pub fn network_request_status(&self, index: i32) -> i32 {
        self.inner.network_request_status(index)
    }

    /// Clears all recorded network requests.
    pub fn network_clear_requests(&self) {
        self.inner.network_clear_requests()
    }

    /// Returns all recorded network requests as a Vec of (url, method, status).
    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()
    }
}

/// A captured network request record.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NetworkRequest {
    pub url: String,
    pub method: String,
    pub status: i32,
}

/// A recorded session snapshot for Record & Replay (P4-004).
#[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 {
    /// Record a session snapshot: captures current URL, title, DOM HTML, and network log.
    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(),
        }
    }

    /// Serialize a recorded session to JSON.
    pub fn record_session_json(&self) -> String {
        let session = self.record_session();
        serde_json::to_string_pretty(&session).unwrap_or_default()
    }

    /// Replay a recorded session: navigates to the URL and injects the DOM snapshot.
    pub fn replay_session(&self, session: &RecordedSession) {
        self.inner.goto_url(session.url.clone());
        // Inject the recorded DOM as innerHTML of <html>
        let escaped = session.dom_snapshot.replace('\\', "\\\\").replace('`', "\\`");
        self.inner.eval(format!(
            "document.documentElement.innerHTML = `{}`;", escaped
        ));
    }
}

// Re-export stealth types for external consumers
pub use oxy_stealth::{StealthProfile, NavigatorOverrides, TlsProfile};

impl RustPage {
    // Phase 3 — Stealth integration

    /// Apply a full stealth profile: injects evasion JS AND sets User-Agent
    /// on the network hook. Should be called after navigation begins.
    /// The JS overrides navigator, canvas, WebGL, audio, WebRTC fingerprints.
    /// The User-Agent override is applied at the CEF network request level (P3-003).
    pub fn apply_stealth_profile(&self, profile: &StealthProfile) {
        // P3-003: Apply TLS profile User-Agent to CEF request handler
        self.inner.set_user_agent(profile.navigator.user_agent.clone());

        // P3-004: Inject stealth JS before page interaction
        let tz = profile.spoof_timezone.as_deref();
        let js = oxy_stealth::generate_stealth_js(
            &profile.navigator,
            profile.canvas_seed,
            tz,
        );
        self.inner.eval(js);
    }

    /// Convenience: apply a named built-in stealth profile.
    /// Supported names: "chrome_131_macos", "chrome_131_windows", "firefox_128_linux"
    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::*;

    // NOTE: These tests verify the CXX bridge compiles and the C++ constructors
    // don't crash. Full CEF initialization requires the CEF framework at runtime.
    // When CEF libs are not linked, these will exercise the C++ code paths but
    // CefInitialize will fail gracefully (printing an error), and the Page methods
    // will return "[no browser]" or similar sentinel values.

    #[test]
    fn test_engine_lifecycle() {
        let engine = RustEngine::new();
        engine.shutdown();
        // If we get here without panic/segfault, the bridge works.
    }

    #[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();
        // Without CEF runtime, goto returns false (no browser instance).
        // With CEF runtime, it returns true.
        let _result = page.goto("https://example.com");
    }

    #[test]
    fn test_dom_queries_return_strings() {
        let engine = RustEngine::new();
        let page = engine.new_page();
        // Verify these don't crash and return valid Rust Strings.
        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");
        // Should return either {"status":"executed"} or {"error":"no browser"}
        assert!(result.starts_with('{'), "eval should return JSON-like string");
    }
}