Skip to main content

webui_desktop/
app.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use 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
37/// Builder for Rust-first WebUI desktop hosts.
38///
39/// Source and bundle inputs share registration and window configuration. No
40/// startup rendering occurs until [`Self::build`], so route providers and Rust
41/// state participate in the first render, not only subsequent requests.
42pub struct DesktopAppBuilder {
43    input: AppInput,
44    #[cfg(feature = "application-ipc")]
45    ipc_options: IpcOptions,
46}
47
48impl DesktopAppBuilder {
49    /// Set the window options used by both rendering and the native frame.
50    #[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    /// Set native shell configuration, overriding any bundled defaults.
61    #[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    /// Set the stable application identity used by the native frame.
72    #[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    /// Set the maximum asset bytes buffered in one protocol response.
84    #[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    /// Set startup state from any serializable Rust value.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`DesktopError`] if state serialization fails.
99    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    /// Set startup state from an existing JSON value.
108    #[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    /// Set pre-resolved design token CSS.
119    #[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    /// Set the protobuf IPC registry.
130    #[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    /// Configure application IPC permissions and bounded resource limits.
142    ///
143    /// The default denies all application methods. Use
144    /// [`IpcOptions::for_schema`] to explicitly grant a generated contract.
145    #[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    /// Register a Rust route state provider.
153    ///
154    /// Patterns support literal segments and `:param` captures, e.g.
155    /// `/contacts/:id`.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`DesktopError`] if the pattern is invalid.
160    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    /// Register a Rust custom-protocol API handler.
173    ///
174    /// Patterns support literal segments and `:param` captures, e.g.
175    /// `/api/contacts/:id`.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`DesktopError`] if the pattern is invalid.
180    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    /// Render startup content and construct the owning desktop frame.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`DesktopError`] if source compilation, bundle loading, a route
200    /// provider, or startup rendering fails.
201    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
238/// Entry point for constructing Rust-first desktop apps.
239pub struct DesktopApp;
240
241impl DesktopApp {
242    /// Create an opt-in native frame for an already-running loopback HTTP app.
243    ///
244    /// This does not start a server, compile sources, load a bundle, or grant IPC.
245    #[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    /// Construct a source-backed app without compiling or rendering it yet.
251    ///
252    /// Source-only asset and theme settings belong on `config`. Register shared
253    /// state and handlers on the returned builder before calling `build`.
254    #[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    /// Load bundle metadata and prepare an app for shared handler registration.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`DesktopError`] when the bundle manifest cannot be read or parsed.
273    pub fn from_bundle(bundle_dir: impl Into<PathBuf>) -> Result<DesktopAppBuilder> {
274        Self::from_bundle_config(DesktopBundleConfig::new(bundle_dir.into()))
275    }
276
277    /// Prepare a bundle-backed app with existing Rust state and registrations.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`DesktopError`] when the bundle manifest cannot be read or parsed.
282    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    /// Prepare a bundle-backed app using an already-loaded manifest.
289    ///
290    /// This avoids reading metadata twice when the host also needs it to
291    /// initialize application storage or other shared services.
292    #[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/// Run a packaged app with bundled seed state and default Rust registrations.
306///
307/// Hosts needing route or IPC handlers should configure [`DesktopApp`] and
308/// launch the resulting frame instead.
309///
310/// # Errors
311///
312/// Returns [`DesktopError`] when resources are missing, bundle loading fails,
313/// or the native backend fails to initialize or run.
314#[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        // Construction already loaded metadata; build must not reload it.
435        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        // A source builder also defers compilation until build.
552        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}