microsoft-webui-desktop 3.0.0

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

use std::path::PathBuf;

use thiserror::Error;

/// Result type used by the WebUI desktop runtime.
pub type Result<T> = std::result::Result<T, DesktopError>;

/// Failure to establish an app-owned persistent browser-data boundary.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum WebsiteDataError {
    /// Persistence requires an explicit identity.
    #[error("persistent website data requires an app_id; help: set LocalServerAppBuilder::app_id to the packaged application's bundle identifier")]
    MissingIdentity,
    /// The identity cannot safely name app-owned storage.
    #[error("invalid app_id for persistent website data; help: use 1–255 ASCII letters, digits, dots, underscores or hyphens, with no trailing dot")]
    InvalidIdentity,
    /// The process is not running as the executable of a real macOS .app bundle.
    #[error("persistent website data requires a packaged macOS .app executable; help: launch the app from its packaged Contents/MacOS executable")]
    Unpackaged,
    /// The requested identity does not own the running application bundle.
    #[error("app_id does not match the running macOS bundle identifier; help: use the exact CFBundleIdentifier of the packaged .app")]
    IdentityMismatch,
    /// No safe native-owned persistent store is available on this platform.
    #[error("persistent website data is unsupported on this platform; help: use a packaged macOS app or a Windows app-owned WebView2 profile")]
    Unsupported,
}

