Skip to main content

rdesktop_cef/
renderer.rs

1//! Chrome/Chromium backend via Chrome DevTools Protocol (CDP).
2//!
3//! Launches Chrome in headless mode, captures screenshots via CDP,
4//! and renders them to native tao windows using Windows GDI.
5
6use std::cell::RefCell;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use rdesktop_core::config::{AppConfig, WindowConfig};
11use rdesktop_core::ipc::{IpcHandler, IpcMessage};
12use rdesktop_core::renderer::{Renderer, RendererKind, ResizeEdge};
13use rdesktop_core::window::WindowHandle;
14use rdesktop_core::Result;
15
16use chromiumoxide::browser::{Browser, BrowserConfig};
17use chromiumoxide::cdp::browser_protocol::input::{
18    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
19    MouseButton,
20};
21use chromiumoxide::handler::viewport::Viewport;
22use chromiumoxide::page::Page;
23use futures::StreamExt;
24use tao::event::{ElementState, Event, StartCause, WindowEvent};
25use tao::event_loop::{ControlFlow, EventLoopBuilder};
26use tao::window::{Window, WindowBuilder, WindowId};
27use tokio::runtime::Runtime;
28
29// ── Windows GDI FFI ──────────────────────────────────────────────
30#[cfg(target_os = "windows")]
31#[allow(non_snake_case)]
32mod gdi {
33    use std::ffi::c_void;
34
35    #[repr(C)]
36    pub struct BITMAPINFOHEADER {
37        pub biSize: u32,
38        pub biWidth: i32,
39        pub biHeight: i32,
40        pub biPlanes: u16,
41        pub biBitCount: u16,
42        pub biCompression: u32,
43        pub biSizeImage: u32,
44        pub biXPelsPerMeter: i32,
45        pub biYPelsPerMeter: i32,
46        pub biClrUsed: u32,
47        pub biClrImportant: u32,
48    }
49
50    #[repr(C)]
51    pub struct RECT {
52        pub left: i32,
53        pub top: i32,
54        pub right: i32,
55        pub bottom: i32,
56    }
57
58    pub const BI_RGB: u32 = 0;
59    pub const DIB_RGB_COLORS: u32 = 0;
60    pub const SRCCOPY: u32 = 0x00CC0020;
61
62    pub type HWND = *mut c_void;
63    pub type HDC = *mut c_void;
64
65    #[link(name = "user32")]
66    extern "system" {
67        pub fn GetDC(hwnd: HWND) -> HDC;
68        pub fn ReleaseDC(hwnd: HWND, hdc: HDC) -> i32;
69        pub fn GetClientRect(hwnd: HWND, rect: *mut RECT) -> i32;
70    }
71
72    #[link(name = "gdi32")]
73    extern "system" {
74        pub fn StretchDIBits(
75            hdc: HDC,
76            x_dest: i32,
77            y_dest: i32,
78            dest_width: i32,
79            dest_height: i32,
80            x_src: i32,
81            y_src: i32,
82            src_width: i32,
83            src_height: i32,
84            bits: *const c_void,
85            bits_info: *const c_void,
86            usage: u32,
87            rop: u32,
88        ) -> i32;
89    }
90}
91// ─────────────────────────────────────────────────────────────────
92
93struct ChromePage {
94    page: Page,
95    width: u32,
96    height: u32,
97    pixels: Vec<u8>, // BGRA
98    mouse_pos: (f64, f64),
99}
100
101enum PendingOp {
102    LoadUrl(u64, String),
103    LoadHtml(u64, String),
104    EvalScript(u64, String),
105    SendToFrontend(u64, String),
106}
107
108pub struct CefRenderer {
109    _config: AppConfig,
110    pending_pages: RefCell<Vec<(u64, WindowConfig)>>,
111    pending_ops: RefCell<Vec<PendingOp>>,
112    next_id: RefCell<u64>,
113    ipc_handler: Option<Arc<dyn IpcHandler>>,
114    mod_shift: RefCell<bool>,
115    mod_caps: RefCell<bool>,
116    screenshot_sink: Option<Arc<dyn Fn(&[u8], u32, u32) + Send + Sync>>,
117    /// External outbox for native → frontend pushes (e.g. a Node extension
118    /// host, or global hotkey / global input events). Drained every frame by
119    /// the event loop, same contract as the WebView backend.
120    outbox: Arc<Mutex<Vec<String>>>,
121}
122
123impl CefRenderer {
124    pub fn new(config: &AppConfig) -> Result<Self> {
125        Ok(Self {
126            _config: config.clone(),
127            pending_pages: RefCell::new(Vec::new()),
128            pending_ops: RefCell::new(Vec::new()),
129            next_id: RefCell::new(1),
130            ipc_handler: None,
131            mod_shift: RefCell::new(false),
132            mod_caps: RefCell::new(false),
133            screenshot_sink: None,
134            outbox: Arc::new(Mutex::new(Vec::new())),
135        })
136    }
137
138    /// Publish each complete CDP PNG frame to a native Agent or test sink.
139    /// The callback receives immutable bytes and the decoded frame dimensions.
140    pub fn set_screenshot_sink(&mut self, sink: Arc<dyn Fn(&[u8], u32, u32) + Send + Sync>) {
141        self.screenshot_sink = Some(sink);
142    }
143
144    /// Attach an external outbox so other runtimes (e.g. a Node extension host,
145    /// or the global hotkey / input managers) can push messages to the
146    /// frontend. Each entry is a JSON string emitted as
147    /// `window.__RDESKTOP_IPC__(<json>)`.
148    pub fn set_outbox(&mut self, outbox: Arc<Mutex<Vec<String>>>) {
149        self.outbox = outbox;
150    }
151
152    fn alloc_id(&self) -> u64 {
153        let mut id = self.next_id.borrow_mut();
154        let v = *id;
155        *id += 1;
156        v
157    }
158
159    fn find_chrome() -> Option<String> {
160        let lad = std::env::var("LOCALAPPDATA").unwrap_or_default();
161        let cands: Vec<String> = if cfg!(target_os = "windows") {
162            vec![
163                r"C:\Program Files\Google\Chrome\Application\chrome.exe".into(),
164                r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe".into(),
165                r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe".into(),
166                format!(r"{}\Google\Chrome\Application\chrome.exe", lad),
167            ]
168        } else if cfg!(target_os = "macos") {
169            vec![
170                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".into(),
171                "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge".into(),
172            ]
173        } else {
174            vec![
175                "/usr/bin/google-chrome".into(),
176                "/usr/bin/chromium".into(),
177                "/usr/bin/chromium-browser".into(),
178            ]
179        };
180        cands.into_iter().find(|p| std::path::Path::new(p).exists())
181    }
182
183    fn bridge_script() -> &'static str {
184        r#"
185        (function() {
186            if (window.__RDESKTOP_BRIDGE__) return;
187            window.__RDESKTOP_BRIDGE__ = true;
188            window.__RDESKTOP_RESOLVE__ = {};
189            window.__RDESKTOP_QUEUE__ = [];
190            window.__RDESKTOP_INVOKE__ = function(cmd, payload) {
191                return new Promise(function(resolve, reject) {
192                    var id = Math.random().toString(36).slice(2);
193                    window.__RDESKTOP_RESOLVE__[id] = resolve;
194                    window.__RDESKTOP_QUEUE__.push({ id: id, cmd: cmd, payload: payload || {} });
195                    setTimeout(function() {
196                        if (window.__RDESKTOP_RESOLVE__[id]) {
197                            delete window.__RDESKTOP_RESOLVE__[id];
198                            reject(new Error('IPC timeout'));
199                        }
200                    }, 30000);
201                });
202            };
203            window.__rdesktop_take__ = function() {
204                var q = window.__RDESKTOP_QUEUE__ || [];
205                window.__RDESKTOP_QUEUE__ = [];
206                return JSON.stringify(q);
207            };
208            window.__RDESKTOP_IPC__ = function(message) {
209                try {
210                    var data = typeof message === 'string' ? JSON.parse(message) : message;
211                    if (data.id && window.__RDESKTOP_RESOLVE__[data.id]) {
212                        window.__RDESKTOP_RESOLVE__[data.id](data);
213                        delete window.__RDESKTOP_RESOLVE__[data.id];
214                    } else if (window.__RDESKTOP_PUSH__) {
215                        // Unnamed push (e.g. extension host → UI event, or a
216                        // global hotkey / global input event).
217                        window.__RDESKTOP_PUSH__(data);
218                    }
219                } catch (e) { console.error('rdesktop IPC error:', e); }
220            };
221            window.__RDESKTOP_WINDOW__ = {
222                minimize: function() {},
223                maximize: function() {},
224                close: function() { window.close(); },
225                startDrag: function() {},
226                startResize: function() {},
227                setFullscreen: function() {},
228                isMaximized: false,
229                isFullscreen: false
230            };
231        })();
232        "#
233    }
234
235    fn decode_png(png_bytes: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
236        let dec = png::Decoder::new(std::io::Cursor::new(png_bytes));
237        let mut reader = dec.read_info().ok()?;
238        let mut buf = vec![0u8; reader.output_buffer_size().unwrap_or(0)];
239        let info = reader.next_frame(&mut buf).ok()?;
240        let (w, h) = (info.width, info.height);
241        let rgba = &buf[..info.buffer_size()];
242        let mut bgra = Vec::with_capacity((w * h * 4) as usize);
243        for c in rgba.chunks_exact(4) {
244            bgra.extend_from_slice(&[c[2], c[1], c[0], 255]);
245        }
246        Some((w, h, bgra))
247    }
248
249    #[cfg(target_os = "windows")]
250    fn blit(window: &Window, pixels: &[u8], w: u32, h: u32) {
251        use gdi::*;
252        use tao::platform::windows::WindowExtWindows;
253
254        let hwnd = window.hwnd() as HWND;
255        unsafe {
256            let hdc = GetDC(hwnd);
257            if hdc.is_null() {
258                return;
259            }
260
261            let bmi = BITMAPINFOHEADER {
262                biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
263                biWidth: w as i32,
264                biHeight: -(h as i32), // top-down
265                biPlanes: 1,
266                biBitCount: 32,
267                biCompression: BI_RGB,
268                biSizeImage: 0,
269                biXPelsPerMeter: 0,
270                biYPelsPerMeter: 0,
271                biClrUsed: 0,
272                biClrImportant: 0,
273            };
274
275            let mut rect = RECT {
276                left: 0,
277                top: 0,
278                right: 0,
279                bottom: 0,
280            };
281            GetClientRect(hwnd, &mut rect);
282
283            StretchDIBits(
284                hdc,
285                0,
286                0,
287                rect.right,
288                rect.bottom,
289                0,
290                0,
291                w as i32,
292                h as i32,
293                pixels.as_ptr() as *const _,
294                &bmi as *const _ as *const _,
295                DIB_RGB_COLORS,
296                SRCCOPY,
297            );
298
299            ReleaseDC(hwnd, hdc);
300        }
301    }
302
303    #[cfg(not(target_os = "windows"))]
304    fn blit(_window: &Window, _pixels: &[u8], _w: u32, _h: u32) {
305        tracing::warn!("Chrome GDI rendering: Windows only. Other platforms pending.");
306    }
307}
308
309impl Renderer for CefRenderer {
310    fn init(&mut self) -> Result<()> {
311        Ok(())
312    }
313
314    fn create_window(&mut self, config: &WindowConfig) -> Result<WindowHandle> {
315        let id = self.alloc_id();
316        self.pending_pages.borrow_mut().push((id, config.clone()));
317        Ok(WindowHandle::new(id))
318    }
319
320    fn load_url(&self, w: WindowHandle, url: &str) -> Result<()> {
321        self.pending_ops
322            .borrow_mut()
323            .push(PendingOp::LoadUrl(w.id(), url.into()));
324        Ok(())
325    }
326    fn load_html(&self, w: WindowHandle, html: &str) -> Result<()> {
327        self.pending_ops
328            .borrow_mut()
329            .push(PendingOp::LoadHtml(w.id(), html.into()));
330        Ok(())
331    }
332    fn eval_script(&self, w: WindowHandle, script: &str) -> Result<()> {
333        self.pending_ops
334            .borrow_mut()
335            .push(PendingOp::EvalScript(w.id(), script.into()));
336        Ok(())
337    }
338    fn set_ipc_handler(&mut self, h: Box<dyn IpcHandler>) {
339        self.ipc_handler = Some(Arc::from(h));
340    }
341    fn send_to_frontend(&self, w: WindowHandle, msg: &str) -> Result<()> {
342        self.pending_ops
343            .borrow_mut()
344            .push(PendingOp::SendToFrontend(w.id(), msg.into()));
345        Ok(())
346    }
347    fn set_title(&self, _w: WindowHandle, _t: &str) -> Result<()> {
348        Ok(())
349    }
350    fn set_size(&self, _w: WindowHandle, _x: u32, _y: u32) -> Result<()> {
351        Ok(())
352    }
353    fn set_resizable(&self, _w: WindowHandle, _r: bool) -> Result<()> {
354        Ok(())
355    }
356    fn set_visible(&self, _w: WindowHandle, _v: bool) -> Result<()> {
357        Ok(())
358    }
359    fn close_window(&mut self, _w: WindowHandle) -> Result<()> {
360        Ok(())
361    }
362    fn minimize_window(&self, _w: WindowHandle) -> Result<()> {
363        Ok(())
364    }
365    fn maximize_window(&self, _w: WindowHandle) -> Result<()> {
366        Ok(())
367    }
368    fn is_maximized(&self, _w: WindowHandle) -> Result<bool> {
369        Ok(false)
370    }
371    fn set_fullscreen(&self, _w: WindowHandle, _f: bool) -> Result<()> {
372        Ok(())
373    }
374    fn is_fullscreen(&self, _w: WindowHandle) -> Result<bool> {
375        Ok(false)
376    }
377    fn start_drag(&self, _w: WindowHandle) -> Result<()> {
378        Ok(())
379    }
380    fn start_resize(&self, _w: WindowHandle, _e: ResizeEdge) -> Result<()> {
381        Ok(())
382    }
383    fn set_decorations(&self, _w: WindowHandle, _d: bool) -> Result<()> {
384        Ok(())
385    }
386    fn set_always_on_top(&self, _w: WindowHandle, _a: bool) -> Result<()> {
387        Ok(())
388    }
389
390    fn run(self: Box<Self>) -> Result<()> {
391        tracing::info!("Chrome renderer starting");
392
393        let chrome_path = Self::find_chrome()
394            .ok_or_else(|| rdesktop_core::RdesktopError::Cef("Chrome not found".into()))?;
395
396        let pending_pages = self
397            .pending_pages
398            .borrow_mut()
399            .drain(..)
400            .collect::<Vec<_>>();
401        let pending_ops = self.pending_ops.borrow_mut().drain(..).collect::<Vec<_>>();
402
403        // External outbox for native → frontend pushes (Node extension host, etc.)
404        let outbox_for_loop = self.outbox.clone();
405
406        // ── Phase 2: global hotkeys & input hooks ───────────────────────
407        // Wired through the shared outbox so the frontend receives them as
408        // `window.__RDESKTOP_PUSH__` events (`rdesktop.globalHotkey` /
409        // `rdesktop.globalInput`). Managers live for the whole event loop.
410        let global_handler = rdesktop_core::PushHandler::new(self.outbox.clone());
411        let _hotkey_manager = {
412            let mgr = rdesktop_core::HotkeyManager::new(global_handler.clone());
413            for (i, hc) in self._config.hotkeys.iter().enumerate() {
414                if let Ok(hk) = hc.combo.parse::<rdesktop_core::Hotkey>() {
415                    let id = i as u32 + 1;
416                    if let Err(e) = mgr.register(id, &hk) {
417                        tracing::warn!("failed to register hotkey {:?}: {}", hc.combo, e);
418                    }
419                } else {
420                    tracing::warn!("invalid hotkey combo: {:?}", hc.combo);
421                }
422            }
423            mgr
424        };
425        let _input_manager = if self._config.global_input.enabled {
426            let mut inp = rdesktop_core::GlobalInput::new(global_handler.clone());
427            if self._config.global_input.mouse_move {
428                inp = inp.with_mouse_move(true);
429            }
430            match inp.start() {
431                Ok(()) => Some(inp),
432                Err(e) => {
433                    tracing::warn!("failed to start global input: {}", e);
434                    None
435                }
436            }
437        } else {
438            None
439        };
440
441        let rt = Runtime::new().map_err(|e| rdesktop_core::RdesktopError::Cef(format!("{}", e)))?;
442
443        let (viewport_width, viewport_height) = pending_pages
444            .first()
445            .map(|(_, config)| (config.width, config.height))
446            .unwrap_or((800, 600));
447        let cfg = BrowserConfig::builder()
448            .chrome_executable(&chrome_path)
449            .no_sandbox()
450            .new_headless_mode()
451            .window_size(viewport_width, viewport_height)
452            .viewport(Viewport {
453                width: viewport_width,
454                height: viewport_height,
455                device_scale_factor: Some(1.0),
456                emulating_mobile: false,
457                is_landscape: viewport_width >= viewport_height,
458                has_touch: false,
459            })
460            .build()
461            .map_err(|e| rdesktop_core::RdesktopError::Cef(format!("{}", e)))?;
462
463        let (browser, mut handler) = rt
464            .block_on(async { Browser::launch(cfg).await })
465            .map_err(|e| rdesktop_core::RdesktopError::Cef(format!("{}", e)))?;
466
467        rt.spawn(async move { while let Some(_) = handler.next().await {} });
468
469        let screenshot_sink = self.screenshot_sink.clone();
470
471        // Create Chrome pages
472        let mut pages: Vec<(u64, ChromePage)> = Vec::new();
473        for (rd_id, wc) in &pending_pages {
474            let page = rt
475                .block_on(async { browser.new_page("about:blank").await })
476                .map_err(|e| rdesktop_core::RdesktopError::Cef(format!("{}", e)))?;
477
478            rt.block_on(async {
479                let _ = page.evaluate(Self::bridge_script()).await;
480            });
481
482            let screenshot = rt.block_on(async {
483                use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams;
484                page.screenshot(CaptureScreenshotParams::builder().build())
485                    .await
486                    .ok()
487            });
488            let (width, height, pixels) = screenshot
489                .as_deref()
490                .and_then(Self::decode_png)
491                .unwrap_or_else(|| {
492                    (
493                        wc.width,
494                        wc.height,
495                        vec![0u8; (wc.width * wc.height * 4) as usize],
496                    )
497                });
498            if let (Some(sink), Some(bytes)) = (screenshot_sink.as_ref(), screenshot.as_deref()) {
499                sink(bytes, width, height);
500            }
501
502            pages.push((
503                *rd_id,
504                ChromePage {
505                    page,
506                    width,
507                    height,
508                    pixels,
509                    mouse_pos: (0.0, 0.0),
510                },
511            ));
512        }
513
514        // Process pending ops
515        for op in &pending_ops {
516            let page = pages
517                .iter()
518                .find(|(id, _)| match op {
519                    PendingOp::LoadUrl(i, _)
520                    | PendingOp::LoadHtml(i, _)
521                    | PendingOp::EvalScript(i, _)
522                    | PendingOp::SendToFrontend(i, _) => id == i,
523                })
524                .map(|(_, p)| &p.page);
525            let Some(page) = page else { continue };
526            match op {
527                PendingOp::LoadUrl(_, url) => {
528                    rt.block_on(async {
529                        let _ = page.goto(url.as_str()).await;
530                    });
531                }
532                PendingOp::LoadHtml(_, html) => {
533                    rt.block_on(async {
534                        let _ = page.set_content(html.as_str()).await;
535                    });
536                }
537                PendingOp::EvalScript(_, script) => {
538                    rt.block_on(async {
539                        let _ = page.evaluate(script.as_str()).await;
540                    });
541                }
542                PendingOp::SendToFrontend(_, msg) => {
543                    if let Ok(js) = serde_json::to_string(msg) {
544                        let s = format!("window.__RDESKTOP_IPC__({js})");
545                        rt.block_on(async {
546                            let _ = page.evaluate(s.as_str()).await;
547                        });
548                    }
549                }
550            }
551        }
552
553        // Enter tao event loop
554        let event_loop = EventLoopBuilder::new().build();
555        let mut tao_windows: HashMap<WindowId, (Window, u64)> = HashMap::new();
556
557        event_loop.run(move |event, el_target, cf| {
558            *cf = ControlFlow::WaitUntil(
559                std::time::Instant::now() + std::time::Duration::from_millis(33),
560            );
561
562            match event {
563                Event::NewEvents(StartCause::Init) => {
564                    for (rd_id, wc) in &pending_pages {
565                        let window = match WindowBuilder::new()
566                            .with_title(&wc.title)
567                            .with_inner_size(tao::dpi::LogicalSize::new(wc.width, wc.height))
568                            .with_resizable(wc.resizable)
569                            .with_decorations(wc.decorations)
570                            .with_transparent(wc.transparent)
571                            .with_always_on_top(wc.always_on_top)
572                            .with_window_icon(rdesktop_core::window_icon(wc))
573                            .build(el_target)
574                        {
575                            Ok(w) => w,
576                            Err(e) => {
577                                tracing::error!("Window: {}", e);
578                                continue;
579                            }
580                        };
581
582                        if let Some((_, p)) = pages.iter().find(|(id, _)| id == rd_id) {
583                            Self::blit(&window, &p.pixels, p.width, p.height);
584                        }
585
586                        let tao_id = window.id();
587
588                        // Realize wallpaper/overlay/click-through window attributes.
589                        rdesktop_core::apply_window_attributes(&window, wc);
590
591                        tao_windows.insert(tao_id, (window, *rd_id));
592                        tracing::info!(rd_id = rd_id, ?tao_id, "Chrome window created");
593                    }
594                }
595
596                Event::WindowEvent {
597                    event: WindowEvent::CursorMoved { position, .. },
598                    window_id,
599                    ..
600                } => {
601                    if let Some((_, rd_id)) = tao_windows.get(&window_id) {
602                        if let Some((_, p)) = pages.iter_mut().find(|(id, _)| id == rd_id) {
603                            p.mouse_pos = (position.x, position.y);
604                            let params = DispatchMouseEventParams::builder()
605                                .r#type(DispatchMouseEventType::MouseMoved)
606                                .x(position.x)
607                                .y(position.y)
608                                .build()
609                                .unwrap();
610                            rt.block_on(async {
611                                let _ = p.page.execute(params).await;
612                            });
613                        }
614                    }
615                }
616
617                Event::WindowEvent {
618                    event:
619                        WindowEvent::MouseInput {
620                            state: bs, button, ..
621                        },
622                    window_id,
623                    ..
624                } => {
625                    if let Some((_, rd_id)) = tao_windows.get(&window_id) {
626                        if let Some((_, p)) = pages.iter().find(|(id, _)| id == rd_id) {
627                            let mt = match bs {
628                                ElementState::Pressed => DispatchMouseEventType::MousePressed,
629                                ElementState::Released => DispatchMouseEventType::MouseReleased,
630                                _ => DispatchMouseEventType::MouseReleased,
631                            };
632                            let mb = match button {
633                                tao::event::MouseButton::Left => MouseButton::Left,
634                                tao::event::MouseButton::Right => MouseButton::Right,
635                                tao::event::MouseButton::Middle => MouseButton::Middle,
636                                _ => return,
637                            };
638                            let params = DispatchMouseEventParams::builder()
639                                .r#type(mt)
640                                .x(p.mouse_pos.0)
641                                .y(p.mouse_pos.1)
642                                .button(mb)
643                                .build()
644                                .unwrap();
645                            rt.block_on(async {
646                                let _ = p.page.execute(params).await;
647                            });
648                        }
649                    }
650                }
651
652                Event::WindowEvent {
653                    event: WindowEvent::MouseWheel { delta, .. },
654                    window_id,
655                    ..
656                } => {
657                    if let Some((_, rd_id)) = tao_windows.get(&window_id) {
658                        if let Some((_, p)) = pages.iter().find(|(id, _)| id == rd_id) {
659                            let (dx, dy) = match delta {
660                                tao::event::MouseScrollDelta::LineDelta(dx, dy) => {
661                                    (dx as f64 * 50.0, dy as f64 * 50.0)
662                                }
663                                tao::event::MouseScrollDelta::PixelDelta(pos) => (pos.x, pos.y),
664                                _ => (0.0, 0.0),
665                            };
666                            let params = DispatchMouseEventParams::builder()
667                                .r#type(DispatchMouseEventType::MouseWheel)
668                                .x(p.mouse_pos.0)
669                                .y(p.mouse_pos.1)
670                                .delta_x(dx)
671                                .delta_y(dy)
672                                .build()
673                                .unwrap();
674                            rt.block_on(async {
675                                let _ = p.page.execute(params).await;
676                            });
677                        }
678                    }
679                }
680
681                Event::WindowEvent {
682                    event:
683                        WindowEvent::KeyboardInput {
684                            event: key_event, ..
685                        },
686                    window_id,
687                    ..
688                } => {
689                    if let Some((_, rd_id)) = tao_windows.get(&window_id) {
690                        if let Some((_, p)) = pages.iter().find(|(id, _)| id == rd_id) {
691                            let ts = match key_event.state {
692                                ElementState::Pressed => DispatchKeyEventType::KeyDown,
693                                ElementState::Released => DispatchKeyEventType::KeyUp,
694                                _ => DispatchKeyEventType::KeyUp,
695                            };
696                            let physical = format!("{:?}", key_event.physical_key);
697
698                            // Track modifier state so CDP receives the correct
699                            // character in `text` (e.g. Shift+1 => "!", Shift+a => "A").
700                            if physical == "ShiftLeft" || physical == "ShiftRight" {
701                                *self.mod_shift.borrow_mut() =
702                                    matches!(ts, DispatchKeyEventType::KeyDown);
703                            } else if physical == "CapsLock"
704                                && matches!(ts, DispatchKeyEventType::KeyDown)
705                            {
706                                let mut c = self.mod_caps.borrow_mut();
707                                *c = !*c;
708                            }
709                            let shift = *self.mod_shift.borrow();
710                            let caps = *self.mod_caps.borrow();
711
712                            let (key_text, text_opt) = cdp_key_event(&physical, shift, caps);
713                            let params_builder = DispatchKeyEventParams::builder()
714                                .r#type(ts)
715                                .key(key_text.clone())
716                                .code(physical.clone());
717                            let params = if let Some(t) = text_opt {
718                                params_builder.text(t).build().unwrap()
719                            } else {
720                                params_builder.build().unwrap()
721                            };
722                            rt.block_on(async {
723                                let _ = p.page.execute(params).await;
724                            });
725                        }
726                    }
727                }
728
729                Event::WindowEvent {
730                    event: WindowEvent::CloseRequested,
731                    window_id,
732                    ..
733                } => {
734                    if let Some((_, rd_id)) = tao_windows.remove(&window_id) {
735                        if let Some(idx) = pages.iter().position(|(id, _)| *id == rd_id) {
736                            let (_, p) = pages.remove(idx);
737                            rt.block_on(async {
738                                let _ = p.page.close().await;
739                            });
740                        }
741                    }
742                    if tao_windows.is_empty() {
743                        *cf = ControlFlow::Exit;
744                    }
745                }
746
747                Event::MainEventsCleared => {
748                    // Capture new screenshots and render
749                    for (_, p) in pages.iter_mut() {
750                        use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams;
751                        if let Ok(bytes) = rt.block_on(async {
752                            p.page
753                                .screenshot(CaptureScreenshotParams::builder().build())
754                                .await
755                        }) {
756                            if let Some((w, h, bgra)) = Self::decode_png(&bytes) {
757                                if let Some(sink) = screenshot_sink.as_ref() {
758                                    sink(&bytes, w, h);
759                                }
760                                p.pixels = bgra;
761                                p.width = w;
762                                p.height = h;
763                            }
764                        }
765                    }
766
767                    // Frontend → backend IPC: drain queued invokes and dispatch to the handler
768                    if let Some(handler) = self.ipc_handler.as_ref() {
769                        for (_, p) in pages.iter() {
770                            if let Ok(value) =
771                                rt.block_on(p.page.evaluate("window.__rdesktop_take__()"))
772                            {
773                                if let Some(raw) = value.value().and_then(|v| v.as_str()) {
774                                    if let Ok(messages) =
775                                        serde_json::from_str::<Vec<IpcMessage>>(raw)
776                                    {
777                                        for msg in messages {
778                                            let response = handler.handle(msg);
779                                            if let Ok(json) = serde_json::to_string(&response) {
780                                                let script =
781                                                    format!("window.__RDESKTOP_IPC__({})", json);
782                                                let _ =
783                                                    rt.block_on(p.page.evaluate(script.as_str()));
784                                            }
785                                        }
786                                    }
787                                }
788                            }
789                        }
790                    }
791
792                    // Backend → frontend push (live send_to_frontend)
793                    let live_ops = self.pending_ops.borrow_mut().drain(..).collect::<Vec<_>>();
794                    for op in live_ops {
795                        if let PendingOp::SendToFrontend(rd_id, msg) = op {
796                            if let Some((_, p)) = pages.iter().find(|(id, _)| *id == rd_id) {
797                                if let Ok(js) = serde_json::to_string(&msg) {
798                                    let script = format!("window.__RDESKTOP_IPC__({js})");
799                                    let _ = rt.block_on(p.page.evaluate(script.as_str()));
800                                }
801                            }
802                        }
803                    }
804
805                    // External outbox (native → frontend pushes): global hotkeys,
806                    // global input hooks, and the Node extension host. Broadcast to
807                    // every page so each window receives the event.
808                    let outbox_msgs: Vec<String> = {
809                        let mut queue = outbox_for_loop.lock().unwrap();
810                        queue.drain(..).collect()
811                    };
812                    for json in outbox_msgs {
813                        if let Ok(js) = serde_json::to_string(&json) {
814                            let script = format!("window.__RDESKTOP_IPC__({js})");
815                            for (_, p) in pages.iter() {
816                                let _ = rt.block_on(p.page.evaluate(script.as_str()));
817                            }
818                        }
819                    }
820
821                    for (_, (window, rd_id)) in tao_windows.iter() {
822                        if let Some((_, p)) = pages.iter().find(|(id, _)| id == rd_id) {
823                            Self::blit(window, &p.pixels, p.width, p.height);
824                        }
825                    }
826                }
827
828                Event::LoopDestroyed => {
829                    tracing::info!("Chrome renderer destroyed");
830                }
831                _ => {}
832            }
833        });
834    }
835
836    fn kind(&self) -> RendererKind {
837        RendererKind::Chrome
838    }
839}
840
841/// Map a winit `KeyCode` debug name (e.g. "KeyA", "Digit1", "Enter") to the
842/// `key`/`text` values expected by CDP `Input.dispatchKeyEvent`.
843///
844/// Returns `(key, text)` where `text` is `Some` only for printable characters.
845/// When `shift` or `caps` is active, letters are uppercased and digits/symbols
846/// use their shifted variant, so `Shift+1` produces `"!"` instead of `"1"`.
847fn cdp_key_event(code: &str, shift: bool, caps: bool) -> (String, Option<String>) {
848    if let Some(c) = code.strip_prefix("Key") {
849        let upper = shift ^ caps;
850        let ch = if upper {
851            c.to_uppercase()
852        } else {
853            c.to_lowercase()
854        };
855        return (ch.clone(), Some(ch));
856    }
857    if let Some(d) = code.strip_prefix("Digit") {
858        const SHIFTED: &[&str] = &[")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
859        if let Ok(idx) = d.parse::<usize>() {
860            if idx < SHIFTED.len() {
861                let s = if shift {
862                    SHIFTED[idx].to_string()
863                } else {
864                    d.to_string()
865                };
866                return (s.clone(), Some(s));
867            }
868        }
869    }
870    match code {
871        "Enter" => ("Enter".into(), None),
872        "Escape" => ("Escape".into(), None),
873        "Backspace" => ("Backspace".into(), None),
874        "Tab" => ("Tab".into(), None),
875        "Space" => (" ".into(), Some(" ".into())),
876        "ArrowLeft" => ("ArrowLeft".into(), None),
877        "ArrowRight" => ("ArrowRight".into(), None),
878        "ArrowUp" => ("ArrowUp".into(), None),
879        "ArrowDown" => ("ArrowDown".into(), None),
880        "Delete" => ("Delete".into(), None),
881        "Home" => ("Home".into(), None),
882        "End" => ("End".into(), None),
883        "PageUp" => ("PageUp".into(), None),
884        "PageDown" => ("PageDown".into(), None),
885        "ShiftLeft" | "ShiftRight" => ("Shift".into(), None),
886        "ControlLeft" | "ControlRight" => ("Control".into(), None),
887        "AltLeft" | "AltRight" => ("Alt".into(), None),
888        "MetaLeft" | "MetaRight" => ("Meta".into(), None),
889        _ => (code.to_string(), None),
890    }
891}