1use serde::Serialize;
5use serde_json::Value;
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use crate::error::{DesktopError, Result};
11use crate::frame::DesktopFrame;
12#[cfg(feature = "application-ipc")]
13use crate::ipc::{IpcOptions, IpcRegistry};
14#[cfg(feature = "source")]
15use crate::runtime::DesktopSourceConfig;
16use crate::runtime::{DesktopBundleConfig, DesktopRuntime};
17use crate::{DesktopBundleManifest, DesktopShellConfig, WindowOptions};
18
19enum AppInput {
20 #[cfg(feature = "source")]
21 Source(Box<SourceApp>),
22 Bundle(Box<BundleApp>),
23}
24
25#[cfg(feature = "source")]
26struct SourceApp {
27 config: DesktopSourceConfig,
28 shell: DesktopShellConfig,
29 app_id: Option<String>,
30}
31
32struct BundleApp {
33 config: DesktopBundleConfig,
34 manifest: DesktopBundleManifest,
35}
36
37pub struct DesktopAppBuilder {
43 input: AppInput,
44 #[cfg(feature = "application-ipc")]
45 ipc_options: IpcOptions,
46}
47
48impl DesktopAppBuilder {
49 #[must_use]
51 pub fn window(mut self, window: WindowOptions) -> Self {
52 match &mut self.input {
53 #[cfg(feature = "source")]
54 AppInput::Source(app) => app.config.window = window,
55 AppInput::Bundle(app) => app.manifest.window = window,
56 }
57 self
58 }
59
60 #[must_use]
62 pub fn shell(mut self, shell: DesktopShellConfig) -> Self {
63 match &mut self.input {
64 #[cfg(feature = "source")]
65 AppInput::Source(app) => app.shell = shell,
66 AppInput::Bundle(app) => app.manifest.shell = shell,
67 }
68 self
69 }
70
71 #[must_use]
73 pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
74 let app_id = app_id.into();
75 match &mut self.input {
76 #[cfg(feature = "source")]
77 AppInput::Source(app) => app.app_id = Some(app_id),
78 AppInput::Bundle(app) => app.manifest.app_id = app_id,
79 }
80 self
81 }
82
83 #[must_use]
85 pub fn max_asset_bytes(mut self, max_asset_bytes: u64) -> Self {
86 match &mut self.input {
87 #[cfg(feature = "source")]
88 AppInput::Source(app) => app.config.max_asset_bytes = max_asset_bytes,
89 AppInput::Bundle(app) => app.config.max_asset_bytes = max_asset_bytes,
90 }
91 self
92 }
93
94 pub fn state<T: Serialize>(self, state: &T) -> Result<Self> {
100 let value = serde_json::to_value(state).map_err(|source| DesktopError::Serialization {
101 context: "serializing desktop app state".to_string(),
102 source,
103 })?;
104 Ok(self.state_value(value))
105 }
106
107 #[must_use]
109 pub fn state_value(mut self, state: Value) -> Self {
110 match &mut self.input {
111 #[cfg(feature = "source")]
112 AppInput::Source(app) => app.config.state = Some(state),
113 AppInput::Bundle(app) => app.config.state = Some(state),
114 }
115 self
116 }
117
118 #[must_use]
120 pub fn token_css(mut self, token_css: HashMap<String, String>) -> Self {
121 match &mut self.input {
122 #[cfg(feature = "source")]
123 AppInput::Source(app) => app.config.token_css = Some(token_css),
124 AppInput::Bundle(app) => app.config.token_css = Some(token_css),
125 }
126 self
127 }
128
129 #[must_use]
131 #[cfg(feature = "application-ipc")]
132 pub fn ipc_registry(mut self, registry: IpcRegistry) -> Self {
133 match &mut self.input {
134 #[cfg(feature = "source")]
135 AppInput::Source(app) => app.config.ipc_registry = registry,
136 AppInput::Bundle(app) => app.config.ipc_registry = registry,
137 }
138 self
139 }
140
141 #[must_use]
146 #[cfg(feature = "application-ipc")]
147 pub fn ipc_options(mut self, options: IpcOptions) -> Self {
148 self.ipc_options = options;
149 self
150 }
151
152 pub fn route<F>(mut self, pattern: impl AsRef<str>, handler: F) -> Result<Self>
161 where
162 F: Fn(crate::RouteContext<'_>) -> Result<Value> + Send + Sync + 'static,
163 {
164 match &mut self.input {
165 #[cfg(feature = "source")]
166 AppInput::Source(app) => app.config.route_state.route(pattern, handler)?,
167 AppInput::Bundle(app) => app.config.route_state.route(pattern, handler)?,
168 }
169 Ok(self)
170 }
171
172 pub fn api_route<F>(mut self, pattern: impl AsRef<str>, handler: F) -> Result<Self>
181 where
182 F: Fn(crate::ApiContext<'_>) -> Result<crate::DesktopProtocolResponse>
183 + Send
184 + Sync
185 + 'static,
186 {
187 match &mut self.input {
188 #[cfg(feature = "source")]
189 AppInput::Source(app) => app.config.api_routes.route(pattern, handler)?,
190 AppInput::Bundle(app) => app.config.api_routes.route(pattern, handler)?,
191 }
192 Ok(self)
193 }
194
195 pub fn build(self) -> Result<DesktopFrame> {
202 match self.input {
203 #[cfg(feature = "source")]
204 AppInput::Source(app) => {
205 let SourceApp {
206 config,
207 shell,
208 app_id,
209 } = *app;
210 let window = config.window.clone();
211 let runtime = DesktopRuntime::from_source(config)?;
212 #[cfg(feature = "application-ipc")]
213 let frame =
214 DesktopFrame::with_ipc_options(Arc::new(runtime), window, self.ipc_options)?;
215 #[cfg(not(feature = "application-ipc"))]
216 let frame = DesktopFrame::new(Arc::new(runtime), window)?;
217 let mut frame = frame.with_shell(shell);
218 frame.app_id = app_id;
219 Ok(frame)
220 }
221 AppInput::Bundle(app) => {
222 let BundleApp { config, manifest } = *app;
223 let window = manifest.window.clone();
224 let shell = manifest.shell.clone();
225 let app_id = manifest.app_id.clone();
226 let runtime = DesktopRuntime::from_bundle_config_and_manifest(config, manifest)?;
227 #[cfg(feature = "application-ipc")]
228 let frame =
229 DesktopFrame::with_ipc_options(Arc::new(runtime), window, self.ipc_options)?;
230 #[cfg(not(feature = "application-ipc"))]
231 let frame = DesktopFrame::new(Arc::new(runtime), window)?;
232 Ok(frame.with_shell(shell).with_app_id(app_id))
233 }
234 }
235 }
236}
237
238pub struct DesktopApp;
240
241impl DesktopApp {
242 #[cfg(feature = "local-server")]
246 #[must_use]
247 pub fn from_local_server(options: crate::LocalServerOptions) -> crate::LocalServerAppBuilder {
248 crate::LocalServerAppBuilder::new(options)
249 }
250 #[cfg(feature = "source")]
255 #[must_use]
256 pub fn from_source(config: DesktopSourceConfig) -> DesktopAppBuilder {
257 DesktopAppBuilder {
258 #[cfg(feature = "application-ipc")]
259 ipc_options: IpcOptions::default(),
260 input: AppInput::Source(Box::new(SourceApp {
261 config,
262 shell: DesktopShellConfig::default(),
263 app_id: None,
264 })),
265 }
266 }
267
268 pub fn from_bundle(bundle_dir: impl Into<PathBuf>) -> Result<DesktopAppBuilder> {
274 Self::from_bundle_config(DesktopBundleConfig::new(bundle_dir.into()))
275 }
276
277 pub fn from_bundle_config(config: DesktopBundleConfig) -> Result<DesktopAppBuilder> {
283 let manifest =
284 DesktopBundleManifest::load(&config.bundle_dir.join("manifest.webui-desktop.json"))?;
285 Ok(Self::from_bundle_config_and_manifest(config, manifest))
286 }
287
288 #[must_use]
293 pub fn from_bundle_config_and_manifest(
294 config: DesktopBundleConfig,
295 manifest: DesktopBundleManifest,
296 ) -> DesktopAppBuilder {
297 DesktopAppBuilder {
298 #[cfg(feature = "application-ipc")]
299 ipc_options: IpcOptions::default(),
300 input: AppInput::Bundle(Box::new(BundleApp { config, manifest })),
301 }
302 }
303}
304
305#[cfg(feature = "native")]
315pub fn run_packaged_app() -> Result<()> {
316 let resources = crate::frame::find_packaged_resources_dir()
317 .ok_or(DesktopError::PackagedResourcesNotFound)?;
318 crate::frame::run_frame(DesktopApp::from_bundle(resources)?.build()?)
319}
320
321#[cfg(test)]
322#[allow(clippy::disallowed_methods)]
323mod tests {
324 use super::*;
325 use std::sync::atomic::{AtomicUsize, Ordering};
326
327 use crate::frame::test_support;
328 use crate::{BundleAsset, DesktopProtocolRequest, DesktopProtocolResponse, TitlebarStyle};
329
330 #[derive(Serialize)]
331 struct Seed {
332 label: &'static str,
333 }
334
335 fn configure(builder: DesktopAppBuilder, calls: Arc<AtomicUsize>) -> Result<DesktopAppBuilder> {
336 #[cfg(feature = "application-ipc")]
337 let builder = builder
338 .ipc_registry(crate::ipc_test_support::registry())
339 .ipc_options(crate::ipc_test_support::options());
340 builder
341 .state(&Seed { label: "seed" })?
342 .token_css(HashMap::from([(
343 "default".to_string(),
344 "--accent:red".to_string(),
345 )]))
346 .max_asset_bytes(1024)
347 .app_id("com.example.configured")
348 .window(WindowOptions {
349 title: "Configured".to_string(),
350 titlebar: TitlebarStyle::Overlay { height: 48 },
351 background: Some("#123456".parse().unwrap()),
352 ..WindowOptions::default()
353 })
354 .shell(DesktopShellConfig {
355 icon_path: Some(PathBuf::from("assets/icon.png")),
356 ..DesktopShellConfig::default()
357 })
358 .route("/", move |ctx| {
359 calls.fetch_add(1, Ordering::SeqCst);
360 assert_eq!(ctx.base_state["label"], "seed");
361 Ok(ctx.base_state.clone())
362 })?
363 .api_route("/api/:id", |ctx| {
364 Ok(DesktopProtocolResponse::text(200, ctx.param("id").unwrap()))
365 })
366 }
367
368 fn assert_configured_frame(frame: &DesktopFrame, calls: &AtomicUsize) {
369 assert_eq!(calls.load(Ordering::SeqCst), 1);
370 assert_eq!(frame.app_id.as_deref(), Some("com.example.configured"));
371 assert_eq!(frame.window.title, "Configured");
372 assert_eq!(
373 frame.shell.icon_path.as_deref(),
374 Some(std::path::Path::new("assets/icon.png"))
375 );
376 assert!(frame
377 .runtime
378 .startup_html()
379 .contains("--webui-titlebar-height:48px"));
380 assert!(frame
381 .runtime
382 .startup_html()
383 .contains("--webui-window-background:#123456"));
384 let response = frame
385 .runtime
386 .handle_request(&DesktopProtocolRequest::get("/api/registered"))
387 .unwrap();
388 assert_eq!(response.body, b"registered");
389
390 #[cfg(feature = "application-ipc")]
391 crate::ipc_test_support::assert_echo(frame, b"registered");
392 }
393
394 #[test]
395 fn bundle_preserves_manifest_identity_window_and_shell_without_reloading_it() {
396 let (dir, mut manifest) = test_support::bundle();
397 manifest.window.title = "Bundled".to_string();
398 manifest.window.background = Some("#abcdef".parse().unwrap());
399 manifest.shell.icon_path = Some(PathBuf::from("assets/bundled.png"));
400 std::fs::remove_file(dir.path().join("manifest.webui-desktop.json")).unwrap();
401
402 let frame = DesktopApp::from_bundle_config_and_manifest(
403 DesktopBundleConfig::new(dir.path().to_path_buf()),
404 manifest,
405 )
406 .build()
407 .unwrap();
408
409 assert_eq!(
410 frame.app_id.as_deref(),
411 Some("com.example.webui.frame-test")
412 );
413 assert_eq!(frame.window.title, "Bundled");
414 assert_eq!(
415 frame.shell.icon_path.as_deref(),
416 Some(std::path::Path::new("assets/bundled.png"))
417 );
418 assert!(frame
419 .runtime
420 .startup_html()
421 .contains("--webui-window-background:#abcdef"));
422 }
423
424 #[test]
425 fn bundle_shared_handlers_and_overrides_are_installed_before_startup_render() {
426 let (dir, _) = test_support::bundle();
427 let calls = Arc::new(AtomicUsize::new(0));
428 let builder = configure(
429 DesktopApp::from_bundle(dir.path()).unwrap(),
430 Arc::clone(&calls),
431 )
432 .unwrap();
433 assert_eq!(calls.load(Ordering::SeqCst), 0);
434 std::fs::remove_file(dir.path().join("manifest.webui-desktop.json")).unwrap();
436
437 let frame = builder.build().unwrap();
438
439 assert_configured_frame(&frame, &calls);
440 }
441
442 #[test]
443 fn bundle_config_keeps_existing_registrations_and_accepts_json_state_override() {
444 let (dir, _) = test_support::bundle();
445 let mut config = DesktopBundleConfig::new(dir.path().to_path_buf());
446 config
447 .api_routes
448 .route("/api/preset", |_| {
449 Ok(DesktopProtocolResponse::text(200, "preset"))
450 })
451 .unwrap();
452 let builder = DesktopApp::from_bundle_config(config)
453 .unwrap()
454 .state_value(serde_json::to_value(Seed { label: "override" }).unwrap())
455 .route("/", |ctx| {
456 assert_eq!(ctx.base_state["label"], "override");
457 Ok(Value::Null)
458 })
459 .unwrap();
460
461 let frame = builder.build().unwrap();
462
463 assert_eq!(
464 frame
465 .runtime
466 .handle_request(&DesktopProtocolRequest::get("/api/preset"))
467 .unwrap()
468 .body,
469 b"preset"
470 );
471 }
472
473 #[test]
474 fn bundle_asset_limit_override_reaches_runtime_validation() {
475 let (dir, mut manifest) = test_support::bundle();
476 std::fs::write(dir.path().join("assets/large.txt"), b"large").unwrap();
477 manifest.integrity.assets.push(BundleAsset {
478 path: "assets/large.txt".to_string(),
479 sha256: String::new(),
480 size_bytes: 5,
481 });
482 let result = DesktopApp::from_bundle_config_and_manifest(
483 DesktopBundleConfig::new(dir.path().to_path_buf()),
484 manifest,
485 )
486 .max_asset_bytes(4)
487 .build();
488
489 assert!(matches!(result, Err(DesktopError::AssetTooLarge { .. })));
490 }
491
492 #[test]
493 fn invalid_registrations_and_provider_failures_are_explicit() {
494 let (dir, _) = test_support::bundle();
495 assert!(matches!(
496 DesktopApp::from_bundle(dir.path())
497 .unwrap()
498 .route("invalid", |_| Ok(Value::Null)),
499 Err(DesktopError::InvalidRoutePattern { .. })
500 ));
501 assert!(matches!(
502 DesktopApp::from_bundle(dir.path())
503 .unwrap()
504 .api_route("invalid", |_| Ok(DesktopProtocolResponse::text(200, ""))),
505 Err(DesktopError::InvalidRoutePattern { .. })
506 ));
507 let result = DesktopApp::from_bundle(dir.path())
508 .unwrap()
509 .route("/", |_| {
510 Err(DesktopError::UnsupportedRuntime {
511 message: "fixture provider failure".to_string(),
512 help: "return route state".to_string(),
513 })
514 })
515 .unwrap()
516 .build();
517 assert!(matches!(result, Err(DesktopError::RouteProvider { .. })));
518 }
519
520 #[test]
521 fn state_serialization_failure_is_returned() {
522 struct InvalidState;
523 impl Serialize for InvalidState {
524 fn serialize<S: serde::Serializer>(
525 &self,
526 _serializer: S,
527 ) -> std::result::Result<S::Ok, S::Error> {
528 Err(serde::ser::Error::custom("fixture serialization failure"))
529 }
530 }
531
532 let (dir, _) = test_support::bundle();
533 assert!(matches!(
534 DesktopApp::from_bundle(dir.path())
535 .unwrap()
536 .state(&InvalidState),
537 Err(DesktopError::Serialization { .. })
538 ));
539 }
540
541 #[cfg(feature = "source")]
542 #[test]
543 fn source_uses_the_same_handler_and_render_configuration() {
544 let dir = tempfile::TempDir::new().unwrap();
545 let config = DesktopSourceConfig::new(crate::BuildOptions {
546 app_dir: dir.path().to_path_buf(),
547 ..Default::default()
548 });
549 let calls = Arc::new(AtomicUsize::new(0));
550 let builder = configure(DesktopApp::from_source(config), Arc::clone(&calls)).unwrap();
551 std::fs::write(dir.path().join("index.html"), "<main>Hello</main>").unwrap();
553 assert_eq!(calls.load(Ordering::SeqCst), 0);
554
555 let frame = builder.build().unwrap();
556
557 assert_configured_frame(&frame, &calls);
558 }
559}