Skip to main content

webui_desktop/
bundle.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4#[cfg(feature = "source")]
5use std::collections::HashSet;
6#[cfg(any(feature = "source", feature = "packaging"))]
7use std::ffi::OsString;
8use std::fs;
9#[cfg(feature = "source")]
10use std::io::Read;
11#[cfg(any(feature = "source", feature = "packaging"))]
12use std::path::Component;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16#[cfg(feature = "source")]
17use serde_json::Value;
18#[cfg(feature = "source")]
19use sha2::{Digest, Sha256};
20#[cfg(feature = "source")]
21use webui::RenderOptions;
22#[cfg(feature = "source")]
23use webui_handler::ResponseWriter;
24
25use crate::error::{DesktopError, Result};
26#[cfg(feature = "source")]
27use crate::hydration::{handler_for_plugin, plugin_name};
28
29pub use crate::window::WindowOptions;
30
31/// Runtime-neutral shell extension points for native desktop hosts.
32#[derive(Clone, Debug, Default, Deserialize, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct DesktopShellConfig {
35    /// Optional app icon path relative to the bundle root.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub icon_path: Option<PathBuf>,
38    /// Optional tray icon configuration.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub tray: Option<TrayConfig>,
41    /// Native menu declarations. Empty means platform default menu.
42    #[serde(default)]
43    pub menus: Vec<DesktopMenu>,
44}
45
46/// Tray icon configuration.
47#[derive(Clone, Debug, Deserialize, Serialize)]
48pub struct TrayConfig {
49    /// Icon path relative to the bundle root.
50    pub icon_path: PathBuf,
51    /// Optional tooltip text.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub tooltip: Option<String>,
54}
55
56/// Native menu descriptor.
57#[derive(Clone, Debug, Deserialize, Serialize)]
58pub struct DesktopMenu {
59    /// Stable menu identifier.
60    pub id: String,
61    /// Visible menu label.
62    pub label: String,
63    /// Child menu items.
64    #[serde(default)]
65    pub items: Vec<DesktopMenuItem>,
66}
67
68/// Native menu item descriptor.
69#[derive(Clone, Debug, Deserialize, Serialize)]
70pub struct DesktopMenuItem {
71    /// Stable item identifier.
72    pub id: String,
73    /// Visible item label.
74    pub label: String,
75    /// Optional desktop IPC command invoked by this menu item.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub command: Option<String>,
78    /// Optional keyboard accelerator in platform-neutral text form.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub accelerator: Option<String>,
81}
82
83/// Native package target supported by `webui desktop package`.
84#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum DesktopPackageTarget {
87    /// macOS `.app` bundle.
88    MacosApp,
89    /// Windows portable directory.
90    WindowsPortable,
91    /// Linux portable directory.
92    LinuxPortable,
93}
94
95/// One asset included in a desktop bundle.
96#[derive(Clone, Debug, Deserialize, Serialize)]
97pub struct BundleAsset {
98    /// Path relative to the bundle resource root.
99    pub path: String,
100    /// SHA-256 digest encoded as lowercase hexadecimal.
101    pub sha256: String,
102    /// Asset size in bytes.
103    pub size_bytes: u64,
104}
105
106/// Integrity metadata for immutable bundle contents.
107#[derive(Clone, Debug, Default, Deserialize, Serialize)]
108pub struct BundleIntegrity {
109    /// SHA-256 digest of `protocol.bin` encoded as lowercase hexadecimal.
110    pub protocol_sha256: String,
111    /// Packaged asset hashes.
112    pub assets: Vec<BundleAsset>,
113}
114
115/// Manifest generated by `webui desktop build` and consumed by packaging.
116#[derive(Clone, Debug, Deserialize, Serialize)]
117pub struct DesktopBundleManifest {
118    /// Manifest schema version.
119    pub manifest_version: u32,
120    /// Reverse-DNS application identifier.
121    pub app_id: String,
122    /// Human-readable app name.
123    pub app_name: String,
124    /// Application version.
125    pub version: String,
126    /// Publisher name.
127    pub publisher: String,
128    /// Entry fragment name used for startup render.
129    pub entry: String,
130    /// Handler plugin used by the desktop host.
131    #[serde(default)]
132    pub plugin: Option<String>,
133    /// Protocol file path relative to the bundle root.
134    pub protocol_path: PathBuf,
135    /// Optional startup state path relative to the bundle root.
136    pub state_path: Option<PathBuf>,
137    /// Asset directory path relative to the bundle root.
138    pub assets_dir: PathBuf,
139    /// Optional IPC schema path relative to the bundle root.
140    pub ipc_schema: Option<PathBuf>,
141    /// Window defaults.
142    pub window: WindowOptions,
143    /// Native shell extension configuration.
144    #[serde(default)]
145    pub shell: DesktopShellConfig,
146    /// Package targets requested for this bundle.
147    pub package_targets: Vec<DesktopPackageTarget>,
148    /// Integrity hashes for immutable bundle contents.
149    pub integrity: BundleIntegrity,
150}
151
152impl DesktopBundleManifest {
153    /// Current desktop bundle manifest version.
154    pub const VERSION: u32 = 1;
155
156    /// Load a desktop bundle manifest from disk.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`DesktopError`] when the file cannot be read or parsed.
161    pub fn load(path: &Path) -> Result<Self> {
162        let bytes = fs::read(path).map_err(|source| DesktopError::Io {
163            context: format!("reading desktop manifest {}", path.display()),
164            source,
165        })?;
166        serde_json::from_slice(&bytes).map_err(|source| DesktopError::ManifestDeserialization {
167            path: path.to_path_buf(),
168            source,
169        })
170    }
171}
172
173/// Inputs for generating an immutable desktop bundle.
174#[cfg(feature = "source")]
175pub struct DesktopBundleOptions {
176    /// WebUI build options.
177    pub build_options: webui::BuildOptions,
178    /// Output bundle directory.
179    pub out_dir: PathBuf,
180    /// Optional startup state JSON file.
181    pub state_file: Option<PathBuf>,
182    /// Optional static asset root copied into the bundle.
183    pub asset_root: Option<PathBuf>,
184    /// Optional resolved token CSS keyed by theme name.
185    pub token_css: Option<std::collections::HashMap<String, String>>,
186    /// Reverse-DNS application identifier.
187    pub app_id: String,
188    /// Human-readable app name.
189    pub app_name: String,
190    /// Application version.
191    pub version: String,
192    /// Publisher name.
193    pub publisher: String,
194    /// Window defaults.
195    pub window: WindowOptions,
196    /// Optional app icon file copied into the bundle.
197    pub icon_file: Option<PathBuf>,
198    /// Native shell extension configuration.
199    pub shell: DesktopShellConfig,
200    /// Package targets requested for this bundle.
201    pub package_targets: Vec<DesktopPackageTarget>,
202}
203
204/// Build an immutable desktop bundle on disk.
205///
206/// # Errors
207///
208/// Returns [`DesktopError`] if WebUI build fails, bundle files cannot be
209/// written, static assets escape the asset root, or manifest serialization
210/// fails.
211#[cfg(feature = "source")]
212pub fn build_desktop_bundle(options: DesktopBundleOptions) -> Result<DesktopBundleManifest> {
213    validate_bundle_output(&options)?;
214    prepare_out_dir(&options.out_dir)?;
215
216    let build_options = options.build_options.clone();
217    let build_result = webui::build(build_options.clone())?;
218    let projection_inputs = projection_manifest_paths(&build_options)
219        .map(normalized_absolute_path)
220        .collect::<Result<Vec<_>>>()?;
221    let protocol_path = PathBuf::from("protocol.bin");
222    let protocol_dest = options.out_dir.join(&protocol_path);
223    fs::write(&protocol_dest, &build_result.protocol_bytes).map_err(|source| DesktopError::Io {
224        context: format!("writing desktop protocol {}", protocol_dest.display()),
225        source,
226    })?;
227
228    let assets_dir = PathBuf::from("assets");
229    let assets_dest = options.out_dir.join(&assets_dir);
230    fs::create_dir_all(&assets_dest).map_err(|source| DesktopError::Io {
231        context: format!(
232            "creating desktop assets directory {}",
233            assets_dest.display()
234        ),
235        source,
236    })?;
237
238    let mut claimed_assets = HashSet::new();
239    let mut assets =
240        write_generated_css(&assets_dest, &mut claimed_assets, &build_result.css_files)?;
241    #[cfg(feature = "application-ipc")]
242    write_ipc_assets(&assets_dest, &mut claimed_assets, &mut assets)?;
243    let protocol = webui::Protocol::new(build_result.protocol);
244    write_startup_html(StartupHtmlInput {
245        assets_dest: &assets_dest,
246        claimed_assets: &mut claimed_assets,
247        assets: &mut assets,
248        protocol: &protocol,
249        plugin: build_options.plugin,
250        entry: &build_options.entry,
251        state_file: options.state_file.as_ref(),
252        token_css: options.token_css.as_ref(),
253    })?;
254    if let Some(asset_root) = &options.asset_root {
255        copy_static_assets(
256            asset_root,
257            &assets_dest,
258            &mut claimed_assets,
259            &mut assets,
260            &projection_inputs,
261        )?;
262    }
263    let mut shell = options.shell;
264    if let Some(icon_file) = &options.icon_file {
265        shell.icon_path = Some(copy_app_icon(
266            icon_file,
267            &assets_dest,
268            &mut claimed_assets,
269            &mut assets,
270        )?);
271    }
272
273    let state_path = match &options.state_file {
274        Some(state_file) => {
275            let state_dest_name = PathBuf::from("state.json");
276            let state_dest = options.out_dir.join(&state_dest_name);
277            write_bundle_state(state_file, options.token_css.as_ref(), &state_dest)?;
278            Some(state_dest_name)
279        }
280        None => match options.token_css.as_ref() {
281            Some(token_css) => {
282                let state_dest_name = PathBuf::from("state.json");
283                let state_dest = options.out_dir.join(&state_dest_name);
284                let mut state = Value::Object(serde_json::Map::new());
285                webui_tokens::inject_token_css(&mut state, token_css);
286                write_state_value(&state, &state_dest)?;
287                Some(state_dest_name)
288            }
289            None => None,
290        },
291    };
292
293    let manifest = DesktopBundleManifest {
294        manifest_version: DesktopBundleManifest::VERSION,
295        app_id: options.app_id,
296        app_name: options.app_name,
297        version: options.version,
298        publisher: options.publisher,
299        entry: build_options.entry,
300        plugin: build_options.plugin.map(plugin_name).map(str::to_string),
301        protocol_path,
302        state_path,
303        assets_dir,
304        ipc_schema: None,
305        window: options.window,
306        shell,
307        package_targets: options.package_targets,
308        integrity: BundleIntegrity {
309            protocol_sha256: sha256_file(&protocol_dest)?,
310            assets,
311        },
312    };
313
314    let manifest_path = options.out_dir.join("manifest.webui-desktop.json");
315    let manifest_bytes =
316        serde_json::to_vec_pretty(&manifest).map_err(DesktopError::ManifestSerialization)?;
317    fs::write(&manifest_path, manifest_bytes).map_err(|source| DesktopError::Io {
318        context: format!("writing desktop manifest {}", manifest_path.display()),
319        source,
320    })?;
321
322    Ok(manifest)
323}
324
325#[cfg(feature = "source")]
326fn validate_bundle_output(options: &DesktopBundleOptions) -> Result<()> {
327    let output = normalized_absolute_path(&options.out_dir)?;
328    let lexical_output = lexical_absolute_path(&options.out_dir)?;
329    let app_dir = normalized_absolute_path(&options.build_options.app_dir)?;
330    let lexical_app_dir = lexical_absolute_path(&options.build_options.app_dir)?;
331    reject_path_overlap(&output, &app_dir, "app")?;
332    reject_path_overlap(&lexical_output, &app_dir, "app")?;
333    reject_path_overlap(&output, &lexical_app_dir, "app")?;
334    reject_path_overlap(&lexical_output, &lexical_app_dir, "app")?;
335    if let Some(state_file) = &options.state_file {
336        let state = normalized_absolute_path(state_file)?;
337        let lexical_state = lexical_absolute_path(state_file)?;
338        reject_path_overlap(&output, &state, "state")?;
339        reject_path_overlap(&lexical_output, &state, "state")?;
340        reject_path_overlap(&output, &lexical_state, "state")?;
341        reject_path_overlap(&lexical_output, &lexical_state, "state")?;
342    }
343    if let Some(asset_root) = &options.asset_root {
344        let assets = normalized_absolute_path(asset_root)?;
345        let lexical_assets = lexical_absolute_path(asset_root)?;
346        reject_path_overlap(&output, &assets, "asset")?;
347        reject_path_overlap(&lexical_output, &assets, "asset")?;
348        reject_path_overlap(&output, &lexical_assets, "asset")?;
349        reject_path_overlap(&lexical_output, &lexical_assets, "asset")?;
350    }
351    for path in projection_manifest_paths(&options.build_options) {
352        let input = normalized_absolute_path(path)?;
353        let lexical_input = lexical_absolute_path(path)?;
354        reject_path_overlap(&output, &input, "projection manifest")?;
355        reject_path_overlap(&lexical_output, &input, "projection manifest")?;
356        reject_path_overlap(&output, &lexical_input, "projection manifest")?;
357        reject_path_overlap(&lexical_output, &lexical_input, "projection manifest")?;
358    }
359    Ok(())
360}
361
362#[cfg(feature = "source")]
363fn projection_manifest_paths(options: &webui::BuildOptions) -> impl Iterator<Item = &Path> {
364    options
365        .projection_manifests
366        .iter()
367        .filter_map(|source| match source {
368            webui::ProjectionManifestSource::Path(path)
369            | webui::ProjectionManifestSource::Inline {
370                manifest_path: path,
371                ..
372            } => Some(path.as_path()),
373            webui::ProjectionManifestSource::Prepared(_)
374            | webui::ProjectionManifestSource::Pending(_) => None,
375        })
376}
377
378#[cfg(feature = "source")]
379fn prepare_out_dir(out_dir: &Path) -> Result<()> {
380    if out_dir.exists() {
381        fs::remove_dir_all(out_dir).map_err(|source| DesktopError::Io {
382            context: format!("cleaning desktop bundle directory {}", out_dir.display()),
383            source,
384        })?;
385    }
386    fs::create_dir_all(out_dir).map_err(|source| DesktopError::Io {
387        context: format!("creating desktop bundle directory {}", out_dir.display()),
388        source,
389    })
390}
391
392#[cfg(any(feature = "source", feature = "packaging"))]
393pub(crate) fn normalized_absolute_path(path: &Path) -> Result<PathBuf> {
394    // A symlink followed by `..` must resolve through its filesystem target.
395    let absolute = absolute_path(path)?;
396    let base = resolve_existing_ancestor(&absolute)?;
397    Ok(normalize_components(&base))
398}
399
400#[cfg(any(feature = "source", feature = "packaging"))]
401pub(crate) fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
402    Ok(normalize_components(&absolute_path(path)?))
403}
404
405#[cfg(any(feature = "source", feature = "packaging"))]
406fn absolute_path(path: &Path) -> Result<PathBuf> {
407    let absolute = if path.is_absolute() {
408        path.to_path_buf()
409    } else {
410        std::env::current_dir()
411            .map_err(|source| DesktopError::Io {
412                context: "resolving current directory for desktop output validation".to_string(),
413                source,
414            })?
415            .join(path)
416    };
417    Ok(absolute)
418}
419
420#[cfg(any(feature = "source", feature = "packaging"))]
421fn normalize_components(path: &Path) -> PathBuf {
422    let mut normalized = PathBuf::new();
423    for component in path.components() {
424        match component {
425            Component::CurDir => {}
426            Component::ParentDir => {
427                normalized.pop();
428            }
429            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
430                normalized.push(component.as_os_str());
431            }
432        }
433    }
434    normalized
435}
436
437#[cfg(any(feature = "source", feature = "packaging"))]
438fn resolve_existing_ancestor(path: &Path) -> Result<PathBuf> {
439    if path.exists() {
440        return path.canonicalize().map_err(|source| DesktopError::Io {
441            context: format!("resolving desktop path {}", path.display()),
442            source,
443        });
444    }
445
446    let mut missing = Vec::<OsString>::new();
447    let mut cursor = path;
448    while !cursor.exists() {
449        if let Some(name) = cursor.file_name() {
450            missing.push(name.to_os_string());
451        }
452        let Some(parent) = cursor.parent() else {
453            break;
454        };
455        cursor = parent;
456    }
457
458    let mut resolved = cursor.canonicalize().map_err(|source| DesktopError::Io {
459        context: format!("resolving desktop path ancestor {}", cursor.display()),
460        source,
461    })?;
462    for component in missing.iter().rev() {
463        resolved.push(component);
464    }
465    Ok(resolved)
466}
467
468#[cfg(any(feature = "source", feature = "packaging"))]
469pub(crate) fn reject_path_overlap(
470    output: &Path,
471    input: &Path,
472    input_label: &'static str,
473) -> Result<()> {
474    if output == input || output.starts_with(input) || input.starts_with(output) {
475        return Err(DesktopError::OutputPathOverlap {
476            output: output.to_path_buf(),
477            input: input.to_path_buf(),
478            input_label,
479        });
480    }
481    Ok(())
482}
483
484#[cfg(feature = "source")]
485fn write_generated_css(
486    assets_dest: &Path,
487    claimed_assets: &mut HashSet<String>,
488    css_files: &[(String, String)],
489) -> Result<Vec<BundleAsset>> {
490    let mut assets = Vec::with_capacity(css_files.len());
491    for (name, content) in css_files {
492        claim_asset(claimed_assets, name)?;
493        let path = assets_dest.join(name);
494        fs::write(&path, content).map_err(|source| DesktopError::Io {
495            context: format!("writing generated desktop CSS {}", path.display()),
496            source,
497        })?;
498        assets.push(asset_record(name, &path)?);
499    }
500    Ok(assets)
501}
502
503#[cfg(feature = "source")]
504#[cfg(feature = "application-ipc")]
505fn write_ipc_assets(
506    assets_dest: &Path,
507    claimed_assets: &mut HashSet<String>,
508    assets: &mut Vec<BundleAsset>,
509) -> Result<()> {
510    for (name, bytes) in [
511        ("_webui/ipc/runtime.js", crate::ipc_assets::BROWSER_RUNTIME),
512        (
513            "_webui/ipc/bootstrap.js",
514            crate::ipc_assets::NATIVE_BOOTSTRAP_SCRIPT.as_bytes(),
515        ),
516    ] {
517        claim_asset(claimed_assets, name)?;
518        let path = assets_dest.join(name);
519        if let Some(parent) = path.parent() {
520            fs::create_dir_all(parent).map_err(|source| DesktopError::Io {
521                context: format!("creating desktop IPC asset directory {}", parent.display()),
522                source,
523            })?;
524        }
525        fs::write(&path, bytes).map_err(|source| DesktopError::Io {
526            context: format!("writing desktop IPC asset {}", path.display()),
527            source,
528        })?;
529        assets.push(asset_record(name, &path)?);
530    }
531    Ok(())
532}
533
534#[cfg(feature = "source")]
535fn copy_app_icon(
536    icon_file: &Path,
537    assets_dest: &Path,
538    claimed_assets: &mut HashSet<String>,
539    assets: &mut Vec<BundleAsset>,
540) -> Result<PathBuf> {
541    let extension = icon_file
542        .extension()
543        .and_then(|value| value.to_str())
544        .filter(|value| !value.is_empty())
545        .unwrap_or("icon");
546    let mut relative = String::with_capacity("app-icon.".len() + extension.len());
547    relative.push_str("app-icon.");
548    relative.push_str(extension);
549    claim_asset(claimed_assets, &relative)?;
550    let dest = assets_dest.join(&relative);
551    copy_file_to(icon_file, &dest)?;
552    assets.push(asset_record(&relative, &dest)?);
553    Ok(PathBuf::from("assets").join(relative))
554}
555
556#[cfg(feature = "source")]
557struct StartupHtmlInput<'a> {
558    assets_dest: &'a Path,
559    claimed_assets: &'a mut HashSet<String>,
560    assets: &'a mut Vec<BundleAsset>,
561    protocol: &'a webui::Protocol,
562    plugin: Option<webui::Plugin>,
563    entry: &'a str,
564    state_file: Option<&'a PathBuf>,
565    token_css: Option<&'a std::collections::HashMap<String, String>>,
566}
567
568#[cfg(feature = "source")]
569fn write_startup_html(input: StartupHtmlInput<'_>) -> Result<()> {
570    const STARTUP_HTML_NAME: &str = "index.html";
571    claim_asset(input.claimed_assets, STARTUP_HTML_NAME)?;
572    let mut state = read_bundle_state(input.state_file)?;
573    if let Some(token_css) = input.token_css {
574        webui_tokens::inject_token_css(&mut state, token_css);
575    }
576    if let Value::Object(map) = &mut state {
577        map.insert("basePath".to_string(), Value::String("/".to_string()));
578    }
579    let handler = handler_for_plugin(input.plugin);
580    let mut writer = MemoryWriter::with_capacity(4096);
581    handler.render(
582        input.protocol,
583        &state,
584        &RenderOptions::new(input.entry, "/"),
585        &mut writer,
586    )?;
587    let path = input.assets_dest.join(STARTUP_HTML_NAME);
588    fs::write(&path, writer.buf).map_err(|source| DesktopError::Io {
589        context: format!("writing desktop startup HTML {}", path.display()),
590        source,
591    })?;
592    input.assets.push(asset_record(STARTUP_HTML_NAME, &path)?);
593    Ok(())
594}
595
596#[cfg(feature = "source")]
597fn read_bundle_state(path: Option<&PathBuf>) -> Result<Value> {
598    let Some(path) = path else {
599        return Ok(Value::Object(serde_json::Map::new()));
600    };
601    let json = fs::read_to_string(path).map_err(|source| DesktopError::Io {
602        context: format!("reading desktop state {}", path.display()),
603        source,
604    })?;
605    serde_json::from_str(&json).map_err(|source| DesktopError::StateJson {
606        path: path.clone(),
607        source,
608    })
609}
610
611#[cfg(feature = "source")]
612fn write_bundle_state(
613    state_file: &Path,
614    token_css: Option<&std::collections::HashMap<String, String>>,
615    state_dest: &Path,
616) -> Result<()> {
617    if token_css.is_none() {
618        return copy_file(state_file, state_dest);
619    }
620    let mut state = read_bundle_state(Some(&state_file.to_path_buf()))?;
621    if let Some(token_css) = token_css {
622        webui_tokens::inject_token_css(&mut state, token_css);
623    }
624    write_state_value(&state, state_dest)
625}
626
627#[cfg(feature = "source")]
628fn write_state_value(state: &Value, state_dest: &Path) -> Result<()> {
629    let bytes = serde_json::to_vec_pretty(state).map_err(|source| DesktopError::Serialization {
630        context: "serializing desktop bundle state".to_string(),
631        source,
632    })?;
633    fs::write(state_dest, bytes).map_err(|source| DesktopError::Io {
634        context: format!("writing desktop bundle state {}", state_dest.display()),
635        source,
636    })
637}
638
639#[cfg(feature = "source")]
640struct MemoryWriter {
641    buf: String,
642}
643
644#[cfg(feature = "source")]
645impl MemoryWriter {
646    fn with_capacity(capacity: usize) -> Self {
647        Self {
648            buf: String::with_capacity(capacity),
649        }
650    }
651}
652
653#[cfg(feature = "source")]
654impl ResponseWriter for MemoryWriter {
655    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
656        self.buf.push_str(content);
657        Ok(())
658    }
659
660    fn end(&mut self) -> webui_handler::Result<()> {
661        Ok(())
662    }
663}
664
665#[cfg(feature = "source")]
666fn copy_static_assets(
667    asset_root: &Path,
668    assets_dest: &Path,
669    claimed_assets: &mut HashSet<String>,
670    assets: &mut Vec<BundleAsset>,
671    build_inputs: &[PathBuf],
672) -> Result<()> {
673    let canonical_root = asset_root
674        .canonicalize()
675        .map_err(|source| DesktopError::Io {
676            context: format!("resolving desktop static assets {}", asset_root.display()),
677            source,
678        })?;
679
680    let mut stack = vec![canonical_root.clone()];
681    while let Some(dir) = stack.pop() {
682        for entry in fs::read_dir(&dir).map_err(|source| DesktopError::Io {
683            context: format!("reading desktop static assets {}", dir.display()),
684            source,
685        })? {
686            let entry = entry.map_err(|source| DesktopError::Io {
687                context: format!("reading desktop static asset entry in {}", dir.display()),
688                source,
689            })?;
690            let path = entry.path();
691            let file_type = entry.file_type().map_err(|source| DesktopError::Io {
692                context: format!("reading desktop static asset type {}", path.display()),
693                source,
694            })?;
695            if file_type.is_dir() {
696                stack.push(path);
697                continue;
698            }
699            if !file_type.is_file() {
700                continue;
701            }
702
703            let canonical = path.canonicalize().map_err(|source| DesktopError::Io {
704                context: format!("resolving desktop static asset {}", path.display()),
705                source,
706            })?;
707            if !canonical.starts_with(&canonical_root) {
708                return Err(DesktopError::InvalidAssetPath {
709                    path: canonical.display().to_string(),
710                });
711            }
712            if build_inputs.contains(&canonical) {
713                continue;
714            }
715            let relative = canonical.strip_prefix(&canonical_root).map_err(|_| {
716                DesktopError::InvalidAssetPath {
717                    path: canonical.display().to_string(),
718                }
719            })?;
720            let relative_string = relative.to_string_lossy().replace('\\', "/");
721            claim_asset(claimed_assets, &relative_string)?;
722            let dest = assets_dest.join(relative);
723            copy_file(&canonical, &dest)?;
724            assets.push(asset_record(&relative_string, &dest)?);
725        }
726    }
727    Ok(())
728}
729
730#[cfg(feature = "source")]
731fn claim_asset(claimed_assets: &mut HashSet<String>, path: &str) -> Result<()> {
732    if claimed_assets.insert(path.to_string()) {
733        return Ok(());
734    }
735    Err(DesktopError::BundleAssetCollision {
736        path: path.to_string(),
737    })
738}
739
740#[cfg(feature = "source")]
741fn copy_file(source: &Path, dest: &Path) -> Result<()> {
742    copy_file_to(source, dest)
743}
744
745#[cfg(feature = "source")]
746pub(crate) fn copy_file_to(source: &Path, dest: &Path) -> Result<()> {
747    if let Some(parent) = dest.parent() {
748        fs::create_dir_all(parent).map_err(|source| DesktopError::Io {
749            context: format!("creating desktop bundle directory {}", parent.display()),
750            source,
751        })?;
752    }
753    fs::copy(source, dest).map_err(|source| DesktopError::Io {
754        context: format!("copying desktop bundle file to {}", dest.display()),
755        source,
756    })?;
757    Ok(())
758}
759
760#[cfg(feature = "source")]
761fn asset_record(relative: &str, path: &Path) -> Result<BundleAsset> {
762    let size_bytes = fs::metadata(path)
763        .map_err(|source| DesktopError::Io {
764            context: format!("reading desktop bundle asset metadata {}", path.display()),
765            source,
766        })?
767        .len();
768    let mut bundle_path = String::with_capacity("assets/".len() + relative.len());
769    bundle_path.push_str("assets/");
770    bundle_path.push_str(relative);
771    Ok(BundleAsset {
772        path: bundle_path,
773        sha256: sha256_file(path)?,
774        size_bytes,
775    })
776}
777
778#[cfg(feature = "source")]
779fn sha256_file(path: &Path) -> Result<String> {
780    let mut file = fs::File::open(path).map_err(|source| DesktopError::Io {
781        context: format!("hashing desktop bundle file {}", path.display()),
782        source,
783    })?;
784    let mut hasher = Sha256::new();
785    let mut buffer = [0_u8; 16 * 1024];
786    loop {
787        let read = file.read(&mut buffer).map_err(|source| DesktopError::Io {
788            context: format!("hashing desktop bundle file {}", path.display()),
789            source,
790        })?;
791        if read == 0 {
792            break;
793        }
794        hasher.update(&buffer[..read]);
795    }
796    let digest = hasher.finalize();
797    Ok(hex_lower(&digest))
798}
799
800#[cfg(feature = "source")]
801fn hex_lower(bytes: &[u8]) -> String {
802    const HEX: &[u8; 16] = b"0123456789abcdef";
803    let mut out = String::with_capacity(bytes.len() * 2);
804    for byte in bytes {
805        out.push(char::from(HEX[usize::from(byte >> 4)]));
806        out.push(char::from(HEX[usize::from(byte & 0x0f)]));
807    }
808    out
809}
810
811#[cfg(all(test, feature = "source"))]
812#[allow(clippy::disallowed_methods)]
813mod tests {
814    use super::*;
815    use tempfile::TempDir;
816
817    fn write_file(root: &Path, path: &str, content: &str) {
818        let full = root.join(path);
819        if let Some(parent) = full.parent() {
820            fs::create_dir_all(parent).unwrap();
821        }
822        fs::write(full, content).unwrap();
823    }
824
825    fn build_options(app_dir: PathBuf) -> webui::BuildOptions {
826        webui::BuildOptions {
827            app_dir,
828            entry: "index.html".to_string(),
829            // Desktop apps build with `--plugin=webui` (see the
830            // contact-book-manager example), which is required for the
831            // bundler-neutral state projection compiler to populate
832            // per-component navigation keys. Without it, `navigation_mode`
833            // stays `StateProjectionMode::None` and partial responses ship
834            // no state at all.
835            plugin: Some(webui::Plugin::WebUI),
836            ..webui::BuildOptions::default()
837        }
838    }
839
840    #[test]
841    fn builds_manifest_with_protocol_state_and_assets() {
842        let app = TempDir::new().unwrap();
843        let out = TempDir::new().unwrap();
844        write_file(
845            app.path(),
846            "index.html",
847            "<my-card></my-card><main>Hello {{name}}</main>",
848        );
849        write_file(app.path(), "my-card.html", "<p class=\"card\">Card</p>");
850        write_file(app.path(), "my-card.css", ".card { color: red; }");
851        write_file(app.path(), "state.json", r#"{"name":"Bundle"}"#);
852        write_file(app.path(), "public/app.js", "console.log('bundle');");
853
854        let manifest = build_desktop_bundle(DesktopBundleOptions {
855            build_options: build_options(app.path().to_path_buf()),
856            out_dir: out.path().join("desktop"),
857            state_file: Some(app.path().join("state.json")),
858            asset_root: Some(app.path().join("public")),
859            token_css: None,
860            app_id: "com.microsoft.webui.test".to_string(),
861            app_name: "WebUI Test".to_string(),
862            version: "0.0.0".to_string(),
863            publisher: "Microsoft".to_string(),
864            window: WindowOptions::default(),
865            icon_file: None,
866            shell: DesktopShellConfig::default(),
867            package_targets: vec![DesktopPackageTarget::MacosApp],
868        })
869        .unwrap();
870
871        let bundle = out.path().join("desktop");
872        assert!(bundle.join("protocol.bin").is_file());
873        assert!(bundle.join("state.json").is_file());
874        assert!(bundle.join("assets/app.js").is_file());
875        assert!(bundle.join("assets/my-card.css").is_file());
876        assert!(bundle.join("assets/index.html").is_file());
877        for name in ["runtime.js", "bootstrap.js"] {
878            let path = format!("assets/_webui/ipc/{name}");
879            assert_eq!(
880                bundle.join(&path).is_file(),
881                cfg!(feature = "application-ipc"),
882                "{path}"
883            );
884            assert_eq!(
885                manifest
886                    .integrity
887                    .assets
888                    .iter()
889                    .any(|asset| asset.path == path),
890                cfg!(feature = "application-ipc"),
891                "{path}"
892            );
893        }
894        assert!(fs::read_to_string(bundle.join("assets/index.html"))
895            .unwrap()
896            .contains(r#"<link rel="stylesheet" href="my-card.css""#));
897        assert!(bundle.join("manifest.webui-desktop.json").is_file());
898        assert_eq!(manifest.state_path, Some(PathBuf::from("state.json")));
899        assert!(manifest
900            .integrity
901            .assets
902            .iter()
903            .any(|asset| asset.path == "assets/app.js"));
904    }
905
906    fn projection_bundle_options(app: PathBuf, out: PathBuf) -> DesktopBundleOptions {
907        DesktopBundleOptions {
908            build_options: build_options(app),
909            out_dir: out,
910            state_file: None,
911            asset_root: None,
912            token_css: None,
913            app_id: "webui.test.projection".into(),
914            app_name: "Projection".into(),
915            version: "0.0.0".into(),
916            publisher: "Microsoft".into(),
917            window: WindowOptions::default(),
918            icon_file: None,
919            shell: DesktopShellConfig::default(),
920            package_targets: Vec::new(),
921        }
922    }
923
924    #[test]
925    fn direct_bundles_exclude_projection_inputs_from_files_and_integrity() {
926        use webui_protocol::projection_manifest::{
927            ProjectionAdapter, ProjectionManifest, ProjectionProducer, PRODUCER_NAME, SCHEMA_ID,
928        };
929
930        let root = TempDir::new().unwrap();
931        let app = root.path().join("app");
932        let assets = root.path().join("dist");
933        write_file(&app, "index.html", "<main>Projection</main>");
934        write_file(&assets, "app.js", "export {};");
935        let mut proof = ProjectionManifest {
936            schema: SCHEMA_ID.into(),
937            producer: ProjectionProducer {
938                name: PRODUCER_NAME.into(),
939                version: env!("CARGO_PKG_VERSION").into(),
940            },
941            adapter: ProjectionAdapter {
942                name: "test".into(),
943                bundler: "test@1.0.0".into(),
944            },
945            root: ".".into(),
946            analysis_hash: format!("sha256:{}", "1".repeat(64)),
947            build_id: String::new(),
948            inputs: Default::default(),
949            outputs: Default::default(),
950            components: Default::default(),
951            entry_closures: Default::default(),
952        };
953        proof.build_id = proof.compute_build_id();
954        let json = serde_json::to_string(&proof).unwrap();
955        let input = assets.join("custom-input.json");
956        fs::write(&input, &json).unwrap();
957        let sources = vec![
958            input.clone().into(),
959            webui::ProjectionManifestSource::Inline {
960                manifest_path: input.clone(),
961                json: json.clone(),
962            },
963        ];
964        #[cfg(unix)]
965        let sources = {
966            let nested = assets.join("nested");
967            fs::create_dir(&nested).unwrap();
968            let alias = root.path().join("alias");
969            std::os::unix::fs::symlink(nested, &alias).unwrap();
970            let linked_input = alias.join("../custom-input.json");
971            let mut sources = sources;
972            sources.push(linked_input.clone().into());
973            sources.push(webui::ProjectionManifestSource::Inline {
974                manifest_path: linked_input,
975                json,
976            });
977            sources
978        };
979        for (index, source) in sources.into_iter().enumerate() {
980            let out = root.path().join(format!("bundle-{index}"));
981            let mut options = projection_bundle_options(app.clone(), out.clone());
982            options.asset_root = Some(assets.clone());
983            options.build_options.projection_manifests = vec![source];
984            let manifest = build_desktop_bundle(options).unwrap();
985            assert!(out.join("assets/app.js").is_file());
986            assert!(!out.join("assets/custom-input.json").exists());
987            assert!(!manifest
988                .integrity
989                .assets
990                .iter()
991                .any(|asset| asset.path == "assets/custom-input.json"));
992        }
993    }
994
995    #[test]
996    fn bundle_output_must_not_delete_projection_inputs() {
997        let root = TempDir::new().unwrap();
998        let app = root.path().join("app");
999        let out = root.path().join("bundle");
1000        write_file(&app, "index.html", "<main>Projection</main>");
1001        write_file(&out, "input.json", "preserve input");
1002        let input = out.join("input.json");
1003        let mut options = projection_bundle_options(app, out);
1004        options.build_options.projection_manifests = vec![input.clone().into()];
1005        let error = build_desktop_bundle(options).unwrap_err();
1006        assert!(matches!(error, DesktopError::OutputPathOverlap { .. }));
1007        assert_eq!(fs::read_to_string(input).unwrap(), "preserve input");
1008    }
1009
1010    #[cfg(unix)]
1011    #[test]
1012    fn projection_inputs_follow_symlink_parent_traversal_before_cleanup() {
1013        let root = TempDir::new().unwrap();
1014        let app = root.path().join("app");
1015        let out = root.path().join("bundle");
1016        write_file(&app, "index.html", "<main>Projection</main>");
1017        write_file(&out, "input.json", "preserve input");
1018        let nested = out.join("nested");
1019        fs::create_dir(&nested).unwrap();
1020        let alias = root.path().join("alias");
1021        std::os::unix::fs::symlink(nested, &alias).unwrap();
1022        let sources = [
1023            alias.join("../input.json").into(),
1024            webui::ProjectionManifestSource::Inline {
1025                manifest_path: alias.join("../not-written.json"),
1026                json: "{}".into(),
1027            },
1028        ];
1029        for source in sources {
1030            let mut options = projection_bundle_options(app.clone(), out.clone());
1031            options.build_options.projection_manifests = vec![source];
1032            let error = build_desktop_bundle(options).unwrap_err();
1033            assert!(matches!(error, DesktopError::OutputPathOverlap { .. }));
1034            assert_eq!(
1035                fs::read_to_string(out.join("input.json")).unwrap(),
1036                "preserve input"
1037            );
1038        }
1039    }
1040
1041    #[test]
1042    fn ipc_asset_names_are_reserved_only_when_enabled() {
1043        let app = TempDir::new().unwrap();
1044        let out = TempDir::new().unwrap();
1045        write_file(app.path(), "index.html", "<main>Hello</main>");
1046        write_file(app.path(), "public/_webui/ipc/runtime.js", "collision");
1047        write_file(
1048            app.path(),
1049            "public/_webui/ipc/bootstrap.js",
1050            "application bootstrap",
1051        );
1052
1053        let result = build_desktop_bundle(DesktopBundleOptions {
1054            build_options: webui::BuildOptions {
1055                app_dir: app.path().to_path_buf(),
1056                entry: "index.html".to_string(),
1057                ..webui::BuildOptions::default()
1058            },
1059            out_dir: out.path().join("desktop"),
1060            state_file: None,
1061            asset_root: Some(app.path().join("public")),
1062            token_css: None,
1063            app_id: "com.microsoft.webui.test".to_string(),
1064            app_name: "WebUI Test".to_string(),
1065            version: "0.0.0".to_string(),
1066            publisher: "Microsoft".to_string(),
1067            window: WindowOptions::default(),
1068            icon_file: None,
1069            shell: DesktopShellConfig::default(),
1070            package_targets: Vec::new(),
1071        });
1072
1073        #[cfg(feature = "application-ipc")]
1074        assert!(matches!(
1075            result.unwrap_err(),
1076            DesktopError::BundleAssetCollision { .. }
1077        ));
1078        #[cfg(not(feature = "application-ipc"))]
1079        {
1080            let manifest = result.unwrap();
1081            for (name, expected) in [
1082                ("runtime.js", "collision"),
1083                ("bootstrap.js", "application bootstrap"),
1084            ] {
1085                let path = format!("assets/_webui/ipc/{name}");
1086                assert_eq!(
1087                    fs::read_to_string(out.path().join("desktop").join(&path)).unwrap(),
1088                    expected
1089                );
1090                assert!(manifest
1091                    .integrity
1092                    .assets
1093                    .iter()
1094                    .any(|asset| asset.path == path));
1095            }
1096        }
1097    }
1098
1099    #[test]
1100    fn copies_app_icon_into_bundle_shell_metadata() {
1101        let app = TempDir::new().unwrap();
1102        let out = TempDir::new().unwrap();
1103        write_file(app.path(), "index.html", "<main>Hello</main>");
1104        write_file(app.path(), "app.icns", "icon");
1105
1106        let manifest = build_desktop_bundle(DesktopBundleOptions {
1107            build_options: build_options(app.path().to_path_buf()),
1108            out_dir: out.path().join("desktop"),
1109            state_file: None,
1110            asset_root: None,
1111            token_css: None,
1112            app_id: "com.microsoft.webui.test".to_string(),
1113            app_name: "WebUI Test".to_string(),
1114            version: "0.0.0".to_string(),
1115            publisher: "Microsoft".to_string(),
1116            window: WindowOptions::default(),
1117            icon_file: Some(app.path().join("app.icns")),
1118            shell: DesktopShellConfig::default(),
1119            package_targets: Vec::new(),
1120        })
1121        .unwrap();
1122
1123        let bundle = out.path().join("desktop");
1124        assert_eq!(
1125            manifest.shell.icon_path,
1126            Some(PathBuf::from("assets/app-icon.icns"))
1127        );
1128        assert!(bundle.join("assets/app-icon.icns").is_file());
1129        assert!(manifest
1130            .integrity
1131            .assets
1132            .iter()
1133            .any(|asset| asset.path == "assets/app-icon.icns"));
1134    }
1135
1136    #[test]
1137    fn rejects_output_inside_app_directory() {
1138        let app = TempDir::new().unwrap();
1139        write_file(app.path(), "index.html", "<main>Hello</main>");
1140
1141        let err = build_desktop_bundle(DesktopBundleOptions {
1142            build_options: build_options(app.path().to_path_buf()),
1143            out_dir: app.path().join("desktop"),
1144            state_file: None,
1145            asset_root: None,
1146            token_css: None,
1147            app_id: "com.microsoft.webui.test".to_string(),
1148            app_name: "WebUI Test".to_string(),
1149            version: "0.0.0".to_string(),
1150            publisher: "Microsoft".to_string(),
1151            window: WindowOptions::default(),
1152            icon_file: None,
1153            shell: DesktopShellConfig::default(),
1154            package_targets: Vec::new(),
1155        })
1156        .unwrap_err();
1157
1158        assert!(matches!(err, DesktopError::OutputPathOverlap { .. }));
1159    }
1160
1161    #[cfg(unix)]
1162    #[test]
1163    fn rejects_symlinked_output_inside_app_directory() {
1164        let root = TempDir::new().unwrap();
1165        let app = root.path().join("app");
1166        let linked_app = root.path().join("linked-app");
1167        write_file(&app, "index.html", "<main>Hello</main>");
1168        std::os::unix::fs::symlink(&app, &linked_app).unwrap();
1169
1170        let err = build_desktop_bundle(DesktopBundleOptions {
1171            build_options: build_options(app),
1172            out_dir: linked_app.join("desktop"),
1173            state_file: None,
1174            asset_root: None,
1175            token_css: None,
1176            app_id: "com.microsoft.webui.test".to_string(),
1177            app_name: "WebUI Test".to_string(),
1178            version: "0.0.0".to_string(),
1179            publisher: "Microsoft".to_string(),
1180            window: WindowOptions::default(),
1181            icon_file: None,
1182            shell: DesktopShellConfig::default(),
1183            package_targets: Vec::new(),
1184        })
1185        .unwrap_err();
1186
1187        assert!(matches!(err, DesktopError::OutputPathOverlap { .. }));
1188    }
1189
1190    #[cfg(unix)]
1191    #[test]
1192    fn rejects_symlink_output_leaf_inside_app_directory() {
1193        let root = TempDir::new().unwrap();
1194        let app = root.path().join("app");
1195        let outside = root.path().join("outside");
1196        let linked_output = app.join("desktop");
1197        write_file(&app, "index.html", "<main>Hello</main>");
1198        fs::create_dir_all(&outside).unwrap();
1199        std::os::unix::fs::symlink(&outside, &linked_output).unwrap();
1200
1201        let err = build_desktop_bundle(DesktopBundleOptions {
1202            build_options: build_options(app),
1203            out_dir: linked_output,
1204            state_file: None,
1205            asset_root: None,
1206            token_css: None,
1207            app_id: "com.microsoft.webui.test".to_string(),
1208            app_name: "WebUI Test".to_string(),
1209            version: "0.0.0".to_string(),
1210            publisher: "Microsoft".to_string(),
1211            window: WindowOptions::default(),
1212            icon_file: None,
1213            shell: DesktopShellConfig::default(),
1214            package_targets: Vec::new(),
1215        })
1216        .unwrap_err();
1217
1218        assert!(matches!(err, DesktopError::OutputPathOverlap { .. }));
1219    }
1220}