diff --git a/Cargo.toml b/Cargo.toml index c39f682..7fca1a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,15 @@ serde = { version = "1.0", features = ["derive"] } test-components = { path = "./test-components" } [features] -default = ["brp", "presets"] +default = ["brp", "presets", "safelist"] # Serve the Bevy schema via BRP brp = ["dep:bevy_remote"] +# Enable the use of Default and user preset values when inserting Components in Blender presets = ["brp"] +# Pre-configure a set of allowable crates and types to limit what can be used in Blender +safelist = ["brp"] +# Write a skein.manifest.json to disk and exit the Bevy application +write_manifest_and_exit = [] # Idiomatic Bevy code often triggers these lints, and the CI workflow treats them as errors. # In some cases they may still signal poor code quality however, so consider commenting out these lines. diff --git a/src/lib.rs b/src/lib.rs index 99081c9..9d2eb35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ #![doc = include_str!("../README.md")] -use bevy_app::{App, Plugin}; +use bevy_app::{App, Plugin, Startup}; use bevy_ecs::{ name::Name, observer::Trigger, @@ -15,7 +15,7 @@ use bevy_gltf::{ use bevy_log::{error, trace}; use bevy_platform::collections::HashMap; use bevy_reflect::{Reflect, serde::ReflectDeserializer}; -use serde::de::DeserializeSeed; +use serde::{Deserialize, Serialize, de::DeserializeSeed}; use serde_json::Value; use tracing::{instrument, warn}; @@ -29,6 +29,18 @@ use tracing::{instrument, warn}; #[cfg(feature = "presets")] pub mod presets; +/// Safelists are used for providing a well-defined +/// Component API to artists using Blender. By +/// enabling this feature, you can limit the available +/// components Blender users will see. +/// +/// Doing this in combination with putting all Blender +/// Components in one (or a few) crates, allows users +/// to have a high degree of control over the Blender +/// user experience. +#[cfg(feature = "safelist")] +pub mod safelist; + /// [`SkeinPlugin`] is the main plugin. /// /// This will add Scene postprocessing which will @@ -43,11 +55,35 @@ pub struct SkeinPlugin { /// up BRP yourself #[allow(dead_code)] pub handle_brp: bool, + /// When the `write_manifest_and_exit` feature is enabled, + /// This value controls whether the currently running + /// application should actually write the skein + /// manifest file and exit. + /// + /// By default this value is true. Which means when the + /// `write_manifest_and_exit` feature is enabled, the program + /// will write the file and exit by default. + /// + /// If you want to be able to write the manifest from a + /// previously compiled build, such as when distributing a + /// test game binary, set this value to `false` and choose + /// your own adventure for how to configure it when you want + /// to. (one potential option is to do your own CLI argument + /// parsing) + pub write_manifest_and_exit: bool, } impl Default for SkeinPlugin { fn default() -> Self { - Self { handle_brp: true } + Self { + handle_brp: true, + // default is true, because the feature is off by default, + // so turning the feature on should cause this to execute. + // + // use false if you want to be able to toggle this in a dev + // build that is being distributed as a binary. + write_manifest_and_exit: true, + } } } @@ -56,6 +92,9 @@ impl Plugin for SkeinPlugin { app.init_resource::() .add_observer(skein_processing); + #[cfg(feature = "write_manifest_and_exit")] + app.add_systems(Startup, write_manifest_and_exit); + #[cfg(all( not(target_family = "wasm"), feature = "brp" @@ -73,6 +112,11 @@ impl Plugin for SkeinPlugin { ); } + remote_plugin = remote_plugin.with_method( + safelist::BRP_REGISTRY_SCHEMA_METHOD, + safelist::export_registry_types, + ); + app.add_plugins(( remote_plugin, bevy_remote::http::RemoteHttpPlugin::default(), @@ -275,3 +319,71 @@ fn skein_processing( } } } + +/// The format written to disk when using an "offline" registry +#[derive(Serialize, Deserialize)] +struct SkeinManifest { + /// manifest version. Only bumped if there's a breaking change in the data that we need to communicate to blender + version: usize, + /// the safelisted crates whose components will be shown in Blender; empty Vec is no filter. + crate_safelist: Vec, + /// what version of bevy_skein was used to create this data? + created_using_bevy_skein_version: &'static str, + /// available presets/default values for components + presets: Option, + /// type_reflection data + registry: serde_json::Value, +} + +impl Default for SkeinManifest { + fn default() -> Self { + Self { + version: 1, + crate_safelist: Default::default(), + created_using_bevy_skein_version: env!( + "CARGO_PKG_VERSION" + ), + presets: Default::default(), + registry: Default::default(), + } + } +} + +#[cfg(feature = "write_manifest_and_exit")] +fn write_manifest_and_exit( + mut world: &mut bevy_ecs::world::World, +) -> bevy_ecs::error::Result { + use crate::safelist::export_registry_types; + use bevy_app::AppExit; + use bevy_ecs::{ + event::EventWriter, system::SystemState, + }; + let types_schema = world + .run_system_cached_with( + export_registry_types, + None, + )? + .map_err(|brp_error| format!("{:?}", brp_error))?; + let registry_save_path = std::path::Path::new( + "skein.manifest.registry.json", + ); + + let manifest = SkeinManifest { + crate_safelist: vec![], + presets: None, + registry: types_schema, + ..Default::default() + }; + + let writer = std::fs::File::create(registry_save_path)?; + serde_json::to_writer_pretty(writer, &manifest)?; + + let mut system_state: SystemState< + EventWriter, + > = SystemState::new(&mut world); + + let mut exit_event = system_state.get_mut(&mut world); + + exit_event.write(AppExit::Success); + Ok(()) +} diff --git a/src/safelist.rs b/src/safelist.rs new file mode 100644 index 0000000..09e24a6 --- /dev/null +++ b/src/safelist.rs @@ -0,0 +1,195 @@ +use bevy_ecs::{ + reflect::AppTypeRegistry, system::In, world::World, +}; +use bevy_platform::collections::{HashMap, HashSet}; +use bevy_reflect::{TypeInfo, VariantInfo}; +use bevy_remote::{ + BrpError, BrpResult, error_codes, + schemas::json_schema::JsonSchemaBevyType, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The method path for a `bevy/registry/schema` request. +pub const BRP_REGISTRY_SCHEMA_METHOD: &str = + "skein/registry/schema"; + +/// Constraints that can be placed on a query to include or exclude +/// certain definitions. +#[derive( + Debug, Serialize, Deserialize, Clone, Default, PartialEq, +)] +pub struct BrpJsonSchemaQueryFilter { + /// The crate name of the type name of each component that must not be + /// present on the entity for it to be included in the results. + #[serde( + skip_serializing_if = "Vec::is_empty", + default + )] + pub safelist_types: Vec, + + /// The crate name of the type name of each component that must be present + /// on the entity for it to be included in the results. + #[serde( + skip_serializing_if = "Vec::is_empty", + default + )] + pub safelist_crates: Vec, +} + +/// Handles a `bevy/registry/schema` request (list all registry types in form of schema) coming from a client. +pub fn export_registry_types( + In(params): In>, + world: &World, +) -> BrpResult { + let filter: BrpJsonSchemaQueryFilter = match params { + None => Default::default(), + Some(params) => parse(params)?, + }; + + let types = world.resource::(); + let types = types.read(); + let mut deps = HashSet::new(); + let schemas = types + .iter() + .map(|type_registration| (bevy_remote::schemas::json_schema::export_type(&type_registration), type_registration.type_info())) + .filter(|((_, schema), _)| { + if !filter.safelist_crates.is_empty() { + return schema + .crate_name + .as_ref() + .is_some_and(|crate_name| { + filter + .safelist_crates + .contains(&crate_name) + }); + } else { + return true; + } + }) + .inspect(|((_, _), type_info)| { + for dep in get_type_dependencies(&type_info) { + deps.insert(dep); + } + }) + .map(|(v,_)| v) + .collect::>(); + + dbg!(deps); + dbg!(schemas.get("test_components::TeamMember")); + + serde_json::to_value(schemas) + .map_err(BrpError::internal) +} + +/// A helper function used to parse a `serde_json::Value`. +fn parse Deserialize<'de>>( + value: Value, +) -> Result { + serde_json::from_value(value).map_err(|err| BrpError { + code: error_codes::INVALID_PARAMS, + message: err.to_string(), + data: None, + }) +} + +fn get_type_dependencies( + type_info: &TypeInfo, +) -> Vec<&str> { + match type_info { + TypeInfo::Struct(info) => { + return info + .iter() + .flat_map(|field| match field.type_info() { + Some(next_type_info) => { + get_type_dependencies( + &next_type_info, + ) + } + None => vec![], + }) + .collect::>(); + } + + TypeInfo::Enum(info) => { + let simple = info.iter().all(|variant| { + matches!(variant, VariantInfo::Unit(_)) + }); + if simple { + return vec![info.type_path()]; + } else { + return info + .iter() + .flat_map(|variant| match variant { + VariantInfo::Struct(v) => { + return v + .iter() + .flat_map(|field| match field.type_info() { + Some(next_type_info) => { + get_type_dependencies( + &next_type_info, + ) + } + None => vec![], + }) + .collect::>(); + } + VariantInfo::Tuple(v) => return v + .iter() + .flat_map(|field| { + match field.type_info() { + Some(next_type_info) => { + get_type_dependencies( + &next_type_info, + ) + } + None => vec![], + } + }) + .collect::>(), + VariantInfo::Unit(_) => vec![], + }) + .collect::>(); + } + } + TypeInfo::TupleStruct(info) => { + return info + .iter() + .flat_map(|field| match field.type_info() { + Some(next_type_info) => { + get_type_dependencies( + &next_type_info, + ) + } + None => vec![], + }) + .collect::>(); + } + TypeInfo::List(info) => match info.item_info() { + Some(type_info) => vec![type_info.type_path()], + None => vec![], + }, + TypeInfo::Array(info) => match info.item_info() { + Some(type_info) => vec![type_info.type_path()], + None => vec![], + }, + TypeInfo::Map(_info) => { + // info.key_type + // info.value_type + return vec![]; + } + TypeInfo::Tuple(info) => info + .iter() + .flat_map(|field| match field.type_info() { + Some(next_type_info) => { + get_type_dependencies(&next_type_info) + } + None => vec![], + }) + .collect::>(), + TypeInfo::Set(info) => vec![info.value_ty().path()], + TypeInfo::Opaque(info) => { + vec![info.type_path()] + } + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 97f7f6a..8b00dd5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -9,7 +9,10 @@ fn test_skein_options() { App::new() .add_plugins(( MinimalPlugins, - SkeinPlugin { handle_brp: false }, + SkeinPlugin { + handle_brp: false, + ..default() + }, )) // immediately exit .add_systems(