microsoft-webui-desktop-build 3.0.0

Build-time typed IPC bindings for the WebUI desktop SDK
Documentation
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

use std::{
    collections::BTreeMap,
    fmt::Write as _,
    fs,
    io::Read as _,
    path::{Path, PathBuf},
    process::{Command, Stdio},
    sync::atomic::{AtomicU64, Ordering},
};

use prost_reflect::DescriptorPool;
use sha2::{Digest, Sha256};

use crate::{
    emit,
    error::{io, schema},
    evolution, model, GenerateConfig, GenerateError, GeneratedFiles,
};

const DESCRIPTOR_LIMIT: u64 = 16 * 1024 * 1024;
static NEXT: AtomicU64 = AtomicU64::new(0);

mod tools;

#[cfg(test)]
#[path = "compiler_tests.rs"]
mod tests;

struct Scratch(PathBuf);
impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

pub(crate) fn generate(config: &GenerateConfig) -> Result<GeneratedFiles, GenerateError> {
    crate::artifacts::validate_paths(config)?;
    if config.roots.is_empty() {
        return Err(schema(
            "ipc-roots",
            "roots",
            "no schema roots supplied",
            "provide at least one application .proto file",
        ));
    }
    let base = config
        .lock_file
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or(Path::new("."));
    if !base.is_dir() {
        return Err(schema(
            "ipc-output",
            base.display().to_string(),
            "lock directory does not exist",
            "create the application schema directory first",
        ));
    }
    let scratch = Scratch(base.join(format!(
        ".webui-ipc-{}-{}",
        std::process::id(),
        NEXT.fetch_add(1, Ordering::Relaxed)
    )));
    fs::create_dir(&scratch.0).map_err(|e| io(&scratch.0, e))?;
    let roots = canonical(&config.roots)?;
    let sdk_include = scratch.0.join("include");
    let sdk_options = sdk_include.join("webui/ipc/options.proto");
    if let Some(parent) = sdk_options.parent() {
        fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
    }
    fs::write(
        &sdk_options,
        include_str!("../proto/webui/ipc/options.proto"),
    )
    .map_err(|e| io(&sdk_options, e))?;
    let mut includes = vec![fs::canonicalize(&sdk_include).map_err(|e| io(&sdk_include, e))?];
    includes.extend(canonical(&config.includes)?);
    for root in &roots {
        if fs::metadata(root).map_err(|e| io(root, e))?.len() > DESCRIPTOR_LIMIT {
            return Err(schema(
                "ipc-schema-limit",
                root.display().to_string(),
                "root schema exceeds 16 MiB",
                "split the application schema",
            ));
        }
        if !includes.iter().any(|dir| root.starts_with(dir)) {
            if let Some(parent) = root.parent() {
                includes.push(parent.into());
            }
        }
    }
    let descriptor = scratch.0.join("descriptor.bin");
    let protoc = config
        .protoc
        .clone()
        .or_else(|| std::env::var_os("PROTOC").map(PathBuf::from))
        .unwrap_or_else(|| PathBuf::from("protoc"));
    let protoc = tools::program(&protoc)?;
    let mut cmd = proto_command(&protoc, &includes)?;
    cmd.arg("--include_imports").arg("--include_source_info");
    tools::path_option(&mut cmd, "--descriptor_set_out=", &descriptor)?;
    for root in &roots {
        cmd.arg(tools::protoc_path(root)?);
    }
    run(&mut cmd, "protoc", "install protoc and set GenerateConfig.protoc; check the schema file/line in the compiler diagnostic")?;
    let bytes = bounded_read(&descriptor)?;
    let pool = DescriptorPool::decode(bytes.as_slice()).map_err(|e| {
        schema(
            "ipc-descriptor",
            "protoc output",
            e.to_string(),
            "compile the schema with a supported protoc",
        )
    })?;
    for file in pool.files() {
        if file.name().starts_with('-') {
            return Err(schema(
                "ipc-input-path",
                file.name(),
                "protobuf file name would be interpreted as a compiler option",
                "rename protobuf files so their import paths do not start with a dash",
            ));
        }
    }
    let contract = model::build(&pool)?;
    let normalized = normalized(&contract)?;
    let mut hash = String::with_capacity(64);
    for byte in Sha256::digest(&normalized) {
        write!(hash, "{byte:02x}").ok();
    }
    let old = match bounded_read(&config.lock_file) {
        Ok(bytes) => Some(bytes),
        Err(GenerateError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
            None
        }
        Err(e) => return Err(e),
    };
    let lock = evolution::update(&contract, &hash, old.as_deref())?;
    let mut artifacts = BTreeMap::new();
    artifacts.extend(emit::rust_payload(&contract, &config.rust_out));
    artifacts.extend(emit::typescript_payload(&contract, &config.ts_out));
    let rust_wrapper = config.rust_out.join("ipc.rs");
    let ts_wrapper = config.ts_out.join("ipc.ts");
    let ts_runtime = config.ts_out.join("ipc-runtime.ts");
    if [&rust_wrapper, &ts_wrapper, &ts_runtime]
        .iter()
        .any(|path| artifacts.contains_key(*path))
    {
        return Err(schema(
            "ipc-output-collision",
            "ipc",
            "schema codec collides with the generated IPC wrapper",
            "rename the ipc.proto or ipc-runtime.proto file, or ipc package",
        ));
    }
    artifacts.insert(rust_wrapper, emit::rust(&contract, &hash)?.into_bytes());
    let (facade, runtime) = emit::typescript(&contract, &hash)?;
    artifacts.insert(ts_wrapper, facade.into_bytes());
    artifacts.insert(ts_runtime, runtime.into_bytes());
    let manifest = config.lock_file.with_file_name("ipc-schema.json");
    if config.lock_file == manifest
        || artifacts.contains_key(&manifest)
        || artifacts.contains_key(&config.lock_file)
    {
        return Err(schema(
            "ipc-output-collision",
            config.lock_file.display().to_string(),
            "schema metadata overlaps another generated artifact",
            "choose a distinct compatibility lock path",
        ));
    }
    artifacts.insert(manifest.clone(), normalized);
    artifacts.insert(config.lock_file.clone(), json(&lock)?);
    let result = GeneratedFiles {
        rust: artifacts
            .keys()
            .filter(|p| p.starts_with(&config.rust_out))
            .cloned()
            .collect(),
        typescript: artifacts
            .keys()
            .filter(|p| p.starts_with(&config.ts_out))
            .cloned()
            .collect(),
        manifest,
        inventory: config.lock_file.with_file_name("ipc-generated-files.json"),
        lock_file: config.lock_file.clone(),
        schema_hash: hash,
    };
    crate::artifacts::persist(config, artifacts)?;
    Ok(result)
}

