#[cfg(feature = "source")]
use std::collections::HashSet;
#[cfg(any(feature = "source", feature = "packaging"))]
use std::ffi::OsString;
use std::fs;
#[cfg(feature = "source")]
use std::io::Read;
#[cfg(any(feature = "source", feature = "packaging"))]
use std::path::Component;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[cfg(feature = "source")]
use serde_json::Value;
#[cfg(feature = "source")]
use sha2::{Digest, Sha256};
#[cfg(feature = "source")]
use webui::RenderOptions;
#[cfg(feature = "source")]
use webui_handler::ResponseWriter;
use crate::error::{DesktopError, Result};
#[cfg(feature = "source")]
use crate::hydration::{handler_for_plugin, plugin_name};
pub use crate::window::WindowOptions;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DesktopShellConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tray: Option<TrayConfig>,
#[serde(default)]
pub menus: Vec<DesktopMenu>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TrayConfig {
pub icon_path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tooltip: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DesktopMenu {
pub id: String,
pub label: String,
#[serde(default)]
pub items: Vec<DesktopMenuItem>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DesktopMenuItem {
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accelerator: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DesktopPackageTarget {
MacosApp,
WindowsPortable,
LinuxPortable,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BundleAsset {
pub path: String,
pub sha256: String,
pub size_bytes: u64,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct BundleIntegrity {
pub protocol_sha256: String,
pub assets: Vec<BundleAsset>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DesktopBundleManifest {
pub manifest_version: u32,
pub app_id: String,
pub app_name: String,
pub version: String,
pub publisher: String,
pub entry: String,
#[serde(default)]
pub plugin: Option<String>,
pub protocol_path: PathBuf,
pub state_path: Option<PathBuf>,
pub assets_dir: PathBuf,
pub ipc_schema: Option<PathBuf>,
pub window: WindowOptions,
#[serde(default)]
pub shell: DesktopShellConfig,
pub package_targets: Vec<DesktopPackageTarget>,
pub integrity: BundleIntegrity,
}
impl DesktopBundleManifest {
pub const VERSION: u32 = 1;
pub fn load(path: &Path) -> Result<Self> {
let bytes = fs::read(path).map_err(|source| DesktopError::Io {
context: format!("reading desktop manifest {}", path.display()),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| DesktopError::ManifestDeserialization {
path: path.to_path_buf(),
source,
})
}
}
#[cfg(feature = "source")]
pub struct DesktopBundleOptions {
pub build_options: webui::BuildOptions,
pub out_dir: PathBuf,
pub state_file: Option<PathBuf>,
pub asset_root: Option<PathBuf>,
pub token_css: Option<std::collections::HashMap<String, String>>,
pub app_id: String,
pub app_name: String,
pub version: String,
pub publisher: String,
pub window: WindowOptions,
pub icon_file: Option<PathBuf>,
pub shell: DesktopShellConfig,
pub package_targets: Vec<DesktopPackageTarget>,
}
#[cfg(feature = "source")]
pub fn build_desktop_bundle(options: DesktopBundleOptions) -> Result<DesktopBundleManifest> {
validate_bundle_output(&options)?;
prepare_out_dir(&options.out_dir)?;
let build_options = options.build_options.clone();
let build_result = webui::build(build_options.clone())?;
let projection_inputs = projection_manifest_paths(&build_options)
.map(normalized_absolute_path)
.collect::<Result<Vec<_>>>()?;
let protocol_path = PathBuf::from("protocol.bin");
let protocol_dest = options.out_dir.join(&protocol_path);
fs::write(&protocol_dest, &build_result.protocol_bytes).map_err(|source| DesktopError::Io {
context: format!("writing desktop protocol {}", protocol_dest.display()),
source,
})?;
let assets_dir = PathBuf::from("assets");
let assets_dest = options.out_dir.join(&assets_dir);
fs::create_dir_all(&assets_dest).map_err(|source| DesktopError::Io {
context: format!(
"creating desktop assets directory {}",
assets_dest.display()
),
source,
})?;
let mut claimed_assets = HashSet::new();
let mut assets =
write_generated_css(&assets_dest, &mut claimed_assets, &build_result.css_files)?;
#[cfg(feature = "application-ipc")]
write_ipc_assets(&assets_dest, &mut claimed_assets, &mut assets)?;
let protocol = webui::Protocol::new(build_result.protocol);
write_startup_html(StartupHtmlInput {
assets_dest: &assets_dest,
claimed_assets: &mut claimed_assets,
assets: &mut assets,
protocol: &protocol,
plugin: build_options.plugin,
entry: &build_options.entry,
state_file: options.state_file.as_ref(),
token_css: options.token_css.as_ref(),
})?;
if let Some(asset_root) = &options.asset_root {
copy_static_assets(
asset_root,
&assets_dest,
&mut claimed_assets,
&mut assets,
&projection_inputs,
)?;
}
let mut shell = options.shell;
if let Some(icon_file) = &options.icon_file {
shell.icon_path = Some(copy_app_icon(
icon_file,
&assets_dest,
&mut claimed_assets,
&mut assets,
)?);
}
let state_path = match &options.state_file {
Some(state_file) => {
let state_dest_name = PathBuf::from("state.json");
let state_dest = options.out_dir.join(&state_dest_name);
write_bundle_state(state_file, options.token_css.as_ref(), &state_dest)?;
Some(state_dest_name)
}
None => match options.token_css.as_ref() {
Some(token_css) => {
let state_dest_name = PathBuf::from("state.json");
let state_dest = options.out_dir.join(&state_dest_name);
let mut state = Value::Object(serde_json::Map::new());
webui_tokens::inject_token_css(&mut state, token_css);
write_state_value(&state, &state_dest)?;
Some(state_dest_name)
}
None => None,
},
};
let manifest = DesktopBundleManifest {
manifest_version: DesktopBundleManifest::VERSION,
app_id: options.app_id,
app_name: options.app_name,
version: options.version,
publisher: options.publisher,
entry: build_options.entry,
plugin: build_options.plugin.map(plugin_name).map(str::to_string),
protocol_path,
state_path,
assets_dir,
ipc_schema: None,
window: options.window,
shell,
package_targets: options.package_targets,
integrity: BundleIntegrity {
protocol_sha256: sha256_file(&protocol_dest)?,
assets,
},
};
let manifest_path = options.out_dir.join("manifest.webui-desktop.json");
let manifest_bytes =
serde_json::to_vec_pretty(&manifest).map_err(DesktopError::ManifestSerialization)?;
fs::write(&manifest_path, manifest_bytes).map_err(|source| DesktopError::Io {
context: format!("writing desktop manifest {}", manifest_path.display()),
source,
})?;
Ok(manifest)
}
#[cfg(feature = "source")]
fn validate_bundle_output(options: &DesktopBundleOptions) -> Result<()> {
let output = normalized_absolute_path(&options.out_dir)?;
let lexical_output = lexical_absolute_path(&options.out_dir)?;
let app_dir = normalized_absolute_path(&options.build_options.app_dir)?;
let lexical_app_dir = lexical_absolute_path(&options.build_options.app_dir)?;
reject_path_overlap(&output, &app_dir, "app")?;
reject_path_overlap(&lexical_output, &app_dir, "app")?;
reject_path_overlap(&output, &lexical_app_dir, "app")?;
reject_path_overlap(&lexical_output, &lexical_app_dir, "app")?;
if let Some(state_file) = &options.state_file {
let state = normalized_absolute_path(state_file)?;
let lexical_state = lexical_absolute_path(state_file)?;
reject_path_overlap(&output, &state, "state")?;
reject_path_overlap(&lexical_output, &state, "state")?;
reject_path_overlap(&output, &lexical_state, "state")?;
reject_path_overlap(&lexical_output, &lexical_state, "state")?;
}
if let Some(asset_root) = &options.asset_root {
let assets = normalized_absolute_path(asset_root)?;
let lexical_assets = lexical_absolute_path(asset_root)?;
reject_path_overlap(&output, &assets, "asset")?;
reject_path_overlap(&lexical_output, &assets, "asset")?;
reject_path_overlap(&output, &lexical_assets, "asset")?;
reject_path_overlap(&lexical_output, &lexical_assets, "asset")?;
}
for path in projection_manifest_paths(&options.build_options) {
let input = normalized_absolute_path(path)?;
let lexical_input = lexical_absolute_path(path)?;
reject_path_overlap(&output, &input, "projection manifest")?;
reject_path_overlap(&lexical_output, &input, "projection manifest")?;
reject_path_overlap(&output, &lexical_input, "projection manifest")?;
reject_path_overlap(&lexical_output, &lexical_input, "projection manifest")?;
}
Ok(())
}
#[cfg(feature = "source")]
fn projection_manifest_paths(options: &webui::BuildOptions) -> impl Iterator<Item = &Path> {
options
.projection_manifests
.iter()
.filter_map(|source| match source {
webui::ProjectionManifestSource::Path(path)
| webui::ProjectionManifestSource::Inline {
manifest_path: path,
..
} => Some(path.as_path()),
webui::ProjectionManifestSource::Prepared(_)
| webui::ProjectionManifestSource::Pending(_) => None,
})
}
#[cfg(feature = "source")]
fn prepare_out_dir(out_dir: &Path) -> Result<()> {
if out_dir.exists() {
fs::remove_dir_all(out_dir).map_err(|source| DesktopError::Io {
context: format!("cleaning desktop bundle directory {}", out_dir.display()),
source,
})?;
}
fs::create_dir_all(out_dir).map_err(|source| DesktopError::Io {
context: format!("creating desktop bundle directory {}", out_dir.display()),
source,
})
}
#[cfg(any(feature = "source", feature = "packaging"))]
pub(crate) fn normalized_absolute_path(path: &Path) -> Result<PathBuf> {
let absolute = absolute_path(path)?;
let base = resolve_existing_ancestor(&absolute)?;
Ok(normalize_components(&base))
}
#[cfg(any(feature = "source", feature = "packaging"))]
pub(crate) fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
Ok(normalize_components(&absolute_path(path)?))
}
#[cfg(any(feature = "source", feature = "packaging"))]
fn absolute_path(path: &Path) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|source| DesktopError::Io {
context: "resolving current directory for desktop output validation".to_string(),
source,
})?
.join(path)
};
Ok(absolute)
}
#[cfg(any(feature = "source", feature = "packaging"))]
fn normalize_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
#[cfg(any(feature = "source", feature = "packaging"))]
fn resolve_existing_ancestor(path: &Path) -> Result<PathBuf> {
if path.exists() {
return path.canonicalize().map_err(|source| DesktopError::Io {
context: format!("resolving desktop path {}", path.display()),
source,
});
}
let mut missing = Vec::<OsString>::new();
let mut cursor = path;
while !cursor.exists() {
if let Some(name) = cursor.file_name() {
missing.push(name.to_os_string());
}
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent;
}
let mut resolved = cursor.canonicalize().map_err(|source| DesktopError::Io {
context: format!("resolving desktop path ancestor {}", cursor.display()),
source,
})?;
for component in missing.iter().rev() {
resolved.push(component);
}
Ok(resolved)
}
#[cfg(any(feature = "source", feature = "packaging"))]
pub(crate) fn reject_path_overlap(
output: &Path,
input: &Path,
input_label: &'static str,
) -> Result<()> {
if output == input || output.starts_with(input) || input.starts_with(output) {
return Err(DesktopError::OutputPathOverlap {
output: output.to_path_buf(),
input: input.to_path_buf(),
input_label,
});
}
Ok(())
}
#[cfg(feature = "source")]
fn write_generated_css(
assets_dest: &Path,
claimed_assets: &mut HashSet<String>,
css_files: &[(String, String)],
) -> Result<Vec<BundleAsset>> {
let mut assets = Vec::with_capacity(css_files.len());
for (name, content) in css_files {
claim_asset(claimed_assets, name)?;
let path = assets_dest.join(name);
fs::write(&path, content).map_err(|source| DesktopError::Io {
context: format!("writing generated desktop CSS {}", path.display()),
source,
})?;
assets.push(asset_record(name, &path)?);
}
Ok(assets)
}
#[cfg(feature = "source")]
#[cfg(feature = "application-ipc")]
fn write_ipc_assets(
assets_dest: &Path,
claimed_assets: &mut HashSet<String>,
assets: &mut Vec<BundleAsset>,
) -> Result<()> {
for (name, bytes) in [
("_webui/ipc/runtime.js", crate::ipc_assets::BROWSER_RUNTIME),
(
"_webui/ipc/bootstrap.js",
crate::ipc_assets::NATIVE_BOOTSTRAP_SCRIPT.as_bytes(),
),
] {
claim_asset(claimed_assets, name)?;
let path = assets_dest.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| DesktopError::Io {
context: format!("creating desktop IPC asset directory {}", parent.display()),
source,
})?;
}
fs::write(&path, bytes).map_err(|source| DesktopError::Io {
context: format!("writing desktop IPC asset {}", path.display()),
source,
})?;
assets.push(asset_record(name, &path)?);
}
Ok(())
}
#[cfg(feature = "source")]
fn copy_app_icon(
icon_file: &Path,
assets_dest: &Path,
claimed_assets: &mut HashSet<String>,
assets: &mut Vec<BundleAsset>,
) -> Result<PathBuf> {
let extension = icon_file
.extension()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.unwrap_or("icon");
let mut relative = String::with_capacity("app-icon.".len() + extension.len());
relative.push_str("app-icon.");
relative.push_str(extension);
claim_asset(claimed_assets, &relative)?;
let dest = assets_dest.join(&relative);
copy_file_to(icon_file, &dest)?;
assets.push(asset_record(&relative, &dest)?);
Ok(PathBuf::from("assets").join(relative))
}
#[cfg(feature = "source")]
struct StartupHtmlInput<'a> {
assets_dest: &'a Path,
claimed_assets: &'a mut HashSet<String>,
assets: &'a mut Vec<BundleAsset>,
protocol: &'a webui::Protocol,
plugin: Option<webui::Plugin>,
entry: &'a str,
state_file: Option<&'a PathBuf>,
token_css: Option<&'a std::collections::HashMap<String, String>>,
}
#[cfg(feature = "source")]
fn write_startup_html(input: StartupHtmlInput<'_>) -> Result<()> {
const STARTUP_HTML_NAME: &str = "index.html";
claim_asset(input.claimed_assets, STARTUP_HTML_NAME)?;
let mut state = read_bundle_state(input.state_file)?;
if let Some(token_css) = input.token_css {
webui_tokens::inject_token_css(&mut state, token_css);
}
if let Value::Object(map) = &mut state {
map.insert("basePath".to_string(), Value::String("/".to_string()));
}
let handler = handler_for_plugin(input.plugin);
let mut writer = MemoryWriter::with_capacity(4096);
handler.render(
input.protocol,
&state,
&RenderOptions::new(input.entry, "/"),
&mut writer,
)?;
let path = input.assets_dest.join(STARTUP_HTML_NAME);
fs::write(&path, writer.buf).map_err(|source| DesktopError::Io {
context: format!("writing desktop startup HTML {}", path.display()),
source,
})?;
input.assets.push(asset_record(STARTUP_HTML_NAME, &path)?);
Ok(())
}
#[cfg(feature = "source")]
fn read_bundle_state(path: Option<&PathBuf>) -> Result<Value> {
let Some(path) = path else {
return Ok(Value::Object(serde_json::Map::new()));
};
let json = fs::read_to_string(path).map_err(|source| DesktopError::Io {
context: format!("reading desktop state {}", path.display()),
source,
})?;
serde_json::from_str(&json).map_err(|source| DesktopError::StateJson {
path: path.clone(),
source,
})
}
#[cfg(feature = "source")]
fn write_bundle_state(
state_file: &Path,
token_css: Option<&std::collections::HashMap<String, String>>,
state_dest: &Path,
) -> Result<()> {
if token_css.is_none() {
return copy_file(state_file, state_dest);
}
let mut state = read_bundle_state(Some(&state_file.to_path_buf()))?;
if let Some(token_css) = token_css {
webui_tokens::inject_token_css(&mut state, token_css);
}
write_state_value(&state, state_dest)
}
#[cfg(feature = "source")]
fn write_state_value(state: &Value, state_dest: &Path) -> Result<()> {
let bytes = serde_json::to_vec_pretty(state).map_err(|source| DesktopError::Serialization {
context: "serializing desktop bundle state".to_string(),
source,
})?;
fs::write(state_dest, bytes).map_err(|source| DesktopError::Io {
context: format!("writing desktop bundle state {}", state_dest.display()),
source,
})
}
#[cfg(feature = "source")]
struct MemoryWriter {
buf: String,
}
#[cfg(feature = "source")]
impl MemoryWriter {
fn with_capacity(capacity: usize) -> Self {
Self {
buf: String::with_capacity(capacity),
}
}
}
#[cfg(feature = "source")]
impl ResponseWriter for MemoryWriter {
fn write(&mut self, content: &str) -> webui_handler::Result<()> {
self.buf.push_str(content);
Ok(())
}
fn end(&mut self) -> webui_handler::Result<()> {
Ok(())
}
}
#[cfg(feature = "source")]
fn copy_static_assets(
asset_root: &Path,
assets_dest: &Path,
claimed_assets: &mut HashSet<String>,
assets: &mut Vec<BundleAsset>,
build_inputs: &[PathBuf],
) -> Result<()> {
let canonical_root = asset_root
.canonicalize()
.map_err(|source| DesktopError::Io {
context: format!("resolving desktop static assets {}", asset_root.display()),
source,
})?;
let mut stack = vec![canonical_root.clone()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).map_err(|source| DesktopError::Io {
context: format!("reading desktop static assets {}", dir.display()),
source,
})? {
let entry = entry.map_err(|source| DesktopError::Io {
context: format!("reading desktop static asset entry in {}", dir.display()),
source,
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|source| DesktopError::Io {
context: format!("reading desktop static asset type {}", path.display()),
source,
})?;
if file_type.is_dir() {
stack.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
let canonical = path.canonicalize().map_err(|source| DesktopError::Io {
context: format!("resolving desktop static asset {}", path.display()),
source,
})?;
if !canonical.starts_with(&canonical_root) {
return Err(DesktopError::InvalidAssetPath {
path: canonical.display().to_string(),
});
}
if build_inputs.contains(&canonical) {
continue;
}
let relative = canonical.strip_prefix(&canonical_root).map_err(|_| {
DesktopError::InvalidAssetPath {
path: canonical.display().to_string(),
}
})?;
let relative_string = relative.to_string_lossy().replace('\\', "/");
claim_asset(claimed_assets, &relative_string)?;
let dest = assets_dest.join(relative);
copy_file(&canonical, &dest)?;
assets.push(asset_record(&relative_string, &dest)?);
}
}
Ok(())
}
#[cfg(feature = "source")]
fn claim_asset(claimed_assets: &mut HashSet<String>, path: &str) -> Result<()> {
if claimed_assets.insert(path.to_string()) {
return Ok(());
}
Err(DesktopError::BundleAssetCollision {
path: path.to_string(),
})
}
#[cfg(feature = "source")]
fn copy_file(source: &Path, dest: &Path) -> Result<()> {
copy_file_to(source, dest)
}
#[cfg(feature = "source")]
pub(crate) fn copy_file_to(source: &Path, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|source| DesktopError::Io {
context: format!("creating desktop bundle directory {}", parent.display()),
source,
})?;
}
fs::copy(source, dest).map_err(|source| DesktopError::Io {
context: format!("copying desktop bundle file to {}", dest.display()),
source,
})?;
Ok(())
}
#[cfg(feature = "source")]
fn asset_record(relative: &str, path: &Path) -> Result<BundleAsset> {
let size_bytes = fs::metadata(path)
.map_err(|source| DesktopError::Io {
context: format!("reading desktop bundle asset metadata {}", path.display()),
source,
})?
.len();
let mut bundle_path = String::with_capacity("assets/".len() + relative.len());
bundle_path.push_str("assets/");
bundle_path.push_str(relative);
Ok(BundleAsset {
path: bundle_path,
sha256: sha256_file(path)?,
size_bytes,
})
}
#[cfg(feature = "source")]
fn sha256_file(path: &Path) -> Result<String> {
let mut file = fs::File::open(path).map_err(|source| DesktopError::Io {
context: format!("hashing desktop bundle file {}", path.display()),
source,
})?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 16 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|source| DesktopError::Io {
context: format!("hashing desktop bundle file {}", path.display()),
source,
})?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let digest = hasher.finalize();
Ok(hex_lower(&digest))
}
#[cfg(feature = "source")]
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
#[cfg(all(test, feature = "source"))]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_file(root: &Path, path: &str, content: &str) {
let full = root.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(full, content).unwrap();
}
fn build_options(app_dir: PathBuf) -> webui::BuildOptions {
webui::BuildOptions {
app_dir,
entry: "index.html".to_string(),
plugin: Some(webui::Plugin::WebUI),
..webui::BuildOptions::default()
}
}
#[test]
fn builds_manifest_with_protocol_state_and_assets() {
let app = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
write_file(
app.path(),
"index.html",
"<my-card></my-card><main>Hello {{name}}</main>",
);
write_file(app.path(), "my-card.html", "<p class=\"card\">Card</p>");
write_file(app.path(), "my-card.css", ".card { color: red; }");
write_file(app.path(), "state.json", r#"{"name":"Bundle"}"#);
write_file(app.path(), "public/app.js", "console.log('bundle');");
let manifest = build_desktop_bundle(DesktopBundleOptions {
build_options: build_options(app.path().to_path_buf()),
out_dir: out.path().join("desktop"),
state_file: Some(app.path().join("state.json")),
asset_root: Some(app.path().join("public")),
token_css: None,
app_id: "com.microsoft.webui.test".to_string(),
app_name: "WebUI Test".to_string(),
version: "0.0.0".to_string(),
publisher: "Microsoft".to_string(),
window: WindowOptions::default(),
icon_file: None,
shell: DesktopShellConfig::default(),
package_targets: vec![DesktopPackageTarget::MacosApp],
})
.unwrap();
let bundle = out.path().join("desktop");
assert!(bundle.join("protocol.bin").is_file());
assert!(bundle.join("state.json").is_file());
assert!(bundle.join("assets/app.js").is_file());
assert!(bundle.join("assets/my-card.css").is_file());
assert!(bundle.join("assets/index.html").is_file());
for name in ["runtime.js", "bootstrap.js"] {
let path = format!("assets/_webui/ipc/{name}");
assert_eq!(
bundle.join(&path).is_file(),
cfg!(feature = "application-ipc"),
"{path}"
);
assert_eq!(
manifest
.integrity
.assets
.iter()
.any(|asset| asset.path == path),
cfg!(feature = "application-ipc"),
"{path}"
);
}
assert!(fs::read_to_string(bundle.join("assets/index.html"))
.unwrap()
.contains(r#"<link rel="stylesheet" href="my-card.css""#));
assert!(bundle.join("manifest.webui-desktop.json").is_file());
assert_eq!(manifest.state_path, Some(PathBuf::from("state.json")));
assert!(manifest
.integrity
.assets
.iter()
.any(|asset| asset.path == "assets/app.js"));
}
fn projection_bundle_options(app: PathBuf, out: PathBuf) -> DesktopBundleOptions {
DesktopBundleOptions {
build_options: build_options(app),
out_dir: out,
state_file: None,
asset_root: None,
token_css: None,
app_id: "webui.test.projection".into(),
app_name: "Projection".into(),
version: "0.0.0".into(),
publisher: "Microsoft".into(),
window: WindowOptions::default(),
icon_file: None,
shell: DesktopShellConfig::default(),
package_targets: Vec::new(),
}
}
#[test]
fn direct_bundles_exclude_projection_inputs_from_files_and_integrity() {
use webui_protocol::projection_manifest::{
ProjectionAdapter, ProjectionManifest, ProjectionProducer, PRODUCER_NAME, SCHEMA_ID,
};
let root = TempDir::new().unwrap();
let app = root.path().join("app");
let assets = root.path().join("dist");
write_file(&app, "index.html", "<main>Projection</main>");
write_file(&assets, "app.js", "export {};");
let mut proof = ProjectionManifest {
schema: SCHEMA_ID.into(),
producer: ProjectionProducer {
name: PRODUCER_NAME.into(),
version: env!("CARGO_PKG_VERSION").into(),
},
adapter: ProjectionAdapter {
name: "test".into(),
bundler: "test@1.0.0".into(),
},
root: ".".into(),
analysis_hash: format!("sha256:{}", "1".repeat(64)),
build_id: String::new(),
inputs: Default::default(),
outputs: Default::default(),
components: Default::default(),
entry_closures: Default::default(),
};
proof.build_id = proof.compute_build_id();
let json = serde_json::to_string(&proof).unwrap();
let input = assets.join("custom-input.json");
fs::write(&input, &json).unwrap();
let sources = vec![
input.clone().into(),
webui::ProjectionManifestSource::Inline {
manifest_path: input.clone(),
json: json.clone(),
},
];
#[cfg(unix)]
let sources = {
let nested = assets.join("nested");
fs::create_dir(&nested).unwrap();
let alias = root.path().join("alias");
std::os::unix::fs::symlink(nested, &alias).unwrap();
let linked_input = alias.join("../custom-input.json");
let mut sources = sources;
sources.push(linked_input.clone().into());
sources.push(webui::ProjectionManifestSource::Inline {
manifest_path: linked_input,
json,
});
sources
};
for (index, source) in sources.into_iter().enumerate() {
let out = root.path().join(format!("bundle-{index}"));
let mut options = projection_bundle_options(app.clone(), out.clone());
options.asset_root = Some(assets.clone());
options.build_options.projection_manifests = vec![source];
let manifest = build_desktop_bundle(options).unwrap();
assert!(out.join("assets/app.js").is_file());
assert!(!out.join("assets/custom-input.json").exists());
assert!(!manifest
.integrity
.assets
.iter()
.any(|asset| asset.path == "assets/custom-input.json"));
}
}
#[test]
fn bundle_output_must_not_delete_projection_inputs() {
let root = TempDir::new().unwrap();
let app = root.path().join("app");
let out = root.path().join("bundle");
write_file(&app, "index.html", "<main>Projection</main>");
write_file(&out, "input.json", "preserve input");
let input = out.join("input.json");
let mut options = projection_bundle_options(app, out);
options.build_options.projection_manifests = vec![input.clone().into()];
let error = build_desktop_bundle(options).unwrap_err();
assert!(matches!(error, DesktopError::OutputPathOverlap { .. }));
assert_eq!(fs::read_to_string(input).unwrap(), "preserve input");
}
#[cfg(unix)]
#[test]
fn projection_inputs_follow_symlink_parent_traversal_before_cleanup() {
let root = TempDir::new().unwrap();
let app = root.path().join("app");
let out = root.path().join("bundle");
write_file(&app, "index.html", "<main>Projection</main>");
write_file(&out, "input.json", "preserve input");
let nested = out.join("nested");
fs::create_dir(&nested).unwrap();
let alias = root.path().join("alias");
std::os::unix::fs::symlink(nested, &alias).unwrap();
let sources = [
alias.join("../input.json").into(),
webui::ProjectionManifestSource::Inline {
manifest_path: alias.join("../not-written.json"),
json: "{}".into(),
},
];
for source in sources {
let mut options = projection_bundle_options(app.clone(), out.clone());
options.build_options.projection_manifests = vec![source];
let error = build_desktop_bundle(options).unwrap_err();
assert!(matches!(error, DesktopError::OutputPathOverlap { .. }));
assert_eq!(
fs::read_to_string(out.join("input.json")).unwrap(),
"preserve input"
);
}
}
#[test]
fn ipc_asset_names_are_reserved_only_when_enabled() {
let app = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
write_file(app.path(), "index.html", "<main>Hello</main>");
write_file(app.path(), "public/_webui/ipc/runtime.js", "collision");
write_file(
app.path(),
"public/_webui/ipc/bootstrap.js",
"application bootstrap",
);
let result = build_desktop_bundle(DesktopBundleOptions {
build_options: webui::BuildOptions {
app_dir: app.path().to_path_buf(),
entry: "index.html".to_string(),
..webui::BuildOptions::default()
},
out_dir: out.path().join("desktop"),
state_file: None,
asset_root: Some(app.path().join("public")),
token_css: None,
app_id: "com.microsoft.webui.test".to_string(),
app_name: "WebUI Test".to_string(),
version: "0.0.0".to_string(),
publisher: "Microsoft".to_string(),
window: WindowOptions::default(),
icon_file: None,
shell: DesktopShellConfig::default(),
package_targets: Vec::new(),
});
#[cfg(feature = "application-ipc")]
assert!(matches!(
result.unwrap_err(),
DesktopError::BundleAssetCollision { .. }
));
#[cfg(not(feature = "application-ipc"))]
{
let manifest = result.unwrap();
for (name, expected) in [
("runtime.js", "collision"),
("bootstrap.js", "application bootstrap"),
] {
let path = format!("assets/_webui/ipc/{name}");
assert_eq!(
fs::read_to_string(out.path().join("desktop").join(&path)).unwrap(),
expected
);
assert!(manifest
.integrity
.assets
.iter()
.any(|asset| asset.path == path));
}
}
}
#[test]
fn copies_app_icon_into_bundle_shell_metadata() {
let app = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
write_file(app.path(), "index.html", "<main>Hello</main>");
write_file(app.path(), "app.icns", "icon");
let manifest = build_desktop_bundle(DesktopBundleOptions {
build_options: build_options(app.path().to_path_buf()),
out_dir: out.path().join("desktop"),
state_file: None,
asset_root: None,
token_css: None,
app_id: "com.microsoft.webui.test".to_string(),
app_name: "WebUI Test".to_string(),
version: "0.0.0".to_string(),
publisher: "Microsoft".to_string(),
window: WindowOptions::default(),
icon_file: Some(app.path().join("app.icns")),
shell: DesktopShellConfig::default(),
package_targets: Vec::new(),
})
.unwrap();
let bundle = out.path().join("desktop");
assert_eq!(
manifest.shell.icon_path,
Some(PathBuf::from("assets/app-icon.icns"))
);
assert!(bundle.join("assets/app-icon.icns").is_file());
assert!(manifest
.integrity
.assets
.iter()
.any(|asset| asset.path == "assets/app-icon.icns"));
}
#[test]
fn rejects_output_inside_app_directory() {
let app = TempDir::new().unwrap();
write_file(app.path(), "index.html", "<main>Hello</main>");
let err = build_desktop_bundle(DesktopBundleOptions {
build_options: build_options(app.path().to_path_buf()),
out_dir: app.path().join("desktop"),
state_file: None,
asset_root: None,
token_css: None,
app_id: "com.microsoft.webui.test".to_string(),
app_name: "WebUI Test".to_string(),
version: "0.0.0".to_string(),
publisher: "Microsoft".to_string(),
window: WindowOptions::default(),
icon_file: None,
shell: DesktopShellConfig::default(),
package_targets: Vec::new(),
})
.unwrap_err();
assert!(matches!(err, DesktopError::OutputPathOverlap { .. }));
}
#[cfg(unix)]
#[test]
fn rejects_symlinked_output_inside_app_directory() {
let root = TempDir::new().unwrap();
let app = root.path().join("app");
let linked_app = root.path().join("linked-app");
write_file(&app, "index.html", "<main>Hello</main>");
std::os::unix::fs::symlink(&app, &linked_app).unwrap();
let err = build_desktop_bundle(DesktopBundleOptions {
build_options: build_options(app),
out_dir: linked_app.join("desktop"),
state_file: None,
asset_root: None,
token_css: None,
app_id: "com.microsoft.webui.test".to_string(),
app_name: "WebUI Test".to_string(),
version: "0.0.0".to_string(),
publisher: "Microsoft".to_string(),
window: WindowOptions::default(),
icon_file: None,
shell: DesktopShellConfig::default(),
package_targets: Vec::new(),
})
.unwrap_err();
assert!(matches!(err, DesktopError::OutputPathOverlap { .. }));
}
#[cfg(unix)]
#[test]
fn rejects_symlink_output_leaf_inside_app_directory() {
let root = TempDir::new().unwrap();
let app = root.path().join("app");
let outside = root.path().join("outside");
let linked_output = app.join("desktop");
write_file(&app, "index.html", "<main>Hello</main>");
fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, &linked_output).unwrap();
let err = build_desktop_bundle(DesktopBundleOptions {
build_options: build_options(app),
out_dir: linked_output,
state_file: None,
asset_root: None,
token_css: None,
app_id: "com.microsoft.webui.test".to_string(),
app_name: "WebUI Test".to_string(),
version: "0.0.0".to_string(),
publisher: "Microsoft".to_string(),
window: WindowOptions::default(),
icon_file: None,
shell: DesktopShellConfig::default(),
package_targets: Vec::new(),
})
.unwrap_err();
assert!(matches!(err, DesktopError::OutputPathOverlap { .. }));
}
}