/// Errors produced by the runtime-neutral WebUI desktop layer.
#[derive(Debug, Error)]
pub enum DesktopError {
    /// An explicit native website-data persistence request failed.
    #[error(transparent)]
    WebsiteData(#[from] WebsiteDataError),
    /// Application IPC configuration or delivery failed.
    #[cfg(feature = "application-ipc")]
    #[error("desktop application IPC failed")]
    Ipc(#[from] crate::ipc::IpcError),

    /// No desktop bundle could be found beside the running executable.
    #[error("packaged desktop resources were not found")]
    PackagedResourcesNotFound,

    /// A native or custom desktop backend could not complete an operation.
    #[error("desktop backend failed")]
    Backend {
        /// Underlying backend failure.
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },

    /// I/O failure while reading or writing desktop runtime data.
    #[error("I/O error while {context}")]
    Io {
        /// Operation that failed.
        context: String,
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// WebUI source build failed.
    #[cfg(feature = "source")]
    #[error("desktop WebUI build failed")]
    Build(#[from] webui::WebUIError),

    /// Handler rendering failed.
    #[error("desktop render failed")]
    Render(#[from] webui_handler::HandlerError),

    /// Protocol serialization or deserialization failed.
    #[error("desktop protocol error")]
    Protocol(#[from] webui_protocol::ProtocolError),

    /// State JSON could not be parsed.
    #[error("failed to parse desktop state JSON from {}", path.display())]
    StateJson {
        /// State file path.
        path: PathBuf,
        /// Underlying JSON parser error.
        #[source]
        source: serde_json::Error,
    },

    /// JSON serialization failed.
    #[error("failed while {context}")]
    Serialization {
        /// Operation that failed.
        context: String,
        /// Underlying JSON error.
        #[source]
        source: serde_json::Error,
    },

    /// Design token loading or resolution failed.
    #[error("failed while {context}")]
    Token {
        /// Operation that failed.
        context: String,
        /// Underlying token error.
        #[source]
        source: webui_tokens::TokenError,
    },

    /// A custom-protocol asset path was unsafe.
    #[error("invalid desktop asset path: {path}")]
    InvalidAssetPath {
        /// Rejected request path.
        path: String,
    },

    /// A Rust route provider pattern is invalid.
    #[error("invalid desktop route pattern: {pattern}")]
    InvalidRoutePattern {
        /// Rejected route pattern.
        pattern: String,
        /// Actionable fix.
        help: String,
    },

    /// A Rust route provider failed.
    #[error("desktop route provider failed for {path}: {message}")]
    RouteProvider {
        /// Request path.
        path: String,
        /// Provider error message.
        message: String,
    },

    /// An asset exceeds the configured in-memory response cap.
    #[error(
        "desktop asset is too large: {} ({} bytes, max {} bytes)",
        path.display(),
        size,
        max_bytes
    )]
    AssetTooLarge {
        /// Asset path.
        path: PathBuf,
        /// Actual size in bytes.
        size: u64,
        /// Configured maximum size.
        max_bytes: u64,
    },

    /// Two bundle inputs resolve to the same output asset path.
    #[error("desktop bundle asset collision: {path}")]
    BundleAssetCollision {
        /// Colliding bundle-relative path.
        path: String,
    },

    /// A bundle or package output path overlaps an input path.
    #[error(
        "desktop output path {} overlaps {input_label} path {}",
        output.display(),
        input.display()
    )]
    OutputPathOverlap {
        /// Output path that would be cleaned or written.
        output: PathBuf,
        /// Input path that must be preserved.
        input: PathBuf,
        /// Human-readable input label.
        input_label: &'static str,
    },

    /// An already-compiled host package input is invalid or unsafe.
    #[error("invalid precompiled desktop package: {message}; help: {help}")]
    PackageValidation {
        /// Specific rejected input.
        message: String,
        /// Actionable correction.
        help: &'static str,
    },

    /// Bundle manifest serialization failed.
    #[error("failed to serialize desktop bundle manifest")]
    ManifestSerialization(#[source] serde_json::Error),

    /// Bundle manifest deserialization failed.
    #[error("failed to parse desktop bundle manifest at {}", path.display())]
    ManifestDeserialization {
        /// Manifest path.
        path: PathBuf,
        /// Underlying parser error.
        #[source]
        source: serde_json::Error,
    },

    /// Platform runtime does not support a required desktop capability.
    #[error("unsupported desktop runtime: {message}")]
    UnsupportedRuntime {
        /// What capability is missing.
        message: String,
        /// Actionable fix.
        help: String,
    },
}

impl DesktopError {
    /// Return an actionable hint for errors that have a clear next step.
    #[must_use]
    pub fn hint(&self) -> Option<&str> {
        match self {
            DesktopError::PackagedResourcesNotFound => Some(
                "Run the app from its packaged bundle, or enable the source feature for development with cargo run --features source",
            ),
            DesktopError::InvalidAssetPath { .. } => {
                Some("Use a relative asset path without '.', '..', backslashes, NUL bytes, or drive prefixes")
            }
            DesktopError::AssetTooLarge { .. } => {
                Some("Reduce the asset size or raise the desktop max asset size for a trusted app")
            }
            DesktopError::InvalidRoutePattern { help, .. } => Some(help.as_str()),
            DesktopError::RouteProvider { .. } => {
                Some("Fix the Rust route provider or return a valid route-scoped state object")
            }
            DesktopError::BundleAssetCollision { .. } => {
                Some("Rename the static asset or generated CSS file so each bundle path is unique")
            }
            DesktopError::OutputPathOverlap { .. } => {
                Some("Choose an output directory outside the app, bundle, state, asset, and runner paths")
            }
            DesktopError::PackageValidation { help, .. } => Some(help),
            DesktopError::UnsupportedRuntime { help, .. } => Some(help.as_str()),
            _ => None,
        }
    }

    /// Return a flattened source-chain message for hosts that do not walk
    /// [`std::error::Error::source`].
    #[must_use]
    pub fn chain_message(&self) -> String {
        let mut message = self.to_string();
        let mut source = std::error::Error::source(self);
        while let Some(err) = source {
            message.push_str(": ");
            message.push_str(&err.to_string());
            source = err.source();
        }
        message
    }
}

#[cfg(all(test, feature = "application-ipc"))]
mod tests {
    use super::DesktopError;
    use crate::ipc::{IpcError, IpcErrorCode};

    #[test]
    fn ipc_errors_preserve_their_typed_source_and_actionable_chain() {
        let error = DesktopError::from(IpcError::new(
            IpcErrorCode::PayloadTooLarge,
            "frame exceeds the configured limit",
            "send a smaller message",
        ));
        assert!(matches!(
            &error,
            DesktopError::Ipc(source) if source.code == IpcErrorCode::PayloadTooLarge
        ));
        assert!(std::error::Error::source(&error).is_some());
        assert_eq!(
            error.chain_message(),
            "desktop application IPC failed: frame exceeds the configured limit; help: send a smaller message"
        );
    }
}