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