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