1use 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#[cfg(feature = "source")]
34pub struct DesktopSourceConfig {
35 pub build_options: webui::BuildOptions,
37 pub state_file: Option<PathBuf>,
39 pub state: Option<Value>,
41 pub asset_root: Option<PathBuf>,
43 pub max_asset_bytes: u64,
45 #[cfg(feature = "application-ipc")]
47 pub ipc_registry: IpcRegistry,
48 pub route_state: RouteStateRegistry,
50 pub api_routes: ApiRouteRegistry,
52 pub token_css: Option<HashMap<String, String>>,
54 pub theme: Option<(String, PathBuf)>,
56 pub window: crate::WindowOptions,
62}
63
64#[cfg(feature = "source")]
65impl DesktopSourceConfig {
66 #[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
86pub struct DesktopBundleConfig {
92 pub bundle_dir: PathBuf,
94 pub state: Option<Value>,
96 pub max_asset_bytes: u64,
98 #[cfg(feature = "application-ipc")]
100 pub ipc_registry: IpcRegistry,
101 pub route_state: RouteStateRegistry,
103 pub api_routes: ApiRouteRegistry,
105 pub token_css: Option<HashMap<String, String>>,
107}
108
109impl DesktopBundleConfig {
110 #[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
126pub 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 #[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 pub fn from_bundle(bundle_dir: PathBuf) -> Result<Self> {
230 Self::from_bundle_config(DesktopBundleConfig::new(bundle_dir))
231 }
232
233 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 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 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 #[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 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 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 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}