diff --git a/.sqlx/query-c581524ece3057e4f6853f385c84511dbba186ffc73b3d50ae063dbb7e23ec09.json b/.sqlx/query-c581524ece3057e4f6853f385c84511dbba186ffc73b3d50ae063dbb7e23ec09.json new file mode 100644 index 0000000..8a4b134 --- /dev/null +++ b/.sqlx/query-c581524ece3057e4f6853f385c84511dbba186ffc73b3d50ae063dbb7e23ec09.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM animation_plugins WHERE id = $1 RETURNING path", + "describe": { + "columns": [ + { + "name": "path", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "c581524ece3057e4f6853f385c84511dbba186ffc73b3d50ae063dbb7e23ec09" +} diff --git a/Cargo.lock b/Cargo.lock index 8898314..0099ea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,44 @@ dependencies = [ "syn 2.0.89", ] +[[package]] +name = "actix-multipart" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.8.5", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.89", +] + [[package]] name = "actix-router" version = "0.5.3" @@ -3465,6 +3503,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.1.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.63", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-render" version = "0.1.1" @@ -5531,6 +5590,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "paste" version = "1.0.15" @@ -6910,6 +6975,7 @@ version = "0.1.0" dependencies = [ "actix", "actix-cors", + "actix-multipart", "actix-web", "actix-web-actors", "animation-wrapper", @@ -6935,10 +7001,12 @@ dependencies = [ name = "rustmas-webapi-client" version = "0.1.0" dependencies = [ + "gloo-net 0.6.0", "reqwest", "serde", "thiserror 1.0.63", "url", + "web-sys", "webapi-model", ] diff --git a/animation-template-native/manifest.json b/animation-template-native/manifest.json index c6d6921..2ac6ebd 100644 --- a/animation-template-native/manifest.json +++ b/animation-template-native/manifest.json @@ -1,4 +1,13 @@ { + "id": "io.rustmas.template.native", "display_name": "Animation Template (Native)", - "plugin_type": "native" + "plugin_type": "native", + "author": "Mariusz Różycki ", + "api_version": "0.9", + "version": "1.0", + "tags": [ + "2d", + "3d", + "audio" + ] } \ No newline at end of file diff --git a/animation-template-wasm/manifest.json b/animation-template-wasm/manifest.json index 84f3bec..3cae5b7 100644 --- a/animation-template-wasm/manifest.json +++ b/animation-template-wasm/manifest.json @@ -1,4 +1,13 @@ { + "id": "io.rustmas.template.wasm", "display_name": "Animation Template (Wasm)", - "plugin_type": "wasm" + "plugin_type": "wasm", + "author": "Mariusz Różycki ", + "api_version": "0.9", + "version": "1.0", + "tags": [ + "2d", + "3d", + "audio" + ] } \ No newline at end of file diff --git a/animation-wrapper/src/config.rs b/animation-wrapper/src/config.rs index 2e5aee6..0c749c2 100644 --- a/animation-wrapper/src/config.rs +++ b/animation-wrapper/src/config.rs @@ -27,11 +27,22 @@ pub enum PluginType { Wasm, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum PluginApiVersion { + #[serde(rename = "0.9")] + V0_9, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginManifest { + pub id: String, pub display_name: String, + pub author: String, #[serde(default)] pub plugin_type: PluginType, + pub api_version: PluginApiVersion, + pub version: String, + pub tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/animation-wrapper/src/unwrap.rs b/animation-wrapper/src/unwrap.rs index b3ddc4f..febefc5 100644 --- a/animation-wrapper/src/unwrap.rs +++ b/animation-wrapper/src/unwrap.rs @@ -6,7 +6,7 @@ use std::{ use tar::Archive; -use crate::config::{PluginConfig, PluginManifest}; +use crate::config::PluginManifest; #[derive(Debug, thiserror::Error)] pub enum PluginUnwrapError { @@ -43,28 +43,8 @@ fn manifest_from_crab(path: &Path) -> Result Ok(serde_json::from_reader(entry_reader)?) } -fn animation_id_from_crab(path: &Path) -> Result { - Ok(path - .file_name() - .ok_or(PluginUnwrapError::InvalidFilename)? - .to_string_lossy() - .trim_end_matches(".crab") - .to_string()) -} - -pub fn unwrap_plugin>(path: &P) -> Result { - fn inner(path: &Path) -> Result { - let animation_id = animation_id_from_crab(path)?; - let manifest = manifest_from_crab(path)?; - let path = path.to_owned(); - - Ok(PluginConfig { - animation_id, - manifest, - path, - }) - } - inner(path.as_ref()) +pub fn unwrap_plugin>(path: P) -> Result { + manifest_from_crab(path.as_ref()) } pub fn reader_from_crab>(path: &P) -> Result { diff --git a/animator/src/factory.rs b/animator/src/factory.rs index 5213001..23f5a20 100644 --- a/animator/src/factory.rs +++ b/animator/src/factory.rs @@ -108,8 +108,17 @@ impl AnimationFactory { })? .filter_map(|d| d.ok()) .filter(|d| d.file_name().to_str().is_some_and(|d| d.ends_with(".crab"))) - .filter_map(|d| unwrap::unwrap_plugin(&d.path()).ok()) - .map(|p| (p.animation_id.clone(), p)); + .filter_map(|d| Some(d.path().to_owned()).zip(unwrap::unwrap_plugin(d.path()).ok())) + .map(|(path, manifest)| { + ( + manifest.id.clone(), + PluginConfig { + animation_id: manifest.id.clone(), + manifest, + path, + }, + ) + }); valid_plugins.extend(crab_plugins); @@ -125,9 +134,14 @@ impl AnimationFactory { .and_then(|f| f.to_str()) .is_some_and(|f| f.ends_with(".crab")) { - unwrap::unwrap_plugin(&path).map_err(|e| { + let manifest = unwrap::unwrap_plugin(path).map_err(|e| { AnimationFactoryError::InvalidPlugin(PluginConfigError::InvalidCrab(e)) - })? + })?; + PluginConfig { + animation_id: manifest.id.clone(), + manifest, + path: path.to_owned(), + } } else { PluginConfig::new(path).map_err(AnimationFactoryError::InvalidPlugin)? }; @@ -142,6 +156,20 @@ impl AnimationFactory { } } + pub async fn install(&self, path: &Path) -> Result { + let manifest = unwrap::unwrap_plugin(path) + .map_err(|e| AnimationFactoryError::InvalidPlugin(PluginConfigError::InvalidCrab(e)))?; + + let new_path = self.plugin_dir.join(format!("{}.crab", manifest.id)); + tokio::fs::rename(path, &new_path).await?; + + Ok(PluginConfig { + animation_id: manifest.id.clone(), + manifest, + path: new_path, + }) + } + pub fn points(&self) -> &[(f64, f64, f64)] { &self.points } diff --git a/webapi-client/Cargo.toml b/webapi-client/Cargo.toml index 5ae37fa..62a8cc0 100644 --- a/webapi-client/Cargo.toml +++ b/webapi-client/Cargo.toml @@ -11,6 +11,10 @@ serde = "1.0.193" thiserror = "1.0.60" url = "2.5.0" +web-sys = { version = "0.3.60", features = ["FormData"], optional = true } +gloo-net = { version = "0.6.0", optional = true } + [features] default = [] visualizer = [] +js = ["dep:web-sys", "dep:gloo-net"] diff --git a/webapi-client/src/lib.rs b/webapi-client/src/lib.rs index dfb1a78..89cc4cf 100644 --- a/webapi-client/src/lib.rs +++ b/webapi-client/src/lib.rs @@ -1,14 +1,16 @@ use std::collections::HashMap; +use gloo_net::http::Request; use serde::{Serialize, de::DeserializeOwned}; use url::Url; +use web_sys::FormData; pub use webapi_model::{ Animation, Configuration, GetEventGeneratorSchemaResponse, GetParametersResponse, GetPointsResponse, ListAnimationsResponse, ParameterValue, SwitchAnimationRequest, }; use webapi_model::{ - ApiResponse, Event, SendEventRequest, SetAnimationParametersRequest, + ApiResponse, Event, RemoveAnimationRequest, SendEventRequest, SetAnimationParametersRequest, SetEventGeneratorParametersRequest, SwitchAnimationResponse, }; @@ -137,6 +139,36 @@ impl RustmasApiClient { .animation) } + pub async fn remove_animation(&self, animation_id: String) -> Result> { + Ok(self + .post::( + "animations/remove/", + &RemoveAnimationRequest { animation_id }, + ) + .await? + .animations) + } + + #[cfg(feature = "js")] + pub async fn install_animation(&self, form_data: FormData) -> Result> { + Ok(Request::post(&self.url("animations/install/")) + .body(form_data) + .map_err(|e| GatewayError::InvalidRequest { + reason: e.to_string(), + })? + .send() + .await + .map_err(|e| GatewayError::ApiError { + reason: e.to_string(), + })? + .json::() + .await + .map_err(|e| GatewayError::InvalidResponse { + reason: e.to_string(), + })? + .animations) + } + pub async fn turn_off(&self) -> Result<()> { self.post("animations/turn_off/", &()).await } diff --git a/webapi-model/src/lib.rs b/webapi-model/src/lib.rs index 041a661..8dab6e2 100644 --- a/webapi-model/src/lib.rs +++ b/webapi-model/src/lib.rs @@ -34,6 +34,11 @@ pub struct SwitchAnimationRequest { pub params: Option>, } +#[derive(Serialize, Deserialize)] +pub struct RemoveAnimationRequest { + pub animation_id: String, +} + #[derive(Serialize, Deserialize)] pub struct SwitchAnimationResponse { pub animation: Configuration, diff --git a/webapi/Cargo.toml b/webapi/Cargo.toml index e355865..7ffae9d 100644 --- a/webapi/Cargo.toml +++ b/webapi/Cargo.toml @@ -13,6 +13,7 @@ actix-web-actors = "4.2.0" actix = "0.13.0" actix-cors = "0.6.4" actix-web = "4" +actix-multipart = "0.7.2" async-stream = "0.3.5" config = "0.14.1" futures-core = "0.3.28" diff --git a/webapi/src/animations/logic.rs b/webapi/src/animations/logic.rs index e072f2f..853467e 100644 --- a/webapi/src/animations/logic.rs +++ b/webapi/src/animations/logic.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use log::warn; use rustmas_animator::{AnimationFactory, AnimationFactoryError}; @@ -103,7 +104,7 @@ impl Logic { pub async fn discover( &self, - controller: &mut rustmas_animator::Controller, + controller: &rustmas_animator::Controller, ) -> Result { let animations = self .animation_factory @@ -122,6 +123,37 @@ impl Logic { self.list(controller).await } + pub async fn install( + &self, + controller: &rustmas_animator::Controller, + path: &Path, + ) -> Result { + let plugin_config = self.animation_factory.install(path).await?; + self.storage + .install(&plugin_config) + .await + .map_err(|e| LogicError::InternalError(e.to_string()))?; + self.list(controller).await + } + + pub async fn remove( + &self, + controller: &rustmas_animator::Controller, + animation_id: &str, + ) -> Result { + let path = self + .storage + .delete(animation_id) + .await + .map_err(|e| LogicError::InternalError(e.to_string()))?; + + tokio::fs::remove_file(path) + .await + .map_err(|e| LogicError::InternalError(e.to_string()))?; + + self.list(controller).await + } + pub async fn list( &self, controller: &rustmas_animator::Controller, diff --git a/webapi/src/animations/service.rs b/webapi/src/animations/service.rs index 6210972..6e85031 100644 --- a/webapi/src/animations/service.rs +++ b/webapi/src/animations/service.rs @@ -1,7 +1,8 @@ +use actix_multipart::form::{MultipartForm, tempfile::TempFile}; use actix_web::{HttpResponse, Scope, get, post, web}; use log::error; use serde_json::json; -use webapi_model::{SwitchAnimationRequest, SwitchAnimationResponse}; +use webapi_model::{RemoveAnimationRequest, SwitchAnimationRequest, SwitchAnimationResponse}; use crate::{AnimationController, animations, parameters}; @@ -77,8 +78,8 @@ async fn discover( animations: web::Data, controller: web::Data, ) -> HttpResponse { - let mut controller = controller.lock().await; - match animations.discover(&mut controller).await { + let controller = controller.lock().await; + match animations.discover(&controller).await { Ok(animations) => HttpResponse::Ok().json(animations), Err(animations::LogicError::InternalError(e)) => { HttpResponse::InternalServerError().json(json!({"error": e})) @@ -108,6 +109,56 @@ async fn list( } } +#[derive(Debug, MultipartForm)] +struct AnimationInstallForm { + #[multipart(limit = "10MB")] + file: TempFile, +} + +#[post("/install/")] +async fn install( + form: MultipartForm, + animations: web::Data, + controller: web::Data, +) -> HttpResponse { + let controller = controller.lock().await; + match animations + .install(&controller, form.0.file.file.path()) + .await + { + Ok(animations) => HttpResponse::Ok().json(animations), + Err(animations::LogicError::InvalidAnimation(e)) => { + HttpResponse::BadRequest().json(json!({"error": e.to_string()})) + } + Err(animations::LogicError::InternalError(e)) => { + HttpResponse::InternalServerError().json(json!({"error": e})) + } + Err(e) => { + error!("Unexpected logic error in /list/ call: {e}"); + HttpResponse::InternalServerError().json(json!({"error": "unexpected failure"})) + } + } +} + +#[post("/remove/")] +async fn remove( + form: web::Json, + animations: web::Data, + controller: web::Data, +) -> HttpResponse { + let controller = controller.lock().await; + match animations.remove(&controller, &form.0.animation_id).await { + Ok(animations) => HttpResponse::Ok().json(animations), + Err(animations::LogicError::InternalError(e)) => { + HttpResponse::InternalServerError().json(json!({"error": e})) + } + Err(e) => { + error!("Unexpected logic error in /list/ call: {e}"); + HttpResponse::InternalServerError().json(json!({"error": "unexpected failure"})) + } + } +} + pub fn service() -> Scope { web::scope("/animations") .service(reload) @@ -115,4 +166,6 @@ pub fn service() -> Scope { .service(turn_off) .service(discover) .service(list) + .service(install) + .service(remove) } diff --git a/webapi/src/animations/storage.rs b/webapi/src/animations/storage.rs index 1097875..77b5d8d 100644 --- a/webapi/src/animations/storage.rs +++ b/webapi/src/animations/storage.rs @@ -63,8 +63,11 @@ impl Storage { .into_iter() .filter_map(|r| { let manifest = serde_json::from_slice(&r.manifest) - .inspect_err(|_| { - warn!("Invalid manifest for animation with id {}, skipping", r.id) + .inspect_err(|e| { + warn!( + "Invalid manifest for animation with id {}, skipping: {e}", + r.id + ) }) .ok()?; Some(DbAnimation { @@ -77,4 +80,16 @@ impl Storage { Ok(animations) } + + pub async fn delete(&self, animation_id: &str) -> anyhow::Result { + let path = sqlx::query!( + "DELETE FROM animation_plugins WHERE id = $1 RETURNING path", + animation_id + ) + .fetch_one(&mut *self.conn.lock().await) + .await? + .path; + + Ok(path) + } } diff --git a/webui/Cargo.toml b/webui/Cargo.toml index 2d779d2..4e322cd 100644 --- a/webui/Cargo.toml +++ b/webui/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" lightfx = { path = "../lightfx" } animation-api = { path = "../animation-api" } rustmas-visualizer = { path = "../visualizer", optional = true } -rustmas-webapi-client = { path = "../webapi-client" } +rustmas-webapi-client = { path = "../webapi-client", features = ["js"] } gloo-utils = "0.1" instant = { version = "0.1.12", features = ["wasm-bindgen"] } diff --git a/webui/src/settings/animations.rs b/webui/src/settings/animations.rs index ec1259b..634f0aa 100644 --- a/webui/src/settings/animations.rs +++ b/webui/src/settings/animations.rs @@ -1,27 +1,31 @@ -use log::error; +use log::{error, warn}; use rustmas_webapi_client::{Animation, RustmasApiClient}; -use yew::{Callback, Html, html}; +use wasm_bindgen::JsCast; +use web_sys::{FormData, HtmlFormElement}; +use yew::{Callback, Html, SubmitEvent, html}; #[yew::function_component(AnimationsSettings)] pub fn animations_settings() -> Html { let api = yew::use_context::().expect("gateway to be open"); let animations = yew::use_state::>, _>(|| None); - wasm_bindgen_futures::spawn_local({ - let api = api.clone(); - let animations = animations.clone(); - async move { - match api.list_animations().await { - Ok(mut new_animations) => { - new_animations - .animations - .sort_by(|a, b| a.name.cmp(&b.name)); - animations.set(Some(new_animations.animations)); + if animations.is_none() { + wasm_bindgen_futures::spawn_local({ + let api = api.clone(); + let animations = animations.clone(); + async move { + match api.list_animations().await { + Ok(mut new_animations) => { + new_animations + .animations + .sort_by(|a, b| a.name.cmp(&b.name)); + animations.set(Some(new_animations.animations)); + } + Err(e) => error!("Could not load available animations: {}", e), } - Err(e) => error!("Could not load available animations: {}", e), } - } - }); + }); + } let discover = Callback::from({ let api = api.clone(); @@ -41,14 +45,52 @@ pub fn animations_settings() -> Html { } }); + let remove = |id: String| { + Callback::from({ + let api = api.clone(); + let animations = animations.clone(); + let id = id.clone(); + move |_| { + let api = api.clone(); + let animations = animations.clone(); + let id = id.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api.remove_animation(id).await { + Ok(mut new_animations) => { + new_animations.sort_by(|a, b| a.name.cmp(&b.name)); + animations.set(Some(new_animations)) + } + Err(e) => error!("Failed to remove animation, reason: {}", e), + } + }); + } + }) + }; + + let animation_installed_callback = Callback::from({ + let animations = animations.clone(); + move |new_animations| { + animations.set(Some(new_animations)); + } + }); + if let Some(ref animations) = *animations { html! { <> +

