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