1#[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#[derive(Clone, Debug, Default, Deserialize, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct DesktopShellConfig {
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub icon_path: Option<PathBuf>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub tray: Option<TrayConfig>,
41 #[serde(default)]
43 pub menus: Vec<DesktopMenu>,
44}
45
46#[derive(Clone, Debug, Deserialize, Serialize)]
48pub struct TrayConfig {
49 pub icon_path: PathBuf,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub tooltip: Option<String>,
54}
55
56#[derive(Clone, Debug, Deserialize, Serialize)]
58pub struct DesktopMenu {
59 pub id: String,
61 pub label: String,
63 #[serde(default)]
65 pub items: Vec<DesktopMenuItem>,
66}
67
68#[derive(Clone, Debug, Deserialize, Serialize)]
70pub struct DesktopMenuItem {
71 pub id: String,
73 pub label: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub command: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub accelerator: Option<String>,
81}
82
83#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum DesktopPackageTarget {
87 MacosApp,
89 WindowsPortable,
91 LinuxPortable,
93}
94
95#[derive(Clone, Debug, Deserialize, Serialize)]
97pub struct BundleAsset {
98 pub path: String,
100 pub sha256: String,
102 pub size_bytes: u64,
104}
105
106#[derive(Clone, Debug, Default, Deserialize, Serialize)]
108pub struct BundleIntegrity {
109 pub protocol_sha256: String,
111 pub assets: Vec<BundleAsset>,
113}
114
115#[derive(Clone, Debug, Deserialize, Serialize)]
117pub struct DesktopBundleManifest {
118 pub manifest_version: u32,
120 pub app_id: String,
122 pub app_name: String,
124 pub version: String,
126 pub publisher: String,
128 pub entry: String,
130 #[serde(default)]
132 pub plugin: Option<String>,
133 pub protocol_path: PathBuf,
135 pub state_path: Option<PathBuf>,
137 pub assets_dir: PathBuf,
139 pub ipc_schema: Option<PathBuf>,
141 pub window: WindowOptions,
143 #[serde(default)]
145 pub shell: DesktopShellConfig,
146 pub package_targets: Vec<DesktopPackageTarget>,
148 pub integrity: BundleIntegrity,
150}
151
152impl DesktopBundleManifest {
153 pub const VERSION: u32 = 1;
155
156 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#[cfg(feature = "source")]
175pub struct DesktopBundleOptions {
176 pub build_options: webui::BuildOptions,
178 pub out_dir: PathBuf,
180 pub state_file: Option<PathBuf>,
182 pub asset_root: Option<PathBuf>,
184 pub token_css: Option<std::collections::HashMap<String, String>>,
186 pub app_id: String,
188 pub app_name: String,
190 pub version: String,
192 pub publisher: String,
194 pub window: WindowOptions,
196 pub icon_file: Option<PathBuf>,
198 pub shell: DesktopShellConfig,
200 pub package_targets: Vec<DesktopPackageTarget>,
202}
203
204#[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 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 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}