fn canonical(paths: &[PathBuf]) -> Result<Vec<PathBuf>, GenerateError> {
    paths
        .iter()
        .map(|p| fs::canonicalize(p).map_err(|e| io(p, e)))
        .collect()
}

fn proto_command(protoc: &Path, includes: &[PathBuf]) -> Result<Command, GenerateError> {
    let mut cmd = Command::new(protoc);
    for include in includes {
        cmd.arg("-I").arg(tools::protoc_path(include)?);
    }
    Ok(cmd)
}

fn run(cmd: &mut Command, tool: &str, help: &str) -> Result<(), GenerateError> {
    let failure = |message: String| GenerateError::Tool {
        tool: tool.into(),
        message,
        help: help.into(),
    };
    let mut child = cmd
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| failure(e.to_string()))?;
    let mut diagnostic = Vec::with_capacity(4096);
    if let Some(stderr) = child.stderr.take() {
        if let Err(error) = stderr.take(16_385).read_to_end(&mut diagnostic) {
            let _ = child.kill();
            let _ = child.wait();
            return Err(failure(error.to_string()));
        }
    }
    if diagnostic.len() > 16_384 {
        let _ = child.kill();
        let _ = child.wait();
        return Err(failure(
            "compiler diagnostic exceeded 16 KiB; fix the first schema errors and retry".into(),
        ));
    }
    if !child.wait().map_err(|e| failure(e.to_string()))?.success() {
        return Err(failure(String::from_utf8_lossy(&diagnostic).into_owned()));
    }
    Ok(())
}

fn bounded_read(path: &Path) -> Result<Vec<u8>, GenerateError> {
    if fs::metadata(path).map_err(|e| io(path, e))?.len() > DESCRIPTOR_LIMIT {
        return Err(schema(
            "ipc-schema-limit",
            path.display().to_string(),
            "descriptor exceeds 16 MiB",
            "split the application schema",
        ));
    }
    fs::read(path).map_err(|e| io(path, e))
}

fn json(value: &impl serde::Serialize) -> Result<Vec<u8>, GenerateError> {
    let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| {
        schema(
            "ipc-manifest",
            "generated contract",
            e.to_string(),
            "report this generator error",
        )
    })?;
    bytes.push(b'\n');
    Ok(bytes)
}

fn normalized(contract: &model::Contract) -> Result<Vec<u8>, GenerateError> {
    let mut value = serde_json::to_value(contract).map_err(|e| {
        schema(
            "ipc-manifest",
            &contract.name,
            e.to_string(),
            "report this generator error",
        )
    })?;
    if let Some(messages) = value
        .get_mut("messages")
        .and_then(serde_json::Value::as_array_mut)
    {
        for message in messages {
            if let Some(object) = message.as_object_mut() {
                object.remove("file");
                object.remove("rust");
                object.remove("ts");
            }
        }
    }
    json(&value)
}