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