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