microsoft-webui-desktop 3.0.2

Configurable native desktop SDK for WebUI applications
Documentation
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use clap::Args;

use crate::{print_field, print_finish, print_header};

/// Arguments for `webui desktop init`.
#[derive(Args)]
pub(crate) struct InitArgs {
    /// Directory in which to create the desktop app.
    #[arg(default_value = ".")]
    pub(crate) path: PathBuf,

    /// Replace files generated by a previous init.
    #[arg(long)]
    pub(crate) force: bool,
}

pub(crate) fn execute(args: &InitArgs) -> Result<()> {
    let root = args
        .path
        .canonicalize()
        .unwrap_or_else(|_| args.path.clone());
    fs::create_dir_all(&root)
        .with_context(|| format!("failed to create app directory {}", root.display()))?;

    let files = generated_files(&root)?;
    let conflicts: Vec<&Path> = files
        .iter()
        .map(|(path, _)| path.as_path())
        .filter(|path| path.exists())
        .collect();
    if !conflicts.is_empty() && !args.force {
        let names = conflicts
            .iter()
            .map(|path| {
                path.strip_prefix(&root)
                    .unwrap_or(path)
                    .display()
                    .to_string()
            })
            .collect::<Vec<_>>()
            .join(", ");
        return Err(anyhow::anyhow!(
            "desktop init would overwrite existing files: {names}; help: choose an empty directory or rerun with --force"
        ));
    }

    print_header("WebUI Desktop Init");
    for (path, content) in files {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?;
        print_field(
            "Created",
            &path.strip_prefix(&root).unwrap_or(&path).display(),
        );
    }
    print_finish("Desktop app scaffold complete");
    Ok(())
}

fn generated_files(root: &Path) -> Result<Vec<(PathBuf, String)>> {
    Ok(vec![
        (root.join("package.json"), PACKAGE_JSON.to_string()),
        (root.join("webui-desktop.json"), DESKTOP_CONFIG.to_string()),
        (root.join("src/index.html"), INDEX_HTML.to_string()),
        (root.join("desktop/Cargo.toml"), desktop_cargo_toml()?),
        (root.join("desktop/src/main.rs"), DESKTOP_MAIN.to_string()),
    ])
}

const PACKAGE_JSON: &str = r#"{
  "name": "webui-desktop-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webui build ./src --out ./dist"
  }
}
"#;

const DESKTOP_CONFIG: &str = r#"{
  "appId": "com.example.webui.desktop",
  "appName": "WebUI Desktop App",
  "title": "WebUI Desktop App"
}
"#;

const INDEX_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>WebUI Desktop App</title>
</head>
<body>
  <main>
    <h1>WebUI Desktop App</h1>
    <p>Edit <code>src/index.html</code> to get started.</p>
  </main>
</body>
</html>
"#;

fn desktop_cargo_toml() -> Result<String> {
    let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
    let crate_dir = workspace.join("crates/webui-desktop");
    let mut text = String::from(
        r#"[package]
name = "webui-desktop-app"
version = "0.1.0"
edition = "2021"
publish = false

[[bin]]
name = "webui-desktop-app"
path = "src/main.rs"

[features]
default = []
source = ["webui-desktop/source", "dep:serde", "dep:serde_json"]

[workspace]

[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
strip = true

[dependencies]
"#,
    );
    text.push_str("webui-desktop = { package = \"microsoft-webui-desktop\", ");
    if crate_dir.is_dir() {
        let crate_dir = crate_dir
            .canonicalize()
            .with_context(|| format!("failed to locate SDK at {}", crate_dir.display()))?;
        text.push_str("path = ");
        // JSON string quoting also produces a valid TOML basic string, including
        // checkout paths containing Windows separators, spaces, or quotes.
        text.push_str(&serde_json::to_string(&crate_dir.to_string_lossy())?);
    } else {
        text.push_str("version = \"");
        text.push_str(env!("CARGO_PKG_VERSION"));
        text.push('"');
    }
    text.push_str(", default-features = false, features = [\"native\"] }\n");
    text.push_str("serde = { version = \"1.0\", features = [\"derive\"], optional = true }\n");
    text.push_str("serde_json = { version = \"1.0\", optional = true }\n");
    Ok(text)
}

const DESKTOP_MAIN: &str = r#"// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

use std::process::ExitCode;

use webui_desktop::DesktopError;
#[cfg(feature = "source")]
use webui_desktop::{BuildOptions, DesktopSourceConfig, WindowOptions};
use webui_desktop::{DesktopApp, DesktopAppBuilder, Result};

#[cfg(feature = "source")]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct DesktopSettings {
    app_id: String,
    #[serde(flatten)]
    window: WindowOptions,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("{}", error.chain_message());
            if let Some(help) = error.hint() {
                eprintln!("help: {help}");
            }
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let app = match webui_desktop::find_packaged_resources_dir() {
        Some(resources) => DesktopApp::from_bundle(resources)?,
        None => source_app()?,
    };
    webui_desktop::run_frame(app.build()?)
}