{ "Install new animation" }

+ + +

{ "Available animations" }

    { animations.iter() - .map(|animation| html! {
  • { animation.name.clone() }
  • }) + .map(|animation| html! { +
  • + { animation.name.clone() } + +
  • + }) .collect::() }
@@ -58,3 +100,71 @@ pub fn animations_settings() -> Html { html! {

{ "Loading animations... " }

} } } + +#[derive(Clone, Debug, PartialEq, yew::Properties)] +pub struct AnimationPluginInstallFormProps { + pub animation_installed_callback: Callback>, +} + +#[yew::function_component(AnimationsPluginInstallForm)] +pub fn animations_plugin_install_form(props: &AnimationPluginInstallFormProps) -> Html { + let api = yew::use_context::().expect("gateway to be open"); + let uploading = yew::use_state(|| false); + let error = yew::use_state(|| false); + + let install = Callback::from({ + let api = api.clone(); + let uploading = uploading.clone(); + let error = error.clone(); + let animation_installed_callback = props.animation_installed_callback.clone(); + move |submit: SubmitEvent| { + let api = api.clone(); + let uploading = uploading.clone(); + let error = error.clone(); + let animation_installed_callback = animation_installed_callback.clone(); + submit.prevent_default(); + + uploading.set(true); + let Some(form) = submit + .target() + .and_then(|t| t.dyn_into::().ok()) + else { + warn!("Submit target not a form"); + return; + }; + let Ok(form) = FormData::new_with_form(&form) else { + warn!("Failed to get plugin upload form data"); + return; + }; + wasm_bindgen_futures::spawn_local(async move { + match api.install_animation(form).await { + Ok(mut new_animations) => { + new_animations.sort_by(|a, b| a.name.cmp(&b.name)); + animation_installed_callback.emit(new_animations); + uploading.set(false); + error.set(false); + } + Err(e) => { + error!("Failed to install animation, reason: {}", e); + uploading.set(false); + error.set(true); + } + } + }); + } + }); + + html! { + if *uploading { + { "Uploading..." } + } else { + if *error { + { "Failed to install plugin" } + } +
+ + +
+ } + } +} diff --git a/webui/src/settings/events.rs b/webui/src/settings/events.rs index 8660ca0..37a8c97 100644 --- a/webui/src/settings/events.rs +++ b/webui/src/settings/events.rs @@ -14,18 +14,20 @@ pub fn events_settings() -> Html { let api = yew::use_context::().expect("gateway to be open"); let schema = yew::use_state::>, _>(|| None); - wasm_bindgen_futures::spawn_local({ - let api = api.clone(); - let schema = schema.clone(); - async move { - match api.events_schema().await { - Ok(new_schema) => { - schema.set(Some(new_schema)); + if schema.is_none() { + wasm_bindgen_futures::spawn_local({ + let api = api.clone(); + let schema = schema.clone(); + async move { + match api.events_schema().await { + Ok(new_schema) => { + schema.set(Some(new_schema)); + } + Err(e) => error!("Could not load event generator parameter schema: {}", e), } - Err(e) => error!("Could not load event generator parameter schema: {}", e), } - } - }); + }); + } let values_changed = { let api = api.clone(); diff --git a/webui/style_dark.css b/webui/style_dark.css index 5023d1a..d32fe2c 100644 --- a/webui/style_dark.css +++ b/webui/style_dark.css @@ -91,6 +91,10 @@ img.button { line-height: 2rem; padding: 0.5rem; border-bottom: 1px solid #888; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; } .content {