Skip to main content

webui_desktop/
runtime.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8
9use crate::window::{
10    append_live_background_css, apply_window_css_ref, live_background_css, LiveBackground,
11};
12use serde_json::Value;
13use webui_handler::{Protocol, RenderOptions, ResponseWriter, WebUIHandler};
14
15use crate::error::{DesktopError, Result};
16use crate::hydration::handler_for_name;
17#[cfg(feature = "source")]
18use crate::hydration::handler_for_plugin;
19#[cfg(feature = "application-ipc")]
20use crate::ipc::IpcRegistry;
21use crate::path::resolve_safe_path;
22#[cfg(feature = "application-ipc")]
23use crate::protocol::IPC_ENDPOINT;
24use crate::protocol::{
25    read_asset_response, read_known_asset_response, DesktopHttpMethod, DesktopProtocolRequest,
26    DesktopProtocolResponse, DEFAULT_MAX_ASSET_BYTES,
27};
28use crate::routes::route_path;
29use crate::routes::{ApiRouteRegistry, RouteStateRegistry};
30use crate::{apply_window_css, window_css_block, DesktopPlatform};
31
32/// Source-backed desktop runtime configuration.
33#[cfg(feature = "source")]
34pub struct DesktopSourceConfig {
35    /// WebUI build options.
36    pub build_options: webui::BuildOptions,
37    /// Optional state JSON file.
38    pub state_file: Option<PathBuf>,
39    /// Optional in-memory startup state supplied by Rust app hosts.
40    pub state: Option<Value>,
41    /// Optional static asset root.
42    pub asset_root: Option<PathBuf>,
43    /// Maximum file length delivered by one protocol response.
44    pub max_asset_bytes: u64,
45    /// Protobuf IPC registry.
46    #[cfg(feature = "application-ipc")]
47    pub ipc_registry: IpcRegistry,
48    /// Rust route state providers.
49    pub route_state: RouteStateRegistry,
50    /// Rust custom-protocol API handlers.
51    pub api_routes: ApiRouteRegistry,
52    /// Optional pre-resolved design token CSS keyed by theme name.
53    pub token_css: Option<HashMap<String, String>>,
54    /// Optional theme value and search root to resolve after protocol build.
55    pub theme: Option<(String, PathBuf)>,
56    /// Window configuration used to derive injected window CSS.
57    ///
58    /// Source-mode hosts must supply the same [`crate::WindowOptions`] they pass
59    /// to the native runner. Frame construction rejects mismatched
60    /// titlebar/background styling.
61    pub window: crate::WindowOptions,
62}
63
64#[cfg(feature = "source")]
65impl DesktopSourceConfig {
66    /// Create a source config from WebUI build options.
67    #[must_use]
68    pub fn new(build_options: webui::BuildOptions) -> Self {
69        Self {
70            build_options,
71            state_file: None,
72            state: None,
73            asset_root: None,
74            max_asset_bytes: DEFAULT_MAX_ASSET_BYTES,
75            #[cfg(feature = "application-ipc")]
76            ipc_registry: IpcRegistry::default(),
77            route_state: RouteStateRegistry::new(),
78            api_routes: ApiRouteRegistry::new(),
79            token_css: None,
80            theme: None,
81            window: crate::WindowOptions::default(),
82        }
83    }
84}
85
86/// Bundle-backed desktop runtime configuration.
87///
88/// Use this from app-specific packaged runners that load `protocol.bin` and
89/// immutable assets from a bundle while registering Rust-owned route state and
90/// typed IPC handlers in the executable.
91pub struct DesktopBundleConfig {
92    /// Desktop bundle directory created by `webui desktop build`.
93    pub bundle_dir: PathBuf,
94    /// Optional Rust-owned startup state. When omitted, bundled `state.json` is used.
95    pub state: Option<Value>,
96    /// Maximum file length delivered by one protocol response.
97    pub max_asset_bytes: u64,
98    /// Protobuf IPC registry.
99    #[cfg(feature = "application-ipc")]
100    pub ipc_registry: IpcRegistry,
101    /// Rust route state providers.
102    pub route_state: RouteStateRegistry,
103    /// Rust custom-protocol API handlers.
104    pub api_routes: ApiRouteRegistry,
105    /// Optional pre-resolved design token CSS keyed by theme name.
106    pub token_css: Option<HashMap<String, String>>,
107}
108
109impl DesktopBundleConfig {
110    /// Create a bundle config with empty route/IPC registries.
111    #[must_use]
112    pub fn new(bundle_dir: PathBuf) -> Self {
113        Self {
114            bundle_dir,
115            state: None,
116            max_asset_bytes: DEFAULT_MAX_ASSET_BYTES,
117            #[cfg(feature = "application-ipc")]
118            ipc_registry: IpcRegistry::default(),
119            route_state: RouteStateRegistry::new(),
120            api_routes: ApiRouteRegistry::new(),
121            token_css: None,
122        }
123    }
124}
125
126/// Runtime state shared by a desktop webview custom-protocol handler.
127pub struct DesktopRuntime {
128    protocol: Protocol,
129    entry: String,
130    state: Value,
131    css_files: HashMap<String, String>,
132    asset_root: Option<PathBuf>,
133    #[cfg(all(feature = "native", target_os = "macos"))]
134    bundle_root: Option<PathBuf>,
135    asset_index: HashMap<PathBuf, DesktopAssetEntry>,
136    max_asset_bytes: u64,
137    startup_html: String,
138    window_css: String,
139    live_background: Arc<LiveBackground>,
140    #[cfg(feature = "application-ipc")]
141    ipc_registry: Arc<IpcRegistry>,
142    #[cfg(feature = "application-ipc")]
143    development: bool,
144    handler: WebUIHandler,
145    route_state: RouteStateRegistry,
146    api_routes: ApiRouteRegistry,
147    token_css: Option<HashMap<String, String>>,
148}
149
150struct DesktopAssetEntry {
151    path: PathBuf,
152    content_type: String,
153    size_bytes: u64,
154}
155
156impl DesktopRuntime {
157    /// Build and render a desktop runtime from source paths.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`DesktopError`] if the WebUI build fails, state cannot be read
162    /// or parsed, assets cannot be canonicalized, or startup rendering fails.
163    #[cfg(feature = "source")]
164    pub fn from_source(config: DesktopSourceConfig) -> Result<Self> {
165        let build_result = webui::build(config.build_options.clone())?;
166        let state = match config.state {
167            Some(state) => state,
168            None => read_state(config.state_file.as_ref())?,
169        };
170        let token_css = resolve_config_token_css(
171            config.token_css,
172            config.theme,
173            &build_result.protocol.tokens,
174        )?;
175        let asset_root = canonical_asset_root(config.asset_root.as_ref())?;
176        let css_files = build_result.css_files.into_iter().collect();
177        let protocol = Protocol::new(build_result.protocol);
178        let startup_state = state_for_request(StateRequestContext {
179            protocol: &protocol,
180            entry: &config.build_options.entry,
181            base_state: &state,
182            registry: &config.route_state,
183            token_css: token_css.as_ref(),
184            request_path: "/",
185        })?;
186        let handler = handler_for_plugin(config.build_options.plugin);
187        let window_css = window_css_block(&config.window, DesktopPlatform::current());
188        let startup_html = apply_window_css(
189            render_html(
190                &protocol,
191                &handler,
192                &config.build_options.entry,
193                "/",
194                &startup_state,
195            )?,
196            &window_css,
197        );
198
199        Ok(Self {
200            protocol,
201            entry: config.build_options.entry,
202            state,
203            css_files,
204            asset_root,
205            #[cfg(all(feature = "native", target_os = "macos"))]
206            bundle_root: None,
207            asset_index: HashMap::new(),
208            max_asset_bytes: config.max_asset_bytes,
209            startup_html,
210            window_css,
211            live_background: Arc::default(),
212            #[cfg(feature = "application-ipc")]
213            ipc_registry: Arc::new(config.ipc_registry),
214            #[cfg(feature = "application-ipc")]
215            development: true,
216            handler,
217            route_state: config.route_state,
218            api_routes: config.api_routes,
219            token_css,
220        })
221    }
222
223    /// Load a desktop runtime from a bundle directory.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`DesktopError`] if the bundle manifest, protocol, state, or
228    /// asset root cannot be loaded.
229    pub fn from_bundle(bundle_dir: PathBuf) -> Result<Self> {
230        Self::from_bundle_config(DesktopBundleConfig::new(bundle_dir))
231    }
232
233    /// Load a desktop runtime from a bundle directory with Rust host state.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`DesktopError`] if the bundle manifest, protocol, state, or
238    /// asset root cannot be loaded, or if startup rendering fails.
239    pub fn from_bundle_config(config: DesktopBundleConfig) -> Result<Self> {
240        let bundle_root = canonical_bundle_root(&config.bundle_dir)?;
241        let manifest =
242            crate::DesktopBundleManifest::load(&bundle_root.join("manifest.webui-desktop.json"))?;
243        Self::from_canonical_bundle_config_and_manifest(config, bundle_root, manifest)
244    }
245
246    /// Load a desktop runtime from a bundle directory with an already-loaded manifest.
247    ///
248    /// Use this when the caller also needs manifest metadata such as window or
249    /// shell configuration, so startup does not read and parse the manifest
250    /// twice.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`DesktopError`] if the protocol, state, or asset root cannot be
255    /// loaded, or if startup rendering fails.
256    pub fn from_bundle_config_and_manifest(
257        config: DesktopBundleConfig,
258        manifest: crate::DesktopBundleManifest,
259    ) -> Result<Self> {
260        let bundle_root = canonical_bundle_root(&config.bundle_dir)?;
261        Self::from_canonical_bundle_config_and_manifest(config, bundle_root, manifest)
262    }
263
264    fn from_canonical_bundle_config_and_manifest(
265        config: DesktopBundleConfig,
266        bundle_root: PathBuf,
267        manifest: crate::DesktopBundleManifest,
268    ) -> Result<Self> {
269        let protocol_path =
270            resolve_manifest_path(&bundle_root, &manifest.protocol_path, "protocol")?;
271        let protocol_bytes = fs::read(&protocol_path).map_err(|source| DesktopError::Io {
272            context: format!("reading desktop protocol {}", protocol_path.display()),
273            source,
274        })?;
275        let protocol = Protocol::from_protobuf(&protocol_bytes)?;
276        let state_path = manifest
277            .state_path
278            .as_ref()
279            .map(|path| resolve_manifest_path(&bundle_root, path, "state"))
280            .transpose()?;
281        let state = match config.state {
282            Some(state) => state,
283            None => read_state(state_path.as_ref())?,
284        };
285        let asset_root = resolve_manifest_path(&bundle_root, &manifest.assets_dir, "assets")?;
286        let asset_index = build_asset_index(
287            &asset_root,
288            &manifest.integrity.assets,
289            config.max_asset_bytes,
290        )?;
291        let handler = handler_for_name(manifest.plugin.as_deref());
292        let startup_state = state_for_request(StateRequestContext {
293            protocol: &protocol,
294            entry: &manifest.entry,
295            base_state: &state,
296            registry: &config.route_state,
297            token_css: config.token_css.as_ref(),
298            request_path: "/",
299        })?;
300        let window_css = window_css_block(&manifest.window, DesktopPlatform::current());
301        let startup_html = apply_window_css(
302            render_html(&protocol, &handler, &manifest.entry, "/", &startup_state)?,
303            &window_css,
304        );
305
306        Ok(Self {
307            protocol,
308            entry: manifest.entry,
309            state,
310            css_files: HashMap::new(),
311            asset_root: Some(asset_root),
312            #[cfg(all(feature = "native", target_os = "macos"))]
313            bundle_root: Some(bundle_root),
314            asset_index,
315            max_asset_bytes: config.max_asset_bytes,
316            startup_html,
317            window_css,
318            live_background: Arc::default(),
319            #[cfg(feature = "application-ipc")]
320            ipc_registry: Arc::new(config.ipc_registry),
321            #[cfg(feature = "application-ipc")]
322            development: false,
323            handler,
324            route_state: config.route_state,
325            api_routes: config.api_routes,
326            token_css: config.token_css,
327        })
328    }
329
330    /// Handle one custom-protocol request.
331    ///
332    /// This method is intentionally independent of any specific webview crate,
333    /// so path safety, IPC dispatch, and routing can be unit tested without
334    /// creating an OS window.
335    pub fn handle_request(
336        &self,
337        request: &DesktopProtocolRequest<'_>,
338    ) -> Result<DesktopProtocolResponse> {
339        let path = route_path(request.path);
340        #[cfg(feature = "application-ipc")]
341        if matches!(request.method, DesktopHttpMethod::Get) {
342            if let Some(response) = crate::ipc_assets::response(path) {
343                return Ok(response);
344            }
345        }
346        #[cfg(feature = "application-ipc")]
347        if path == IPC_ENDPOINT || path == "/_webui/ipc/outbound" {
348            return Err(DesktopError::UnsupportedRuntime {
349                message: "application IPC requires a frame-scoped document session".to_string(),
350                help: "route reserved IPC requests through the desktop frame's IPC bridge"
351                    .to_string(),
352            });
353        }
354
355        if let Some(response) = self.api_routes.resolve(request)? {
356            return Ok(response);
357        }
358
359        if matches!(request.method, DesktopHttpMethod::Get) {
360            let request_path = path;
361            if let Some(css) = self.generated_css(request_path) {
362                return Ok(DesktopProtocolResponse::new(
363                    200,
364                    "text/css; charset=utf-8",
365                    css.as_bytes().to_vec(),
366                ));
367            }
368
369            if request_path != "/" && request_path != "/index.html" {
370                if let Some(response) = self.asset_response(request_path)? {
371                    return Ok(response);
372                }
373            }
374
375            if request.wants_json {
376                return self.partial_response(request.path);
377            }
378
379            if request_path == "/" || request_path == "/index.html" {
380                if self.route_state.has_provider("/") {
381                    let html = self.style_html(render_html(
382                        &self.protocol,
383                        &self.handler,
384                        &self.entry,
385                        "/",
386                        &self.state_for_request("/")?,
387                    )?);
388                    return Ok(DesktopProtocolResponse::html(html.into_bytes()));
389                }
390                let body = if let Some(color) = self.live_background.current() {
391                    apply_window_css_ref(&self.startup_html, &live_background_css(color))
392                        .into_bytes()
393                } else {
394                    self.startup_html.as_bytes().to_vec()
395                };
396                return Ok(DesktopProtocolResponse::html(body));
397            }
398
399            if self.protocol.matches_route(&self.entry, request_path) {
400                let html = self.style_html(render_html(
401                    &self.protocol,
402                    &self.handler,
403                    &self.entry,
404                    request_path,
405                    &self.state_for_request(request_path)?,
406                )?);
407                return Ok(DesktopProtocolResponse::html(html.into_bytes()));
408            }
409        }
410
411        Ok(DesktopProtocolResponse::text(404, "Not Found"))
412    }
413
414    /// Return the construction-time HTML snapshot rendered for `/`.
415    ///
416    /// This does not invoke route providers or include later window-control
417    /// changes. Use [`Self::handle_request`] for the current document styling.
418    #[must_use]
419    pub fn startup_html(&self) -> &str {
420        &self.startup_html
421    }
422
423    #[cfg(all(feature = "native", target_os = "macos"))]
424    pub(crate) fn bundle_root(&self) -> Option<&Path> {
425        self.bundle_root.as_deref()
426    }
427
428    pub(crate) fn validate_window(&self, window: &crate::WindowOptions) -> Result<()> {
429        if window_css_block(window, DesktopPlatform::current()) != self.window_css {
430            return Err(DesktopError::UnsupportedRuntime {
431                message: "frame window styling differs from the rendered desktop document".into(),
432                help: "configure window options on DesktopAppBuilder before build, or use the same titlebar and background when constructing the runtime and frame".into(),
433            });
434        }
435        Ok(())
436    }
437
438    pub(crate) fn live_background(&self) -> Arc<LiveBackground> {
439        Arc::clone(&self.live_background)
440    }
441
442    fn style_html(&self, html: String) -> String {
443        let Some(color) = self.live_background.current() else {
444            return apply_window_css(html, &self.window_css);
445        };
446        let mut block = String::with_capacity(self.window_css.len() + 106);
447        block.push_str(&self.window_css);
448        append_live_background_css(&mut block, color);
449        apply_window_css(html, &block)
450    }
451
452    #[cfg(feature = "application-ipc")]
453    pub(crate) fn ipc_registry(&self) -> Arc<IpcRegistry> {
454        Arc::clone(&self.ipc_registry)
455    }
456
457    #[cfg(feature = "application-ipc")]
458    pub(crate) fn is_development(&self) -> bool {
459        self.development
460    }
461
462    fn generated_css(&self, request_path: &str) -> Option<&str> {
463        let name = request_path.trim_start_matches('/');
464        self.css_files.get(name).map(String::as_str)
465    }
466
467    fn asset_response(&self, request_path: &str) -> Result<Option<DesktopProtocolResponse>> {
468        let Some(root) = &self.asset_root else {
469            return Ok(None);
470        };
471
472        if !self.asset_index.is_empty() {
473            let Some(path) = resolve_safe_path(root, request_path) else {
474                return Err(DesktopError::InvalidAssetPath {
475                    path: request_path.to_string(),
476                });
477            };
478            return match self.asset_index.get(&path) {
479                Some(asset) => read_known_asset_response(
480                    root,
481                    &asset.path,
482                    &asset.content_type,
483                    asset.size_bytes,
484                    self.max_asset_bytes,
485                )
486                .map(Some),
487                None => Ok(None),
488            };
489        }
490
491        let Some(path) = resolve_safe_path(root, request_path) else {
492            return Err(DesktopError::InvalidAssetPath {
493                path: request_path.to_string(),
494            });
495        };
496
497        read_asset_response(root, path, self.max_asset_bytes)
498    }
499
500    fn partial_response(&self, request_path: &str) -> Result<DesktopProtocolResponse> {
501        let route_path = route_path(request_path);
502        let state = self.state_for_request(route_path)?;
503        let partial = self
504            .protocol
505            .prepare_partial(state, &self.entry, route_path, "")?;
506        if !partial.is_match() {
507            return Ok(DesktopProtocolResponse::text(404, "Not Found"));
508        }
509        let body = serde_json::to_vec(&partial).map_err(|source| DesktopError::Serialization {
510            context: "serializing desktop router partial".to_string(),
511            source,
512        })?;
513        Ok(DesktopProtocolResponse::new(200, "application/json", body))
514    }
515
516    fn state_for_request(&self, request_path: &str) -> Result<Value> {
517        state_for_request(StateRequestContext {
518            protocol: &self.protocol,
519            entry: &self.entry,
520            base_state: &self.state,
521            registry: &self.route_state,
522            token_css: self.token_css.as_ref(),
523            request_path,
524        })
525    }
526}
527
528fn build_asset_index(
529    asset_root: &Path,
530    assets: &[crate::BundleAsset],
531    max_asset_bytes: u64,
532) -> Result<HashMap<PathBuf, DesktopAssetEntry>> {
533    let mut index = HashMap::with_capacity(assets.len());
534    for asset in assets {
535        let Some(relative) = asset.path.strip_prefix("assets/") else {
536            return Err(DesktopError::InvalidAssetPath {
537                path: asset.path.clone(),
538            });
539        };
540        let relative = Path::new(relative);
541        validate_manifest_relative_path(relative, "asset")?;
542        let path = asset_root.join(relative);
543        let canonical = path.canonicalize().map_err(|source| DesktopError::Io {
544            context: format!("resolving indexed desktop asset {}", path.display()),
545            source,
546        })?;
547        if !canonical.starts_with(asset_root) {
548            return Err(DesktopError::InvalidAssetPath {
549                path: asset.path.clone(),
550            });
551        }
552        if asset.size_bytes > max_asset_bytes {
553            return Err(DesktopError::AssetTooLarge {
554                path,
555                size: asset.size_bytes,
556                max_bytes: max_asset_bytes,
557            });
558        }
559        let content_type = mime_guess::from_path(&path)
560            .first_or_octet_stream()
561            .to_string();
562        index.insert(
563            path.clone(),
564            DesktopAssetEntry {
565                path,
566                content_type,
567                size_bytes: asset.size_bytes,
568            },
569        );
570    }
571    Ok(index)
572}
573
574struct StateRequestContext<'a> {
575    protocol: &'a Protocol,
576    entry: &'a str,
577    base_state: &'a Value,
578    registry: &'a RouteStateRegistry,
579    token_css: Option<&'a HashMap<String, String>>,
580    request_path: &'a str,
581}
582
583fn state_for_request(context: StateRequestContext<'_>) -> Result<Value> {
584    let path = context
585        .request_path
586        .split_once('?')
587        .map_or(context.request_path, |(path, _)| path);
588    let mut state = match context.registry.resolve(path, context.base_state)? {
589        Some(state) => state,
590        None => context.base_state.clone(),
591    };
592
593    if let Value::Object(map) = &mut state {
594        map.insert("basePath".to_string(), Value::String("/".to_string()));
595        if context.token_css.is_none() && !map.contains_key("tokens") {
596            if let Some(tokens) = context.base_state.get("tokens") {
597                map.insert("tokens".to_string(), tokens.clone());
598            }
599        }
600        let params = webui_handler::route_handler::collect_nested_route_params(
601            context.protocol,
602            context.entry,
603            path,
604        );
605        for (key, value) in params {
606            map.insert(key, Value::String(value));
607        }
608    }
609    if let Some(token_css) = context.token_css {
610        webui_tokens::inject_token_css(&mut state, token_css);
611    }
612
613    Ok(state)
614}
615
616#[cfg(feature = "source")]
617fn resolve_config_token_css(
618    token_css: Option<HashMap<String, String>>,
619    theme: Option<(String, PathBuf)>,
620    protocol_tokens: &[String],
621) -> Result<Option<HashMap<String, String>>> {
622    if token_css.is_some() {
623        return Ok(token_css);
624    }
625    let Some((theme, search_root)) = theme else {
626        return Ok(None);
627    };
628    let theme_path = webui_tokens::resolve_theme_path(&theme, &search_root).map_err(|source| {
629        DesktopError::Token {
630            context: format!("resolving desktop theme {theme}"),
631            source,
632        }
633    })?;
634    let token_file =
635        webui_tokens::load_token_file(&theme_path).map_err(|source| DesktopError::Token {
636            context: format!("loading desktop theme {}", theme_path.display()),
637            source,
638        })?;
639    let resolved =
640        webui_tokens::resolve_tokens(protocol_tokens, &token_file).map_err(|source| {
641            DesktopError::Token {
642                context: "resolving desktop theme tokens".to_string(),
643                source,
644            }
645        })?;
646    Ok(Some(resolved.css))
647}
648
649fn read_state(path: Option<&PathBuf>) -> Result<Value> {
650    let Some(path) = path else {
651        return Ok(Value::Object(serde_json::Map::new()));
652    };
653
654    let json = fs::read_to_string(path).map_err(|source| DesktopError::Io {
655        context: format!("reading desktop state {}", path.display()),
656        source,
657    })?;
658    serde_json::from_str(&json).map_err(|source| DesktopError::StateJson {
659        path: path.clone(),
660        source,
661    })
662}
663
664fn canonical_bundle_root(bundle_dir: &Path) -> Result<PathBuf> {
665    bundle_dir
666        .canonicalize()
667        .map_err(|source| DesktopError::Io {
668            context: format!("resolving desktop bundle root {}", bundle_dir.display()),
669            source,
670        })
671}
672
673fn resolve_manifest_path(bundle_root: &Path, path: &Path, label: &str) -> Result<PathBuf> {
674    validate_manifest_relative_path(path, label)?;
675    let joined = bundle_root.join(path);
676    let canonical = joined.canonicalize().map_err(|source| DesktopError::Io {
677        context: format!("resolving desktop bundle {label} {}", joined.display()),
678        source,
679    })?;
680    if !canonical.starts_with(bundle_root) {
681        return Err(DesktopError::InvalidAssetPath {
682            path: format!("{label}: {}", path.display()),
683        });
684    }
685    Ok(canonical)
686}
687
688fn validate_manifest_relative_path(path: &Path, label: &str) -> Result<()> {
689    for component in path.components() {
690        match component {
691            Component::Normal(segment)
692                if segment
693                    .to_str()
694                    .is_some_and(|value| !value.contains('\\') && !value.contains('\0')) => {}
695            _ => {
696                return Err(DesktopError::InvalidAssetPath {
697                    path: format!("{label}: {}", path.display()),
698                });
699            }
700        }
701    }
702    Ok(())
703}
704
705#[cfg(feature = "source")]
706fn canonical_asset_root(path: Option<&PathBuf>) -> Result<Option<PathBuf>> {
707    match path {
708        Some(path) => path
709            .canonicalize()
710            .map(Some)
711            .map_err(|source| DesktopError::Io {
712                context: format!("resolving desktop asset root {}", path.display()),
713                source,
714            }),
715        None => Ok(None),
716    }
717}
718
719fn render_html(
720    protocol: &Protocol,
721    handler: &WebUIHandler,
722    entry: &str,
723    request_path: &str,
724    state: &Value,
725) -> Result<String> {
726    let mut writer = MemoryWriter::with_capacity(4096);
727    handler.render(
728        protocol,
729        state,
730        &RenderOptions::new(entry, request_path),
731        &mut writer,
732    )?;
733    Ok(writer.buf)
734}
735
736struct MemoryWriter {
737    buf: String,
738}
739
740impl MemoryWriter {
741    fn with_capacity(capacity: usize) -> Self {
742        Self {
743            buf: String::with_capacity(capacity),
744        }
745    }
746}
747
748impl ResponseWriter for MemoryWriter {
749    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
750        self.buf.push_str(content);
751        Ok(())
752    }
753
754    fn end(&mut self) -> webui_handler::Result<()> {
755        Ok(())
756    }
757}
758
759#[cfg(all(test, feature = "source"))]
760#[allow(clippy::disallowed_methods)]
761mod tests {
762    use super::*;
763    use tempfile::TempDir;
764
765    fn write_file(root: &std::path::Path, path: &str, content: &str) {
766        let full = root.join(path);
767        if let Some(parent) = full.parent() {
768            fs::create_dir_all(parent).unwrap();
769        }
770        fs::write(full, content).unwrap();
771    }
772
773    fn build_options(app_dir: PathBuf) -> webui::BuildOptions {
774        webui::BuildOptions {
775            app_dir,
776            entry: "index.html".to_string(),
777            // Scriptless WebUI fixtures provide exact template-derived
778            // navigation keys without requiring a client bundle.
779            plugin: Some(webui::Plugin::WebUI),
780            ..webui::BuildOptions::default()
781        }
782    }
783
784    #[test]
785    fn renders_startup_html_from_source() {
786        let dir = TempDir::new().unwrap();
787        write_file(dir.path(), "index.html", "<main>Hello {{name}}</main>");
788        write_file(dir.path(), "state.json", r#"{"name":"Desktop"}"#);
789
790        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
791        config.state_file = Some(dir.path().join("state.json"));
792
793        let runtime = DesktopRuntime::from_source(config).unwrap();
794
795        assert!(runtime.startup_html().contains("Hello Desktop"));
796    }
797
798    #[test]
799    fn source_mode_injects_window_css_like_packaged_mode() {
800        let dir = TempDir::new().unwrap();
801        write_file(dir.path(), "index.html", "<main>Hello {{name}}</main>");
802        write_file(dir.path(), "state.json", r#"{"name":"Desktop"}"#);
803
804        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
805        config.state_file = Some(dir.path().join("state.json"));
806        config.window = crate::WindowOptions {
807            titlebar: crate::TitlebarStyle::HiddenInset,
808            background: Some("#101014".parse().unwrap()),
809            ..crate::WindowOptions::default()
810        };
811
812        let runtime = DesktopRuntime::from_source(config).unwrap();
813
814        // Development renders must carry the same titlebar custom properties as
815        // packaged renders, otherwise a custom titlebar only works once shipped.
816        assert!(runtime
817            .startup_html()
818            .contains("--webui-titlebar-inset-start"));
819        assert!(runtime.startup_html().contains("--webui-window-background"));
820    }
821
822    #[test]
823    fn non_root_route_renders_carry_window_css() {
824        let dir = TempDir::new().unwrap();
825        write_file(
826            dir.path(),
827            "index.html",
828            "<route path=\"/\" component=\"my-page\"><route path=\"favorites\" component=\"my-page\" exact /></route>",
829        );
830        write_file(dir.path(), "my-page.html", "<p>{{page}}</p>");
831
832        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
833        config.window = crate::WindowOptions {
834            titlebar: crate::TitlebarStyle::HiddenInset,
835            ..crate::WindowOptions::default()
836        };
837        config
838            .route_state
839            .route("/favorites", |_| {
840                Ok(serde_json::json!({ "page": "favorites" }))
841            })
842            .unwrap();
843
844        let runtime = DesktopRuntime::from_source(config).unwrap();
845        let response = runtime
846            .handle_request(&DesktopProtocolRequest::get("/favorites"))
847            .unwrap();
848
849        assert_eq!(response.status, 200);
850        let html = std::str::from_utf8(response.body.as_bytes().unwrap().as_slice()).unwrap();
851        // A full page load on a non-root route must carry the same titlebar
852        // custom properties as the startup document, or a custom titlebar
853        // collapses as soon as the user navigates.
854        assert!(html.contains("--webui-titlebar-inset-start"));
855    }
856
857    #[test]
858    fn live_background_updates_root_and_future_route_documents() {
859        let dir = TempDir::new().unwrap();
860        write_file(
861            dir.path(),
862            "index.html",
863            "<route path=\"/\" component=\"my-page\"><route path=\"favorites\" component=\"my-page\" exact /></route>",
864        );
865        write_file(dir.path(), "my-page.html", "<p>Contacts</p>");
866        let initial = "#101014".parse().unwrap();
867        let updated = "#334455cc".parse().unwrap();
868        let window = crate::WindowOptions {
869            background: Some(initial),
870            ..crate::WindowOptions::default()
871        };
872        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
873        config.window = window.clone();
874        let runtime = std::sync::Arc::new(DesktopRuntime::from_source(config).unwrap());
875        let frame = crate::DesktopFrame::new(std::sync::Arc::clone(&runtime), window).unwrap();
876
877        assert!(frame.runtime().startup_html().contains("#101014"));
878        frame.window_handle().set_background(updated).unwrap();
879        for path in ["/", "/favorites"] {
880            let response = runtime
881                .handle_request(&DesktopProtocolRequest::get(path))
882                .unwrap();
883            let body = response.body.as_bytes().unwrap();
884            let html = std::str::from_utf8(body.as_slice()).unwrap();
885            assert!(
886                html.contains("--webui-window-background:#334455cc"),
887                "live background was missing from {path}"
888            );
889            assert!(
890                html.rfind("#334455cc") > html.rfind("#101014"),
891                "the original background overrode the live setting on {path}"
892            );
893        }
894        frame
895            .window_handle()
896            .set_background("#abcdef".parse().unwrap())
897            .unwrap();
898        let response = runtime
899            .handle_request(&DesktopProtocolRequest::get("/favorites"))
900            .unwrap();
901        let html = std::str::from_utf8(response.body.as_bytes().unwrap().as_slice()).unwrap();
902        assert!(html.contains("--webui-window-background:#abcdef"));
903        assert!(!html.contains("#334455cc"));
904    }
905
906    #[test]
907    fn live_background_adds_document_styling_when_none_was_configured() {
908        let dir = TempDir::new().unwrap();
909        write_file(
910            dir.path(),
911            "index.html",
912            "<!doctype html><html><head></head><body><main>Hello</main></body></html>",
913        );
914        let window = crate::WindowOptions::default();
915        let config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
916        let runtime = std::sync::Arc::new(DesktopRuntime::from_source(config).unwrap());
917        let frame = crate::DesktopFrame::new(std::sync::Arc::clone(&runtime), window).unwrap();
918
919        assert!(!runtime.startup_html().contains("--webui-window-background"));
920        frame
921            .window_handle()
922            .set_background("#aabbcc".parse().unwrap())
923            .unwrap();
924        let response = runtime
925            .handle_request(&DesktopProtocolRequest::get("/"))
926            .unwrap();
927        let html = std::str::from_utf8(response.body.as_bytes().unwrap().as_slice()).unwrap();
928        assert!(html.contains(
929            "<style>:root{--webui-window-background:#aabbcc}html{background:var(--webui-window-background)}</style></head>"
930        ));
931    }
932
933    #[test]
934    fn serves_static_asset_with_traversal_protection() {
935        let dir = TempDir::new().unwrap();
936        write_file(dir.path(), "index.html", "<main>Hello</main>");
937        write_file(dir.path(), "assets/app.js", "console.log('ok');");
938        write_file(dir.path(), "assets/config.json", r#"{"ok":true}"#);
939
940        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
941        config.asset_root = Some(dir.path().join("assets"));
942        let runtime = DesktopRuntime::from_source(config).unwrap();
943
944        let response = runtime
945            .handle_request(&DesktopProtocolRequest::get("/app.js"))
946            .unwrap();
947        assert_eq!(response.status, 200);
948        assert_eq!(response.content_type, "text/javascript");
949
950        let json_asset = runtime
951            .handle_request(&DesktopProtocolRequest {
952                method: DesktopHttpMethod::Get,
953                path: "/config.json",
954                body: &[],
955                wants_json: true,
956            })
957            .unwrap();
958        assert_eq!(json_asset.status, 200);
959        assert_eq!(json_asset.content_type, "application/json");
960        assert_eq!(json_asset.body.into_bytes().unwrap(), br#"{"ok":true}"#);
961
962        let err = runtime
963            .handle_request(&DesktopProtocolRequest::get("/%2e%2e/index.html"))
964            .unwrap_err();
965        assert!(matches!(err, DesktopError::InvalidAssetPath { .. }));
966    }
967
968    #[test]
969    #[cfg(feature = "application-ipc")]
970    fn dispatches_ipc_through_the_owning_frame() {
971        let dir = TempDir::new().unwrap();
972        write_file(dir.path(), "index.html", "<main>Hello</main>");
973
974        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
975        config.ipc_registry = crate::ipc_test_support::registry();
976        let runtime = DesktopRuntime::from_source(config).unwrap();
977        assert!(matches!(
978            runtime.handle_request(&DesktopProtocolRequest::post(IPC_ENDPOINT, b"unadmitted")),
979            Err(DesktopError::UnsupportedRuntime { .. })
980        ));
981        let frame = crate::DesktopFrame::with_ipc_options(
982            Arc::new(runtime),
983            crate::WindowOptions::default(),
984            crate::ipc_test_support::options(),
985        )
986        .unwrap();
987        crate::ipc_test_support::assert_echo(&frame, b"ping");
988    }
989
990    #[test]
991    fn dispatches_custom_api_route_before_assets_and_router() {
992        let dir = TempDir::new().unwrap();
993        write_file(dir.path(), "index.html", "<main>Hello</main>");
994
995        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
996        config
997            .api_routes
998            .route("/api/contacts/:id", |ctx| {
999                assert_eq!(ctx.path, "/api/contacts/42");
1000                assert_eq!(ctx.param("id"), Some("42"));
1001                assert_eq!(ctx.method, &DesktopHttpMethod::Post);
1002                assert_eq!(ctx.body, br#"{"ok":true}"#);
1003                Ok(DesktopProtocolResponse::new(
1004                    201,
1005                    "application/json",
1006                    br#"{"id":"42"}"#.to_vec(),
1007                ))
1008            })
1009            .unwrap();
1010        let runtime = DesktopRuntime::from_source(config).unwrap();
1011
1012        let response = runtime
1013            .handle_request(&DesktopProtocolRequest {
1014                method: DesktopHttpMethod::Post,
1015                path: "/api/contacts/42",
1016                body: br#"{"ok":true}"#,
1017                wants_json: true,
1018            })
1019            .unwrap();
1020
1021        assert_eq!(response.status, 201);
1022        assert_eq!(response.content_type, "application/json");
1023        assert_eq!(response.body, br#"{"id":"42"}"#);
1024    }
1025
1026    #[test]
1027    fn router_json_request_returns_partial_with_state() {
1028        let dir = TempDir::new().unwrap();
1029        write_file(
1030            dir.path(),
1031            "index.html",
1032            "<route path=\"/\" component=\"my-page\"><route path=\"contacts/:id\" component=\"my-page\" exact /></route>",
1033        );
1034        write_file(dir.path(), "my-page.html", "<p>{{title}}</p>");
1035        write_file(dir.path(), "state.json", r#"{"title":"Contact"}"#);
1036
1037        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1038        config.state_file = Some(dir.path().join("state.json"));
1039        let runtime = DesktopRuntime::from_source(config).unwrap();
1040
1041        let response = runtime
1042            .handle_request(&DesktopProtocolRequest {
1043                method: DesktopHttpMethod::Get,
1044                path: "/contacts/42",
1045                body: &[],
1046                wants_json: true,
1047            })
1048            .unwrap();
1049
1050        assert_eq!(response.status, 200);
1051        assert_eq!(response.content_type, "application/json");
1052        let json: Value = serde_json::from_slice(response.body.as_bytes().unwrap()).unwrap();
1053        assert_eq!(json["path"], "/contacts/42");
1054        assert_eq!(json["state"]["title"], "Contact");
1055    }
1056
1057    #[test]
1058    fn bundle_runtime_preserves_rust_route_providers() {
1059        let dir = TempDir::new().unwrap();
1060        let app = dir.path().join("app");
1061        let bundle = dir.path().join("bundle");
1062        write_file(
1063            &app,
1064            "index.html",
1065            "<route path=\"/\" component=\"my-page\"><route path=\"users/:id\" component=\"my-page\" exact /></route>",
1066        );
1067        write_file(&app, "my-page.html", "<p>{{name}}</p>");
1068
1069        crate::build_desktop_bundle(crate::DesktopBundleOptions {
1070            build_options: build_options(app),
1071            out_dir: bundle.clone(),
1072            state_file: None,
1073            asset_root: None,
1074            token_css: None,
1075            app_id: "com.microsoft.webui.bundle".to_string(),
1076            app_name: "Bundle Host".to_string(),
1077            version: "0.0.0".to_string(),
1078            publisher: "Microsoft".to_string(),
1079            window: crate::WindowOptions::default(),
1080            icon_file: None,
1081            shell: crate::DesktopShellConfig::default(),
1082            package_targets: Vec::new(),
1083        })
1084        .unwrap();
1085
1086        let mut config = DesktopBundleConfig::new(bundle);
1087        config
1088            .route_state
1089            .route("/users/:id", |ctx| {
1090                let mut map = serde_json::Map::new();
1091                map.insert(
1092                    "name".to_string(),
1093                    Value::String(ctx.param("id").unwrap_or_default().to_string()),
1094                );
1095                Ok(Value::Object(map))
1096            })
1097            .unwrap();
1098        let runtime = DesktopRuntime::from_bundle_config(config).unwrap();
1099
1100        let state = partial_state(&runtime, "/users/ada");
1101        assert_eq!(state["name"], "ada");
1102    }
1103
1104    #[test]
1105    fn bundle_runtime_serves_manifest_indexed_assets() {
1106        let dir = TempDir::new().unwrap();
1107        let app = dir.path().join("app");
1108        let bundle = dir.path().join("bundle");
1109        write_file(&app, "index.html", "<main>Hello</main>");
1110        write_file(&app, "public/app.js", "console.log('bundle');");
1111
1112        crate::build_desktop_bundle(crate::DesktopBundleOptions {
1113            build_options: build_options(app.clone()),
1114            out_dir: bundle.clone(),
1115            state_file: None,
1116            asset_root: Some(app.join("public")),
1117            token_css: None,
1118            app_id: "com.microsoft.webui.bundle".to_string(),
1119            app_name: "Bundle Host".to_string(),
1120            version: "0.0.0".to_string(),
1121            publisher: "Microsoft".to_string(),
1122            window: crate::WindowOptions::default(),
1123            icon_file: None,
1124            shell: crate::DesktopShellConfig::default(),
1125            package_targets: Vec::new(),
1126        })
1127        .unwrap();
1128
1129        let runtime = DesktopRuntime::from_bundle(bundle).unwrap();
1130        let response = runtime
1131            .handle_request(&DesktopProtocolRequest::get("/app.js?v=1"))
1132            .unwrap();
1133
1134        assert_eq!(response.status, 200);
1135        assert_eq!(response.content_type, "text/javascript");
1136        assert_eq!(
1137            response.body.into_bytes().unwrap(),
1138            b"console.log('bundle');"
1139        );
1140
1141        let err = runtime
1142            .handle_request(&DesktopProtocolRequest::get("/%2e%2e/protocol.bin"))
1143            .unwrap_err();
1144        assert!(matches!(err, DesktopError::InvalidAssetPath { .. }));
1145    }
1146
1147    #[test]
1148    fn bundle_runtime_rejects_manifest_paths_outside_bundle() {
1149        let dir = TempDir::new().unwrap();
1150        let app = dir.path().join("app");
1151        let bundle = dir.path().join("bundle");
1152        write_file(&app, "index.html", "<main>Hello</main>");
1153
1154        crate::build_desktop_bundle(crate::DesktopBundleOptions {
1155            build_options: build_options(app),
1156            out_dir: bundle.clone(),
1157            state_file: None,
1158            asset_root: None,
1159            token_css: None,
1160            app_id: "com.microsoft.webui.bundle".to_string(),
1161            app_name: "Bundle Host".to_string(),
1162            version: "0.0.0".to_string(),
1163            publisher: "Microsoft".to_string(),
1164            window: crate::WindowOptions::default(),
1165            icon_file: None,
1166            shell: crate::DesktopShellConfig::default(),
1167            package_targets: Vec::new(),
1168        })
1169        .unwrap();
1170
1171        let manifest_path = bundle.join("manifest.webui-desktop.json");
1172        let mut manifest: Value =
1173            serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap()).unwrap();
1174        manifest["protocol_path"] = Value::String("../protocol.bin".to_string());
1175        fs::write(
1176            &manifest_path,
1177            serde_json::to_vec_pretty(&manifest).unwrap(),
1178        )
1179        .unwrap();
1180
1181        let err = match DesktopRuntime::from_bundle(bundle) {
1182            Ok(_) => panic!("expected manifest path validation error"),
1183            Err(err) => err,
1184        };
1185        assert!(matches!(err, DesktopError::InvalidAssetPath { .. }));
1186    }
1187
1188    #[test]
1189    fn file_backed_state_is_not_route_scoped_without_provider() {
1190        let dir = TempDir::new().unwrap();
1191        write_file(
1192            dir.path(),
1193            "index.html",
1194            "<route path=\"/\" component=\"my-page\"><route path=\"contacts/:id\" component=\"my-page\" exact /><route path=\"favorites\" component=\"my-page\" exact /><route path=\"groups/:group\" component=\"my-page\" exact /></route>",
1195        );
1196        write_file(dir.path(), "my-page.html", "<p>{{page}}</p>");
1197        write_file(
1198            dir.path(),
1199            "state.json",
1200            r##"{
1201              "groups":["Work","Friends"],
1202              "contacts":[
1203                {"id":"1","firstName":"Ada","lastName":"Lovelace","group":"Work","favorite":true,"initials":"AL","avatarColor":"#fff","email":"ada@example.com","phone":"1","company":"","notes":"","address":""},
1204                {"id":"2","firstName":"Grace","lastName":"Hopper","group":"Friends","favorite":false,"initials":"GH","avatarColor":"#000","email":"grace@example.com","phone":"2","company":"","notes":"","address":""}
1205              ]
1206            }"##,
1207        );
1208
1209        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1210        config.state_file = Some(dir.path().join("state.json"));
1211        let runtime = DesktopRuntime::from_source(config).unwrap();
1212
1213        let favorites = runtime.state_for_request("/favorites").unwrap();
1214        assert!(favorites.get("page").is_none());
1215        assert_eq!(favorites["contacts"].as_array().map(Vec::len), Some(2));
1216        assert_eq!(partial_state(&runtime, "/favorites"), serde_json::json!({}));
1217    }
1218
1219    #[test]
1220    fn rust_route_provider_overrides_file_backed_state() {
1221        let dir = TempDir::new().unwrap();
1222        write_file(
1223            dir.path(),
1224            "index.html",
1225            "<route path=\"/\" component=\"my-page\"><route path=\"contacts/:id\" component=\"my-page\" exact /></route>",
1226        );
1227        write_file(
1228            dir.path(),
1229            "my-page.html",
1230            "<p>{{name}} {{id}}</p><a href=\"{{basePath}}\">Home</a>",
1231        );
1232        write_file(dir.path(), "state.json", r#"{"name":"base"}"#);
1233
1234        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1235        config.state_file = Some(dir.path().join("state.json"));
1236        config
1237            .route_state
1238            .route("/contacts/:id", |ctx| {
1239                Ok(serde_json::json!({
1240                    "name": "provider",
1241                    "id": ctx.param("id").unwrap_or("")
1242                }))
1243            })
1244            .unwrap();
1245        let runtime = DesktopRuntime::from_source(config).unwrap();
1246
1247        let state = partial_state(&runtime, "/contacts/42");
1248        assert_eq!(state["name"], "provider");
1249        assert_eq!(state["id"], "42");
1250        assert_eq!(state["basePath"], "/");
1251    }
1252
1253    #[test]
1254    fn route_provider_state_preserves_seed_tokens() {
1255        let dir = TempDir::new().unwrap();
1256        write_file(
1257            dir.path(),
1258            "index.html",
1259            "<html><head><style>:root{/*{{{tokens.light}}}*/}</style></head><body>\
1260             <route path=\"/\" component=\"my-page\"><route path=\"contacts\" component=\"my-page\" exact /></route>\
1261             </body></html>",
1262        );
1263        write_file(dir.path(), "my-page.html", "<p>{{page}}</p>");
1264        write_file(
1265            dir.path(),
1266            "state.json",
1267            r#"{"tokens":{"light":"--font-family-base: system-ui;"}}"#,
1268        );
1269
1270        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1271        config.state_file = Some(dir.path().join("state.json"));
1272        config
1273            .route_state
1274            .route("/contacts", |_| {
1275                let mut map = serde_json::Map::new();
1276                map.insert("page".to_string(), Value::String("contacts".to_string()));
1277                Ok(Value::Object(map))
1278            })
1279            .unwrap();
1280        let runtime = DesktopRuntime::from_source(config).unwrap();
1281
1282        let state = runtime.state_for_request("/contacts").unwrap();
1283
1284        assert_eq!(
1285            state["tokens"]["light"],
1286            Value::String("--font-family-base: system-ui;".to_string())
1287        );
1288        assert!(partial_state(&runtime, "/contacts").get("tokens").is_none());
1289        let response = runtime
1290            .handle_request(&DesktopProtocolRequest::get("/contacts"))
1291            .unwrap();
1292        let html = std::str::from_utf8(response.body.as_bytes().unwrap()).unwrap();
1293        assert!(html.contains(":root{--font-family-base: system-ui;}"));
1294    }
1295
1296    #[test]
1297    fn source_and_bundle_partials_match_web_projection() {
1298        let dir = TempDir::new().unwrap();
1299        let app = dir.path().join("app");
1300        write_file(
1301            &app,
1302            "index.html",
1303            "<route path=\"/\" component=\"app-shell\">\
1304             <route path=\"contacts/:id\" component=\"contact-page\" exact />\
1305             <route path=\"reports\" component=\"report-page\" exact />\
1306             </route>",
1307        );
1308        write_file(
1309            &app,
1310            "app-shell.html",
1311            "<header>{{mode}}</header><outlet />",
1312        );
1313        write_file(
1314            &app,
1315            "contact-page.html",
1316            "<p>{{name}} {{id}}</p><if condition=\"expanded\"><p>{{details}}</p></if>",
1317        );
1318        write_file(&app, "report-page.html", "<p>{{unusedReports.length}}</p>");
1319        let state_file = dir.path().join("state.json");
1320        let seed = serde_json::json!({
1321            "mode": "desktop", "name": "Ada", "expanded": false, "details": "Ready on demand",
1322            "unusedReports": ["not needed by this route"],
1323            "$webui": {"bodyEnd": "<script>host-only</script>"}
1324        });
1325        fs::write(&state_file, serde_json::to_vec(&seed).unwrap()).unwrap();
1326
1327        for bundled in [false, true] {
1328            let runtime = if bundled {
1329                let bundle = dir.path().join("bundle");
1330                crate::build_desktop_bundle(crate::DesktopBundleOptions {
1331                    build_options: build_options(app.clone()),
1332                    out_dir: bundle.clone(),
1333                    state_file: Some(state_file.clone()),
1334                    asset_root: None,
1335                    token_css: None,
1336                    app_id: "webui.test.projection".into(),
1337                    app_name: "Projection".into(),
1338                    version: "0.0.0".into(),
1339                    publisher: "Microsoft".into(),
1340                    window: crate::WindowOptions::default(),
1341                    icon_file: None,
1342                    shell: crate::DesktopShellConfig::default(),
1343                    package_targets: Vec::new(),
1344                })
1345                .unwrap();
1346                DesktopRuntime::from_bundle(bundle).unwrap()
1347            } else {
1348                let mut config = DesktopSourceConfig::new(build_options(app.clone()));
1349                config.state_file = Some(state_file.clone());
1350                DesktopRuntime::from_source(config).unwrap()
1351            };
1352            let path = "/contacts/42";
1353            let response = runtime
1354                .handle_request(&DesktopProtocolRequest {
1355                    method: DesktopHttpMethod::Get,
1356                    path,
1357                    body: &[],
1358                    wants_json: true,
1359                })
1360                .unwrap();
1361            assert_eq!(response.status, 200);
1362            let bytes = response.body.as_bytes().unwrap();
1363            let web = runtime
1364                .protocol
1365                .render_partial_json(
1366                    &runtime.state_for_request(path).unwrap().to_string(),
1367                    &runtime.entry,
1368                    path,
1369                    "",
1370                )
1371                .unwrap();
1372            assert_eq!(bytes, web.as_bytes());
1373            let wire: Value = serde_json::from_slice(bytes).unwrap();
1374            assert_eq!(
1375                wire["state"],
1376                serde_json::json!({
1377                    "mode": "desktop", "name": "Ada", "id": "42",
1378                    "expanded": false, "details": "Ready on demand"
1379                })
1380            );
1381            assert_eq!(wire["chain"][1]["params"]["id"], "42");
1382            assert!(wire.get("matched").is_none());
1383        }
1384    }
1385
1386    #[test]
1387    fn missing_script_metadata_keeps_dynamic_fields_but_not_host_injection() {
1388        let dir = TempDir::new().unwrap();
1389        write_file(
1390            dir.path(),
1391            "index.html",
1392            "<route path=\"/\" component=\"my-page\" />",
1393        );
1394        write_file(dir.path(), "my-page.html", "<p>{{name}}</p>");
1395        write_file(dir.path(), "my-page.ts", "export {};");
1396        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1397        config.state = Some(serde_json::json!({
1398            "name": "Ada", "dynamicField": [1, 2],
1399            "$webui": {"bodyEnd": "<script>host-only</script>"}
1400        }));
1401        let runtime = DesktopRuntime::from_source(config).unwrap();
1402        let state = partial_state(&runtime, "/");
1403        assert_eq!(state["name"], "Ada");
1404        assert_eq!(state["dynamicField"], serde_json::json!([1, 2]));
1405        assert!(state.get("$webui").is_none());
1406    }
1407
1408    #[test]
1409    fn route_provider_errors_are_returned() {
1410        let dir = TempDir::new().unwrap();
1411        write_file(
1412            dir.path(),
1413            "index.html",
1414            "<route path=\"/\" component=\"my-page\"><route path=\"contacts/:id\" component=\"my-page\" exact /></route>",
1415        );
1416        write_file(dir.path(), "my-page.html", "<p>{{name}}</p>");
1417
1418        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1419        config
1420            .route_state
1421            .route("/contacts/:id", |_| {
1422                Err(DesktopError::UnsupportedRuntime {
1423                    message: "state store unavailable".to_string(),
1424                    help: "initialize the state store before running the desktop app".to_string(),
1425                })
1426            })
1427            .unwrap();
1428        let runtime = DesktopRuntime::from_source(config).unwrap();
1429
1430        let err = runtime
1431            .handle_request(&DesktopProtocolRequest {
1432                method: DesktopHttpMethod::Get,
1433                path: "/contacts/42",
1434                body: &[],
1435                wants_json: true,
1436            })
1437            .unwrap_err();
1438
1439        assert!(matches!(err, DesktopError::RouteProvider { .. }));
1440    }
1441
1442    #[test]
1443    fn dotted_route_segments_are_not_treated_as_assets() {
1444        let dir = TempDir::new().unwrap();
1445        write_file(
1446            dir.path(),
1447            "index.html",
1448            "<route path=\"/\" component=\"my-page\"><route path=\"users/:id\" component=\"my-page\" exact /></route>",
1449        );
1450        write_file(dir.path(), "my-page.html", "<p>{{id}}</p>");
1451
1452        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1453        config
1454            .route_state
1455            .route("/users/:id", |ctx| {
1456                let mut map = serde_json::Map::new();
1457                map.insert(
1458                    "id".to_string(),
1459                    Value::String(ctx.param("id").unwrap_or_default().to_string()),
1460                );
1461                Ok(Value::Object(map))
1462            })
1463            .unwrap();
1464        let runtime = DesktopRuntime::from_source(config).unwrap();
1465
1466        let html = runtime
1467            .handle_request(&DesktopProtocolRequest::get("/users/jane.doe"))
1468            .unwrap();
1469        assert_eq!(html.status, 200);
1470        assert_eq!(html.content_type, "text/html; charset=utf-8");
1471
1472        let missing = runtime
1473            .handle_request(&DesktopProtocolRequest::get("/missing.js"))
1474            .unwrap();
1475        assert_eq!(missing.status, 404);
1476    }
1477
1478    #[test]
1479    fn query_strings_do_not_break_route_matching() {
1480        let dir = TempDir::new().unwrap();
1481        write_file(
1482            dir.path(),
1483            "index.html",
1484            "<route path=\"/\" component=\"my-page\"><route path=\"favorites\" component=\"my-page\" exact /></route>",
1485        );
1486        write_file(dir.path(), "my-page.html", "<p>{{page}}</p>");
1487
1488        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1489        config
1490            .route_state
1491            .route("/favorites", |_| {
1492                let mut map = serde_json::Map::new();
1493                map.insert("page".to_string(), Value::String("favorites".to_string()));
1494                Ok(Value::Object(map))
1495            })
1496            .unwrap();
1497        let runtime = DesktopRuntime::from_source(config).unwrap();
1498
1499        let response = runtime
1500            .handle_request(&DesktopProtocolRequest {
1501                method: DesktopHttpMethod::Get,
1502                path: "/favorites?sort=asc",
1503                body: &[],
1504                wants_json: true,
1505            })
1506            .unwrap();
1507        assert_eq!(response.status, 200);
1508        let json: Value = serde_json::from_slice(response.body.as_bytes().unwrap()).unwrap();
1509        assert_eq!(json["path"], "/favorites");
1510        assert_eq!(json["state"]["page"], "favorites");
1511    }
1512
1513    #[test]
1514    fn query_strings_do_not_break_html_route_rendering() {
1515        let dir = TempDir::new().unwrap();
1516        write_file(
1517            dir.path(),
1518            "index.html",
1519            "<route path=\"/\" component=\"my-page\"><route path=\"favorites\" component=\"my-page\" exact /></route>",
1520        );
1521        write_file(dir.path(), "my-page.html", "<p>{{page}}</p>");
1522
1523        let mut config = DesktopSourceConfig::new(build_options(dir.path().to_path_buf()));
1524        config
1525            .route_state
1526            .route("/favorites", |_| {
1527                let mut map = serde_json::Map::new();
1528                map.insert("page".to_string(), Value::String("favorites".to_string()));
1529                Ok(Value::Object(map))
1530            })
1531            .unwrap();
1532        let runtime = DesktopRuntime::from_source(config).unwrap();
1533
1534        let response = runtime
1535            .handle_request(&DesktopProtocolRequest::get("/favorites?sort=asc"))
1536            .unwrap();
1537
1538        assert_eq!(response.status, 200);
1539        let html = std::str::from_utf8(response.body.as_bytes().unwrap().as_slice()).unwrap();
1540        assert!(html.contains("<p>favorites</p>"));
1541    }
1542
1543    fn partial_state(runtime: &DesktopRuntime, path: &str) -> Value {
1544        let response = runtime
1545            .handle_request(&DesktopProtocolRequest {
1546                method: DesktopHttpMethod::Get,
1547                path,
1548                body: &[],
1549                wants_json: true,
1550            })
1551            .unwrap();
1552        let json: Value = serde_json::from_slice(response.body.as_bytes().unwrap()).unwrap();
1553        json["state"].clone()
1554    }
1555}