#[cfg(feature = "source")]
fn source_app() -> Result<DesktopAppBuilder> {
    let settings: DesktopSettings =
        serde_json::from_str(include_str!("../../webui-desktop.json")).map_err(|source| {
            DesktopError::Serialization {
                context: "parsing webui-desktop.json; help: correct its appId and window settings"
                    .to_string(),
                source,
            }
        })?;
    let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("src");
    let options = BuildOptions {
        app_dir,
        ..Default::default()
    };
    Ok(DesktopApp::from_source(DesktopSourceConfig::new(options))
        .app_id(settings.app_id)
        .window(settings.window))
}

#[cfg(not(feature = "source"))]
fn source_app() -> Result<DesktopAppBuilder> {
    Err(DesktopError::PackagedResourcesNotFound)
}
"#;

#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
    use super::*;
    use std::process::Command;
    use tempfile::TempDir;

    #[test]
    fn creates_scaffold_and_runner() {
        let temp = TempDir::new().unwrap();
        execute(&InitArgs {
            path: temp.path().join("app"),
            force: false,
        })
        .unwrap();
        assert!(temp.path().join("app/package.json").is_file());
        assert!(!fs::read_to_string(temp.path().join("app/package.json"))
            .unwrap()
            .contains("webuiDesktop"));
        assert!(temp.path().join("app/webui-desktop.json").is_file());
        assert!(temp.path().join("app/src/index.html").is_file());
        assert!(temp.path().join("app/desktop/src/main.rs").is_file());
        assert!(
            fs::read_to_string(temp.path().join("app/desktop/src/main.rs"))
                .unwrap()
                .contains("include_str!(\"../../webui-desktop.json\")")
        );
    }

    #[test]
    fn refuses_existing_generated_files_without_force() {
        let temp = TempDir::new().unwrap();
        fs::create_dir_all(temp.path().join("app")).unwrap();
        fs::write(temp.path().join("app/package.json"), "{}").unwrap();
        let error = execute(&InitArgs {
            path: temp.path().join("app"),
            force: false,
        })
        .unwrap_err()
        .to_string();
        assert!(error.contains("--force"));
    }

    #[test]
    fn scaffold_is_runtime_only_with_an_explicit_release_profile() {
        let manifest = desktop_cargo_toml().unwrap();
        assert!(manifest.contains("default = []"));
        assert!(manifest
            .contains("source = [\"webui-desktop/source\", \"dep:serde\", \"dep:serde_json\"]"));
        assert!(manifest.contains("default-features = false, features = [\"native\"]"));
        assert!(manifest.contains("[workspace]"));
        assert!(manifest.contains("[profile.release]"));
        assert!(manifest.contains("lto = \"thin\""));
        assert!(manifest.contains("codegen-units = 1"));
        assert!(manifest.contains("panic = \"abort\""));
        assert!(manifest.contains("strip = true"));
        assert!(!manifest.contains("microsoft-webui-desktop-runner"));
        assert!(!manifest.contains("package = \"microsoft-webui\""));
        assert!(!manifest.contains("anyhow"));
        assert!(DESKTOP_MAIN.contains("#[cfg(not(feature = \"source\"))]"));
        assert!(DESKTOP_MAIN.contains("DesktopError::PackagedResourcesNotFound"));
        assert!(DESKTOP_MAIN.contains("DesktopApp::from_bundle(resources)?"));
        assert!(DESKTOP_MAIN.contains("run_frame(app.build()?)"));
        assert!(!DESKTOP_MAIN.contains("webui_desktop_runner"));
    }

    fn assert_standalone_metadata(root: &Path) {
        execute(&InitArgs {
            path: root.to_path_buf(),
            force: false,
        })
        .unwrap();
        let manifest = root.join("desktop/Cargo.toml");
        let output = Command::new(env!("CARGO"))
            .args([
                "metadata",
                "--offline",
                "--no-deps",
                "--format-version",
                "1",
            ])
            .arg("--manifest-path")
            .arg(&manifest)
            .current_dir(root)
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "cargo metadata failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
        assert_eq!(
            Path::new(metadata["workspace_root"].as_str().unwrap())
                .canonicalize()
                .unwrap(),
            root.join("desktop").canonicalize().unwrap()
        );
        let package = &metadata["packages"][0];
        let dependencies = package["dependencies"].as_array().unwrap();
        assert_eq!(dependencies.len(), 3);
        let desktop = dependencies
            .iter()
            .find(|dependency| dependency["name"] == "microsoft-webui-desktop")
            .unwrap();
        assert_eq!(desktop["uses_default_features"], false);
        assert_eq!(desktop["features"][0], "native");
        assert!(package["features"]["default"]
            .as_array()
            .unwrap()
            .is_empty());
        assert!(package["features"]["source"]
            .as_array()
            .unwrap()
            .contains(&serde_json::Value::from("webui-desktop/source")));
    }

    #[test]
    fn scaffold_is_standalone_outside_a_workspace() {
        let temp = TempDir::new().unwrap();
        assert_standalone_metadata(&temp.path().join("app"));
    }

    #[test]
    fn scaffold_is_standalone_inside_a_workspace_without_editing_it() {
        let temp = TempDir::new().unwrap();
        let enclosing = temp.path().join("Cargo.toml");
        let original = b"[workspace]\nmembers = []\nresolver = \"2\"\n";
        fs::write(&enclosing, original).unwrap();

        assert_standalone_metadata(&temp.path().join("app"));

        assert_eq!(fs::read(&enclosing).unwrap(), original);
    }
}