Skip to main content

rdesktop_webview/
renderer.rs

1use std::borrow::Cow;
2use std::cell::RefCell;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use rdesktop_core::config::{AppConfig, WindowConfig};
9use rdesktop_core::ipc::{IpcHandler, IpcMessage};
10use rdesktop_core::renderer::{Renderer, RendererKind, ResizeEdge};
11use rdesktop_core::window::WindowHandle;
12use rdesktop_core::{RdesktopError, Result};
13
14use tao::event::{Event, StartCause, WindowEvent};
15use tao::event_loop::{ControlFlow, EventLoopBuilder};
16use tao::window::{Window, WindowBuilder, WindowId};
17use wry::http::{Request, Response};
18#[cfg(target_os = "windows")]
19use wry::WebViewBuilderExtWindows;
20use wry::{WebView, WebViewBuilder};
21
22struct WindowEntry {
23    window: Window,
24    webview: WebView,
25}
26
27fn serve_asset(root: &Path, request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
28    let request_path = request.uri().path().trim_start_matches('/');
29    let request_path = percent_encoding::percent_decode_str(request_path).decode_utf8_lossy();
30    let relative = Path::new(request_path.as_ref());
31
32    let invalid_path = relative.components().any(|component| {
33        matches!(
34            component,
35            Component::ParentDir | Component::RootDir | Component::Prefix(_)
36        )
37    });
38    if invalid_path {
39        return asset_response(403, "text/plain; charset=utf-8", b"forbidden".to_vec());
40    }
41
42    let relative = if request_path.is_empty() {
43        Path::new("index.html")
44    } else {
45        relative
46    };
47    let path = root.join(relative);
48    match fs::read(&path) {
49        Ok(bytes) => asset_response(200, content_type(&path), bytes),
50        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
51            asset_response(404, "text/plain; charset=utf-8", b"not found".to_vec())
52        }
53        Err(error) => {
54            tracing::error!(path = %path.display(), %error, "Failed to serve native asset");
55            asset_response(
56                500,
57                "text/plain; charset=utf-8",
58                b"asset read failed".to_vec(),
59            )
60        }
61    }
62}
63
64fn asset_response(status: u16, content_type: &str, body: Vec<u8>) -> Response<Cow<'static, [u8]>> {
65    Response::builder()
66        .status(status)
67        .header("Content-Type", content_type)
68        .header("Cache-Control", "no-cache")
69        .body(Cow::Owned(body))
70        .expect("valid native asset response")
71}
72
73fn content_type(path: &Path) -> &'static str {
74    match path
75        .extension()
76        .and_then(|ext| ext.to_str())
77        .unwrap_or_default()
78    {
79        "html" => "text/html; charset=utf-8",
80        "js" | "mjs" => "text/javascript; charset=utf-8",
81        "css" => "text/css; charset=utf-8",
82        "json" => "application/json; charset=utf-8",
83        "png" => "image/png",
84        "jpg" | "jpeg" => "image/jpeg",
85        "svg" => "image/svg+xml",
86        "wav" => "audio/wav",
87        "mp3" => "audio/mpeg",
88        "woff" => "font/woff",
89        "woff2" => "font/woff2",
90        _ => "application/octet-stream",
91    }
92}
93
94/// Pending operation queued before the event loop starts.
95enum PendingOp {
96    LoadUrl(u64, String),
97    LoadHtml(u64, String),
98    EvalScript(u64, String),
99    SetTitle(u64, String),
100    SetSize(u64, u32, u32),
101    SetResizable(u64, bool),
102    SetVisible(u64, bool),
103    SendToFrontend(u64, String),
104    Close(u64),
105    // Frameless / window control
106    Minimize(u64),
107    Maximize(u64),
108    SetFullscreen(u64, bool),
109    StartDrag(u64),
110    StartResize(u64, tao::window::ResizeDirection),
111    SetDecorations(u64, bool),
112    SetAlwaysOnTop(u64, bool),
113}
114
115/// Shared IPC response queue.
116type IpcResponseQueue = Arc<Mutex<Vec<String>>>;
117
118/// Window control commands from the IPC thread, drained by the event loop.
119type WindowCommandQueue = Arc<Mutex<Vec<WindowCommand>>>;
120
121/// A window control command sent from the IPC handler to the event loop.
122struct WindowCommand {
123    rdesktop_id: u64,
124    action: WindowAction,
125}
126
127enum WindowAction {
128    Minimize,
129    Maximize,
130    Close,
131    StartDrag,
132    StartResize(tao::window::ResizeDirection),
133    SetFullscreen(bool),
134}
135
136/// Convert rdesktop ResizeEdge to tao's ResizeDirection.
137fn to_tao_resize(edge: ResizeEdge) -> tao::window::ResizeDirection {
138    match edge {
139        ResizeEdge::Top => tao::window::ResizeDirection::North,
140        ResizeEdge::Bottom => tao::window::ResizeDirection::South,
141        ResizeEdge::Left => tao::window::ResizeDirection::West,
142        ResizeEdge::Right => tao::window::ResizeDirection::East,
143        ResizeEdge::TopLeft => tao::window::ResizeDirection::NorthWest,
144        ResizeEdge::TopRight => tao::window::ResizeDirection::NorthEast,
145        ResizeEdge::BottomLeft => tao::window::ResizeDirection::SouthWest,
146        ResizeEdge::BottomRight => tao::window::ResizeDirection::SouthEast,
147    }
148}
149
150/// WebView-based renderer using wry + tao.
151///
152/// Platform backends:
153/// - Windows: WebView2 (Edge Chromium)
154/// - macOS: WKWebView (WebKit)
155/// - Linux: WebKitGTK
156///
157/// ## Frameless / Custom Title Bar
158///
159/// Set `decorations = false` in `WindowConfig` to create a frameless window.
160/// The frontend can use `window.__RDESKTOP_WINDOW__` to control the window:
161///
162/// ```javascript
163/// window.__RDESKTOP_WINDOW__.minimize()
164/// window.__RDESKTOP_WINDOW__.maximize()
165/// window.__RDESKTOP_WINDOW__.close()
166/// window.__RDESKTOP_WINDOW__.startDrag()       // drag from custom title bar
167/// window.__RDESKTOP_WINDOW__.startResize('bottom-right')  // resize from edge
168/// ```
169pub struct WebViewRenderer {
170    _config: AppConfig,
171    ipc_handler: Option<Arc<dyn IpcHandler>>,
172    pending_windows: RefCell<Vec<(u64, WindowConfig)>>,
173    pending_ops: RefCell<Vec<PendingOp>>,
174    next_window_id: RefCell<u64>,
175    asset_root: Option<PathBuf>,
176    /// External outbox for native → frontend pushes (e.g. a Node extension
177    /// host asking the UI to show a message or apply an editor edit). Drained
178    /// every frame by the event loop, same as `ipc_response_queue`.
179    outbox: Arc<Mutex<Vec<String>>>,
180}
181
182impl WebViewRenderer {
183    pub fn new(config: &AppConfig) -> Result<Self> {
184        Ok(Self {
185            _config: config.clone(),
186            ipc_handler: None,
187            pending_windows: RefCell::new(Vec::new()),
188            pending_ops: RefCell::new(Vec::new()),
189            next_window_id: RefCell::new(1),
190            asset_root: None,
191            outbox: Arc::new(Mutex::new(Vec::new())),
192        })
193    }
194
195    /// Register a local directory as the renderer's `rdesktop://` asset root.
196    ///
197    /// Native WebViews cannot reliably load Vite module assets from
198    /// `file://` or `NavigateToString()` because of origin and module-CORS
199    /// rules. Serving the built frontend through a framework-owned protocol
200    /// gives the page a stable origin on every desktop backend.
201    pub fn set_asset_root(&mut self, root: impl Into<PathBuf>) -> Result<()> {
202        let requested_root = root.into();
203        let root = std::fs::canonicalize(&requested_root).map_err(|error| {
204            RdesktopError::Config(format!(
205                "asset root is not accessible ({}): {error}",
206                requested_root.display()
207            ))
208        })?;
209        if !root.is_dir() {
210            return Err(RdesktopError::Config(format!(
211                "asset root is not a directory: {}",
212                root.display()
213            )));
214        }
215        self.asset_root = Some(root);
216        Ok(())
217    }
218
219    /// Attach an external outbox so other runtimes (e.g. a Node extension
220    /// host) can push messages to the frontend. Each entry is a JSON string
221    /// emitted as `window.__RDESKTOP_IPC__(<json>)`.
222    pub fn set_outbox(&mut self, outbox: Arc<Mutex<Vec<String>>>) {
223        self.outbox = outbox;
224    }
225
226    fn next_id(&self) -> u64 {
227        let mut id = self.next_window_id.borrow_mut();
228        let current = *id;
229        *id += 1;
230        current
231    }
232
233    /// JavaScript bridge injected into every WebView.
234    fn bridge_script() -> &'static str {
235        r#"
236        (function() {
237            if (window.__RDESKTOP_BRIDGE__) return;
238            window.__RDESKTOP_BRIDGE__ = true;
239            window.__RDESKTOP_RESOLVE__ = {};
240
241            // ── IPC Bridge ──────────────────────────────────────
242            window.__RDESKTOP_INVOKE__ = function(cmd, payload) {
243                return new Promise(function(resolve, reject) {
244                    var id = Math.random().toString(36).slice(2);
245                    window.__RDESKTOP_RESOLVE__[id] = resolve;
246                    if (window.ipc && window.ipc.postMessage) {
247                        window.ipc.postMessage(JSON.stringify({ id: id, cmd: cmd, payload: payload || {} }));
248                    }
249                    setTimeout(function() {
250                        if (window.__RDESKTOP_RESOLVE__[id]) {
251                            delete window.__RDESKTOP_RESOLVE__[id];
252                            reject(new Error('IPC timeout'));
253                        }
254                    }, 30000);
255                });
256            };
257
258            window.__RDESKTOP_IPC__ = function(message) {
259                try {
260                    var data = typeof message === 'string' ? JSON.parse(message) : message;
261                    if (data.id && window.__RDESKTOP_RESOLVE__[data.id]) {
262                        window.__RDESKTOP_RESOLVE__[data.id](data);
263                        delete window.__RDESKTOP_RESOLVE__[data.id];
264                    } else if (window.__RDESKTOP_PUSH__) {
265                        // Unnamed push (e.g. extension host → UI event).
266                        window.__RDESKTOP_PUSH__(data);
267                    }
268                } catch (e) {
269                    console.error('rdesktop IPC error:', e);
270                }
271            };
272
273            // ── Window Controls (frameless / custom title bar) ──
274            var postWindowCommand = function(action, extra) {
275                if (!window.ipc || !window.ipc.postMessage) return;
276                var payload = extra || {};
277                payload.__window__ = true;
278                payload.action = action;
279                window.ipc.postMessage(JSON.stringify({
280                    id: 'window-' + Math.random().toString(36).slice(2),
281                    cmd: 'rdesktop.window',
282                    payload: payload
283                }));
284            };
285
286            window.__RDESKTOP_WINDOW__ = {
287                minimize: function() {
288                    postWindowCommand('minimize');
289                },
290                maximize: function() {
291                    postWindowCommand('maximize');
292                },
293                close: function() {
294                    postWindowCommand('close');
295                },
296                startDrag: function() {
297                    postWindowCommand('start_drag');
298                },
299                startResize: function(edge) {
300                    postWindowCommand('start_resize', { edge: edge || 'bottom-right' });
301                },
302                setFullscreen: function(fs) {
303                    postWindowCommand('set_fullscreen', { value: !!fs });
304                },
305                isMaximized: false,
306                isFullscreen: false
307            };
308        })();
309        "#
310    }
311
312    /// Parse a window control payload from the IPC handler.
313    /// Returns Some(WindowCommand) if it's a window command, None otherwise.
314    fn parse_window_payload(
315        payload: &serde_json::Value,
316        rdesktop_id: u64,
317    ) -> Option<WindowCommand> {
318        // Check if the payload has __window__ flag
319        if payload
320            .get("__window__")
321            .and_then(|v| v.as_bool())
322            .unwrap_or(false)
323        {
324            let action = match payload["action"].as_str()? {
325                "minimize" => WindowAction::Minimize,
326                "maximize" => WindowAction::Maximize,
327                "close" => WindowAction::Close,
328                "start_drag" => WindowAction::StartDrag,
329                "start_resize" => {
330                    let edge_str = payload["edge"].as_str().unwrap_or("bottom-right");
331                    let dir = match edge_str {
332                        "top" => tao::window::ResizeDirection::North,
333                        "bottom" => tao::window::ResizeDirection::South,
334                        "left" => tao::window::ResizeDirection::West,
335                        "right" => tao::window::ResizeDirection::East,
336                        "top-left" => tao::window::ResizeDirection::NorthWest,
337                        "top-right" => tao::window::ResizeDirection::NorthEast,
338                        "bottom-left" => tao::window::ResizeDirection::SouthWest,
339                        _ => tao::window::ResizeDirection::SouthEast,
340                    };
341                    WindowAction::StartResize(dir)
342                }
343                "set_fullscreen" => {
344                    let val = payload["value"].as_bool().unwrap_or(false);
345                    WindowAction::SetFullscreen(val)
346                }
347                _ => return None,
348            };
349            return Some(WindowCommand {
350                rdesktop_id,
351                action,
352            });
353        }
354        None
355    }
356
357    fn parse_window_command(msg: &IpcMessage, rdesktop_id: u64) -> Option<WindowCommand> {
358        Self::parse_window_payload(&msg.payload, rdesktop_id)
359    }
360
361    fn parse_legacy_window_command(
362        raw: &serde_json::Value,
363        rdesktop_id: u64,
364    ) -> Option<WindowCommand> {
365        Self::parse_window_payload(raw, rdesktop_id)
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn parses_formal_window_command_envelope() {
375        let message = IpcMessage {
376            id: "window-test".to_string(),
377            cmd: "rdesktop.window".to_string(),
378            payload: serde_json::json!({
379                "__window__": true,
380                "action": "close"
381            }),
382        };
383
384        assert!(WebViewRenderer::parse_window_command(&message, 1).is_some());
385    }
386
387    #[test]
388    fn parses_legacy_top_level_window_command() {
389        let raw = serde_json::json!({
390            "__window__": true,
391            "action": "minimize"
392        });
393
394        assert!(WebViewRenderer::parse_legacy_window_command(&raw, 1).is_some());
395    }
396}
397
398impl Renderer for WebViewRenderer {
399    fn init(&mut self) -> Result<()> {
400        tracing::info!("Initializing WebView renderer");
401        Ok(())
402    }
403
404    fn create_window(&mut self, config: &WindowConfig) -> Result<WindowHandle> {
405        let id = self.next_id();
406        self.pending_windows.borrow_mut().push((id, config.clone()));
407        tracing::info!(window_id = id, "Window queued for creation");
408        Ok(WindowHandle::new(id))
409    }
410
411    fn load_url(&self, window: WindowHandle, url: &str) -> Result<()> {
412        self.pending_ops
413            .borrow_mut()
414            .push(PendingOp::LoadUrl(window.id(), url.to_string()));
415        Ok(())
416    }
417
418    fn load_html(&self, window: WindowHandle, html: &str) -> Result<()> {
419        self.pending_ops
420            .borrow_mut()
421            .push(PendingOp::LoadHtml(window.id(), html.to_string()));
422        Ok(())
423    }
424
425    fn eval_script(&self, window: WindowHandle, script: &str) -> Result<()> {
426        self.pending_ops
427            .borrow_mut()
428            .push(PendingOp::EvalScript(window.id(), script.to_string()));
429        Ok(())
430    }
431
432    fn set_ipc_handler(&mut self, handler: Box<dyn IpcHandler>) {
433        self.ipc_handler = Some(Arc::from(handler));
434    }
435
436    fn send_to_frontend(&self, window: WindowHandle, message: &str) -> Result<()> {
437        self.pending_ops
438            .borrow_mut()
439            .push(PendingOp::SendToFrontend(window.id(), message.to_string()));
440        Ok(())
441    }
442
443    fn set_title(&self, window: WindowHandle, title: &str) -> Result<()> {
444        self.pending_ops
445            .borrow_mut()
446            .push(PendingOp::SetTitle(window.id(), title.to_string()));
447        Ok(())
448    }
449
450    fn set_size(&self, window: WindowHandle, width: u32, height: u32) -> Result<()> {
451        self.pending_ops
452            .borrow_mut()
453            .push(PendingOp::SetSize(window.id(), width, height));
454        Ok(())
455    }
456
457    fn set_resizable(&self, window: WindowHandle, resizable: bool) -> Result<()> {
458        self.pending_ops
459            .borrow_mut()
460            .push(PendingOp::SetResizable(window.id(), resizable));
461        Ok(())
462    }
463
464    fn set_visible(&self, window: WindowHandle, visible: bool) -> Result<()> {
465        self.pending_ops
466            .borrow_mut()
467            .push(PendingOp::SetVisible(window.id(), visible));
468        Ok(())
469    }
470
471    fn close_window(&mut self, window: WindowHandle) -> Result<()> {
472        self.pending_ops
473            .borrow_mut()
474            .push(PendingOp::Close(window.id()));
475        Ok(())
476    }
477
478    // ── Frameless / Window Controls ─────────────────────────────
479
480    fn minimize_window(&self, window: WindowHandle) -> Result<()> {
481        self.pending_ops
482            .borrow_mut()
483            .push(PendingOp::Minimize(window.id()));
484        Ok(())
485    }
486
487    fn maximize_window(&self, window: WindowHandle) -> Result<()> {
488        self.pending_ops
489            .borrow_mut()
490            .push(PendingOp::Maximize(window.id()));
491        Ok(())
492    }
493
494    fn is_maximized(&self, _window: WindowHandle) -> Result<bool> {
495        // This needs to be checked inside the event loop; return false for now.
496        // In practice, the frontend can track this via window state events.
497        Ok(false)
498    }
499
500    fn set_fullscreen(&self, window: WindowHandle, fullscreen: bool) -> Result<()> {
501        self.pending_ops
502            .borrow_mut()
503            .push(PendingOp::SetFullscreen(window.id(), fullscreen));
504        Ok(())
505    }
506
507    fn is_fullscreen(&self, _window: WindowHandle) -> Result<bool> {
508        Ok(false)
509    }
510
511    fn start_drag(&self, window: WindowHandle) -> Result<()> {
512        self.pending_ops
513            .borrow_mut()
514            .push(PendingOp::StartDrag(window.id()));
515        Ok(())
516    }
517
518    fn start_resize(&self, window: WindowHandle, edge: ResizeEdge) -> Result<()> {
519        self.pending_ops
520            .borrow_mut()
521            .push(PendingOp::StartResize(window.id(), to_tao_resize(edge)));
522        Ok(())
523    }
524
525    fn set_decorations(&self, window: WindowHandle, decorations: bool) -> Result<()> {
526        self.pending_ops
527            .borrow_mut()
528            .push(PendingOp::SetDecorations(window.id(), decorations));
529        Ok(())
530    }
531
532    fn set_always_on_top(&self, window: WindowHandle, always: bool) -> Result<()> {
533        self.pending_ops
534            .borrow_mut()
535            .push(PendingOp::SetAlwaysOnTop(window.id(), always));
536        Ok(())
537    }
538
539    // ── Event Loop ──────────────────────────────────────────────
540
541    fn run(mut self: Box<Self>) -> Result<()> {
542        tracing::info!("Starting WebView event loop");
543
544        let ipc_handler = self.ipc_handler.take();
545        let webgpu_enabled = self._config.renderer.webgpu;
546        let asset_root = self.asset_root.clone();
547        let pending_windows: Vec<(u64, WindowConfig)> =
548            self.pending_windows.borrow_mut().drain(..).collect();
549        let pending_ops: Vec<PendingOp> = self.pending_ops.borrow_mut().drain(..).collect();
550
551        let ipc_response_queue: IpcResponseQueue = Arc::new(Mutex::new(Vec::new()));
552        let ipc_queue_for_handler = ipc_response_queue.clone();
553
554        // External outbox for native → frontend pushes (Node extension host, etc.)
555        let outbox_for_loop = self.outbox.clone();
556
557        // ── Phase 2: global hotkeys & input hooks ───────────────────────
558        // Wired through the shared outbox so the frontend receives them as
559        // `window.__RDESKTOP_PUSH__` events (`rdesktop.globalHotkey` /
560        // `rdesktop.globalInput`). Managers live for the whole event loop.
561        let global_handler = rdesktop_core::PushHandler::new(self.outbox.clone());
562        let _hotkey_manager = {
563            let mgr = rdesktop_core::HotkeyManager::new(global_handler.clone());
564            for (i, hc) in self._config.hotkeys.iter().enumerate() {
565                if let Ok(hk) = hc.combo.parse::<rdesktop_core::Hotkey>() {
566                    let id = i as u32 + 1;
567                    if let Err(e) = mgr.register(id, &hk) {
568                        tracing::warn!("failed to register hotkey {:?}: {}", hc.combo, e);
569                    }
570                } else {
571                    tracing::warn!("invalid hotkey combo: {:?}", hc.combo);
572                }
573            }
574            mgr
575        };
576        let _input_manager = if self._config.global_input.enabled {
577            let mut inp = rdesktop_core::GlobalInput::new(global_handler.clone());
578            if self._config.global_input.mouse_move {
579                inp = inp.with_mouse_move(true);
580            }
581            match inp.start() {
582                Ok(()) => Some(inp),
583                Err(e) => {
584                    tracing::warn!("failed to start global input: {}", e);
585                    None
586                }
587            }
588        } else {
589            None
590        };
591
592        // Window command queue for IPC-triggered window operations
593        let window_cmd_queue: WindowCommandQueue = Arc::new(Mutex::new(Vec::new()));
594        let window_cmd_queue_for_ipc = window_cmd_queue.clone();
595
596        // Build a map of rdesktop_id -> first tao_id for the IPC handler
597        // (the IPC handler needs to know which window to operate on)
598        let first_window_id: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None));
599
600        let event_loop = EventLoopBuilder::new().build();
601        let event_loop_proxy = event_loop.create_proxy();
602        let mut windows: HashMap<WindowId, WindowEntry> = HashMap::new();
603        let mut rdesktop_to_tao: HashMap<u64, WindowId> = HashMap::new();
604        let mut tao_to_rdesktop: HashMap<WindowId, u64> = HashMap::new();
605
606        event_loop.run(move |event, event_loop_target, control_flow| {
607            *control_flow = ControlFlow::Wait;
608
609            match event {
610                Event::NewEvents(StartCause::Init) => {
611                    let event_loop_proxy = event_loop_proxy.clone();
612                    // Create all pending windows
613                    for (rdesktop_id, window_config) in &pending_windows {
614                        let window = match WindowBuilder::new()
615                            .with_title(&window_config.title)
616                            .with_inner_size(tao::dpi::LogicalSize::new(
617                                window_config.width,
618                                window_config.height,
619                            ))
620                            .with_resizable(window_config.resizable)
621                            .with_decorations(window_config.decorations)
622                            .with_transparent(window_config.transparent)
623                            .with_always_on_top(window_config.always_on_top)
624                            .with_window_icon(rdesktop_core::window_icon(window_config))
625                            .build(event_loop_target)
626                        {
627                            Ok(w) => w,
628                            Err(e) => {
629                                tracing::error!("Failed to create window {}: {}", rdesktop_id, e);
630                                continue;
631                            }
632                        };
633
634                        let tao_id = window.id();
635
636                        // Realize wallpaper/overlay/click-through window attributes.
637                        rdesktop_core::apply_window_attributes(&window, window_config);
638
639                        let mut builder = WebViewBuilder::new()
640                            .with_url("about:blank")
641                            .with_devtools(cfg!(debug_assertions))
642                            .with_initialization_script(Self::bridge_script());
643
644                        if let Some(root) = asset_root.clone() {
645                            builder = builder.with_custom_protocol(
646                                "rdesktop".to_string(),
647                                move |_webview_id, request| serve_asset(&root, request),
648                            );
649                        }
650
651                        // Enable WebGPU in the web context when requested, so the
652                        // frontend can drive native shaders (wallpaper effects).
653                        if window_config.transparent {
654                            builder = builder.with_transparent(true);
655                        }
656                        // Enable WebGPU in the web context so the frontend can
657                        // drive native shaders (wallpaper effects). On Windows
658                        // WebView2/Edge needs the feature flag; on macOS WKWebView
659                        // exposes WebGPU natively and on Linux WebKitGTK enables it
660                        // via a different path, so the args are Windows-only.
661                        #[cfg(target_os = "windows")]
662                        if webgpu_enabled {
663                            builder = builder.with_additional_browser_args(
664                                "--enable-features=Vulkan,WebGPU --enable-unsafe-webgpu",
665                            );
666                        }
667
668                        // Wire up IPC handler
669                        if let Some(ref handler) = ipc_handler {
670                            let handler = handler.clone();
671                            let queue = ipc_queue_for_handler.clone();
672                            let win_queue = window_cmd_queue_for_ipc.clone();
673                            let wake_proxy = event_loop_proxy.clone();
674                            let _first_id = first_window_id.clone();
675                            let rd_id = *rdesktop_id;
676
677                            builder =
678                                builder.with_ipc_handler(move |req: wry::http::Request<String>| {
679                                    let body = req.body();
680
681                                    // Parse the JSON once so both the formal IPC envelope and
682                                    // legacy top-level window commands remain supported.
683                                    if let Ok(raw) = serde_json::from_str::<serde_json::Value>(body)
684                                    {
685                                        if let Some(cmd) =
686                                            WebViewRenderer::parse_legacy_window_command(
687                                                &raw, rd_id,
688                                            )
689                                        {
690                                            if let Ok(mut q) = win_queue.lock() {
691                                                q.push(cmd);
692                                            }
693                                            let _ = wake_proxy.send_event(());
694                                            return;
695                                        }
696
697                                        if let Ok(msg) = serde_json::from_value::<IpcMessage>(raw) {
698                                            // Formal window command or regular IPC message.
699                                            if let Some(cmd) =
700                                                WebViewRenderer::parse_window_command(&msg, rd_id)
701                                            {
702                                                if let Ok(mut q) = win_queue.lock() {
703                                                    q.push(cmd);
704                                                }
705                                            } else {
706                                                let response = handler.handle(msg);
707                                                if let Ok(json) = serde_json::to_string(&response) {
708                                                    if let Ok(mut q) = queue.lock() {
709                                                        q.push(json);
710                                                    }
711                                                }
712                                            }
713                                            let _ = wake_proxy.send_event(());
714                                        }
715                                    }
716                                });
717                        }
718
719                        let webview = match builder.build(&window) {
720                            Ok(wv) => wv,
721                            Err(e) => {
722                                tracing::error!("Failed to create webview {}: {}", rdesktop_id, e);
723                                continue;
724                            }
725                        };
726
727                        windows.insert(tao_id, WindowEntry { window, webview });
728                        rdesktop_to_tao.insert(*rdesktop_id, tao_id);
729                        tao_to_rdesktop.insert(tao_id, *rdesktop_id);
730
731                        if first_window_id.lock().unwrap().is_none() {
732                            *first_window_id.lock().unwrap() = Some(*rdesktop_id);
733                        }
734
735                        tracing::info!(rdesktop_id = rdesktop_id, ?tao_id, "Window created");
736                    }
737
738                    // Process pending operations
739                    for op in &pending_ops {
740                        Self::apply_op(op, &windows, &rdesktop_to_tao);
741                    }
742                }
743
744                Event::WindowEvent {
745                    event: WindowEvent::CloseRequested,
746                    window_id,
747                    ..
748                } => {
749                    if let Some(rd_id) = tao_to_rdesktop.remove(&window_id) {
750                        rdesktop_to_tao.remove(&rd_id);
751                    }
752                    windows.remove(&window_id);
753                    if windows.is_empty() {
754                        tracing::info!("All windows closed, exiting");
755                        *control_flow = ControlFlow::Exit;
756                    }
757                }
758
759                Event::WindowEvent {
760                    event: WindowEvent::Resized(size),
761                    window_id,
762                    ..
763                } => {
764                    if let Some(entry) = windows.get(&window_id) {
765                        let _ = entry.webview.set_bounds(wry::Rect {
766                            position: tao::dpi::LogicalPosition::<i32>::new(0, 0).into(),
767                            size: tao::dpi::LogicalSize::new(size.width, size.height).into(),
768                        });
769                    }
770                }
771
772                Event::WindowEvent {
773                    event: WindowEvent::ScaleFactorChanged { new_inner_size, .. },
774                    window_id,
775                    ..
776                } => {
777                    if let Some(entry) = windows.get(&window_id) {
778                        let _ = entry.webview.set_bounds(wry::Rect {
779                            position: tao::dpi::LogicalPosition::<i32>::new(0, 0).into(),
780                            size: tao::dpi::LogicalSize::new(
781                                new_inner_size.width,
782                                new_inner_size.height,
783                            )
784                            .into(),
785                        });
786                    }
787                }
788
789                Event::MainEventsCleared => {
790                    // Drain IPC response queue
791                    let responses: Vec<String> = {
792                        let mut queue = ipc_response_queue.lock().unwrap();
793                        queue.drain(..).collect()
794                    };
795                    for json in responses {
796                        if let Some(entry) = windows.values().next() {
797                            if let Ok(js) = serde_json::to_string(&json) {
798                                let script = format!("window.__RDESKTOP_IPC__({js})");
799                                let _ = entry.webview.evaluate_script(&script);
800                            }
801                        }
802                    }
803
804                    // Drain external outbox (native → frontend pushes)
805                    let outbox_msgs: Vec<String> = {
806                        let mut queue = outbox_for_loop.lock().unwrap();
807                        queue.drain(..).collect()
808                    };
809                    for json in outbox_msgs {
810                        if let Some(entry) = windows.values().next() {
811                            if let Ok(js) = serde_json::to_string(&json) {
812                                let script = format!("window.__RDESKTOP_IPC__({js})");
813                                let _ = entry.webview.evaluate_script(&script);
814                            }
815                        }
816                    }
817
818                    // Drain window command queue
819                    let commands: Vec<WindowCommand> = {
820                        let mut queue = window_cmd_queue.lock().unwrap();
821                        queue.drain(..).collect()
822                    };
823                    for cmd in commands {
824                        if let Some(tao_id) = rdesktop_to_tao.get(&cmd.rdesktop_id) {
825                            if let Some(entry) = windows.get(tao_id) {
826                                match cmd.action {
827                                    WindowAction::Minimize => {
828                                        entry.window.set_minimized(true);
829                                    }
830                                    WindowAction::Maximize => {
831                                        let is_max = entry.window.is_maximized();
832                                        entry.window.set_maximized(!is_max);
833                                    }
834                                    WindowAction::Close => {
835                                        *control_flow = ControlFlow::Exit;
836                                    }
837                                    WindowAction::StartDrag => {
838                                        let _ = entry.window.drag_window();
839                                    }
840                                    WindowAction::StartResize(dir) => {
841                                        let _ = entry.window.drag_resize_window(dir);
842                                    }
843                                    WindowAction::SetFullscreen(fs) => {
844                                        if fs {
845                                            entry.window.set_fullscreen(Some(
846                                                tao::window::Fullscreen::Borderless(None),
847                                            ));
848                                        } else {
849                                            entry.window.set_fullscreen(None);
850                                        }
851                                    }
852                                }
853                            }
854                        }
855                    }
856                }
857
858                Event::LoopDestroyed => {
859                    tracing::info!("WebView event loop destroyed");
860                }
861
862                _ => {}
863            }
864        });
865    }
866
867    fn kind(&self) -> RendererKind {
868        RendererKind::WebView
869    }
870}
871
872impl WebViewRenderer {
873    /// Apply a pending operation to a window.
874    fn apply_op(
875        op: &PendingOp,
876        windows: &HashMap<WindowId, WindowEntry>,
877        rdesktop_to_tao: &HashMap<u64, WindowId>,
878    ) {
879        match op {
880            PendingOp::LoadUrl(rd_id, url) => {
881                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
882                    let _ = entry.webview.load_url(url);
883                }
884            }
885            PendingOp::LoadHtml(rd_id, html) => {
886                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
887                    let _ = entry.webview.load_html(html);
888                }
889            }
890            PendingOp::EvalScript(rd_id, script) => {
891                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
892                    let _ = entry.webview.evaluate_script(script);
893                }
894            }
895            PendingOp::SetTitle(rd_id, title) => {
896                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
897                    entry.window.set_title(title);
898                }
899            }
900            PendingOp::SetSize(rd_id, w, h) => {
901                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
902                    entry
903                        .window
904                        .set_inner_size(tao::dpi::LogicalSize::new(*w, *h));
905                }
906            }
907            PendingOp::SetResizable(rd_id, resizable) => {
908                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
909                    entry.window.set_resizable(*resizable);
910                }
911            }
912            PendingOp::SetVisible(rd_id, visible) => {
913                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
914                    entry.window.set_visible(*visible);
915                }
916            }
917            PendingOp::SendToFrontend(rd_id, msg) => {
918                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
919                    if let Ok(js) = serde_json::to_string(msg) {
920                        let script = format!("window.__RDESKTOP_IPC__({js})");
921                        let _ = entry.webview.evaluate_script(&script);
922                    }
923                }
924            }
925            PendingOp::Close(_rd_id) => {
926                // Handled by the caller (removes from maps)
927            }
928            PendingOp::Minimize(rd_id) => {
929                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
930                    entry.window.set_minimized(true);
931                }
932            }
933            PendingOp::Maximize(rd_id) => {
934                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
935                    let is_max = entry.window.is_maximized();
936                    entry.window.set_maximized(!is_max);
937                }
938            }
939            PendingOp::SetFullscreen(rd_id, fs) => {
940                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
941                    if *fs {
942                        entry
943                            .window
944                            .set_fullscreen(Some(tao::window::Fullscreen::Borderless(None)));
945                    } else {
946                        entry.window.set_fullscreen(None);
947                    }
948                }
949            }
950            PendingOp::StartDrag(rd_id) => {
951                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
952                    let _ = entry.window.drag_window();
953                }
954            }
955            PendingOp::StartResize(rd_id, dir) => {
956                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
957                    let _ = entry.window.drag_resize_window(*dir);
958                }
959            }
960            PendingOp::SetDecorations(rd_id, decorations) => {
961                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
962                    entry.window.set_decorations(*decorations);
963                }
964            }
965            PendingOp::SetAlwaysOnTop(rd_id, always) => {
966                if let Some(entry) = rdesktop_to_tao.get(rd_id).and_then(|id| windows.get(id)) {
967                    entry.window.set_always_on_top(*always);
968                }
969            }
970        }
971    }
972}