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