From 92792aded9483bfd33d17b5855b2be53641db221 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Tue, 7 Oct 2025 23:07:43 -0700 Subject: [PATCH] More fullstack fixes (#4737) * allow `--server` only builds! * rollback example * capture `/:route` routes * fix: properly implement file uploads in forms * implement desktop form uploads * Performant serialization of form bytes * finish up file stuff * cleanups * add request headers to streaming context * remove some unwraps --- Cargo.lock | 1 + examples/07-fullstack/multipart_form.rs | 11 +- examples/07-fullstack/server_state.rs | 1 + examples/08-apis/form.rs | 10 +- packages/cli/src/build/builder.rs | 17 +- packages/cli/src/build/context.rs | 11 +- packages/cli/src/build/pre_render.rs | 2 +- packages/cli/src/build/request.rs | 21 +- packages/cli/src/cli/build.rs | 12 +- packages/cli/src/cli/bundle.rs | 11 +- packages/cli/src/cli/hotpatch.rs | 16 +- packages/cli/src/serve/mod.rs | 4 +- packages/cli/src/serve/output.rs | 4 +- packages/cli/src/serve/runner.rs | 68 +-- packages/cli/src/serve/server.rs | 2 +- packages/desktop/headless_tests/events.rs | 22 +- packages/desktop/src/app.rs | 34 +- packages/desktop/src/events.rs | 55 +-- packages/desktop/src/file_upload.rs | 161 ++++--- packages/desktop/src/ipc.rs | 2 - packages/desktop/src/launch.rs | 1 - packages/desktop/src/protocol.rs | 98 ++++- packages/desktop/src/webview.rs | 40 +- packages/fullstack-core/src/error.rs | 82 +++- packages/fullstack-core/src/streaming.rs | 53 ++- packages/fullstack-macro/src/lib.rs | 44 +- packages/fullstack-server/src/server.rs | 13 +- packages/fullstack-server/src/ssr.rs | 18 +- packages/fullstack/Cargo.toml | 2 +- packages/fullstack/src/client.rs | 43 ++ packages/fullstack/src/payloads/multipart.rs | 6 +- packages/html/src/events/form.rs | 439 ++++++++++--------- packages/html/src/file_data.rs | 194 +++++--- packages/interpreter/build.rs | 2 +- packages/interpreter/src/js/common.js | 2 +- packages/interpreter/src/js/hash.txt | 2 +- packages/interpreter/src/js/native.js | 2 +- packages/interpreter/src/js/set_attribute.js | 1 + packages/interpreter/src/lib.rs | 5 +- packages/interpreter/src/ts/common.ts | 2 - packages/interpreter/src/ts/form.ts | 63 --- packages/interpreter/src/ts/native.ts | 150 +++++-- packages/interpreter/src/ts/serialize.ts | 127 +++++- packages/native-dom/src/dioxus_document.rs | 4 +- packages/native-dom/src/events.rs | 9 +- packages/web/Cargo.toml | 3 +- packages/web/src/events/form.rs | 74 ++-- 47 files changed, 1241 insertions(+), 703 deletions(-) create mode 100644 packages/interpreter/src/js/set_attribute.js delete mode 100644 packages/interpreter/src/ts/common.ts delete mode 100644 packages/interpreter/src/ts/form.ts diff --git a/Cargo.lock b/Cargo.lock index ab5af9f3d..d7300c21d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14089,6 +14089,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", diff --git a/examples/07-fullstack/multipart_form.rs b/examples/07-fullstack/multipart_form.rs index a48c63b79..145319203 100644 --- a/examples/07-fullstack/multipart_form.rs +++ b/examples/07-fullstack/multipart_form.rs @@ -52,11 +52,14 @@ fn app() -> Element { #[post("/api/upload-multipart")] async fn upload(mut form: MultipartFormData) -> Result<()> { while let Ok(Some(field)) = form.next_field().await { + let name = field.name().unwrap_or("").to_string(); + let file_name = field.file_name().unwrap_or("").to_string(); + let content_type = field.content_type().unwrap_or("").to_string(); + let size = field.bytes().await.unwrap().len(); + info!( - "Field name: {:?}, filename: {:?}, content_type: {:?}", - field.name().to_owned(), - field.file_name(), - field.content_type(), + "Field name: {:?}, filename: {:?}, content_type: {:?}, size: {:?}", + name, file_name, content_type, size ); } diff --git a/examples/07-fullstack/server_state.rs b/examples/07-fullstack/server_state.rs index ba94a598e..5c5ffac48 100644 --- a/examples/07-fullstack/server_state.rs +++ b/examples/07-fullstack/server_state.rs @@ -17,6 +17,7 @@ Option 1: For simple, synchronous, thread-safe data, we can use statics with atomic types or mutexes. The `LazyLock` type from the standard library is a great choice for simple, synchronous data */ +#[cfg(feature = "server")] static MESSAGES: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); #[post("/api/messages")] diff --git a/examples/08-apis/form.rs b/examples/08-apis/form.rs index bbef24f56..38535303d 100644 --- a/examples/08-apis/form.rs +++ b/examples/08-apis/form.rs @@ -4,15 +4,14 @@ //! in the "values" field. use dioxus::prelude::*; -use std::collections::HashMap; fn main() { dioxus::launch(app); } fn app() -> Element { - let mut values = use_signal(HashMap::new); - let mut submitted_values = use_signal(HashMap::new); + let mut values = use_signal(Vec::new); + let mut submitted_values = use_signal(Vec::new); rsx! { div { style: "display: flex", @@ -63,7 +62,7 @@ fn app() -> Element { input { r#type: "text", name: "full-name" } label { r#for: "email", "Email (matching @example.com)" } - input { r#type: "email", pattern: ".+@example\\.com", size: "30", required: "true", id: "email", name: "email" } + input { r#type: "email", size: "30", id: "email", name: "email" } label { r#for: "password", "Password" } input { r#type: "password", name: "password" } @@ -122,6 +121,9 @@ fn app() -> Element { input { r#type: "checkbox", name: "cbox", value: "yellow", id: "cbox-yellow" } } + // File input + label { r#for: "headshot", "Headshot" } + input { r#type: "file", name: "headshot", id: "headshot", multiple: true, accept: ".png,.jpg,.jpeg" } // Buttons will submit your form by default. button { r#type: "submit", value: "Submit", "Submit the form" } diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 33c4fa83c..efaf87d5c 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -154,19 +154,20 @@ impl AppBuilder { } /// Create a new `AppBuilder` and immediately start a build process. - pub fn started(request: &BuildRequest, mode: BuildMode) -> Result { + pub fn started(request: &BuildRequest, mode: BuildMode, build_id: BuildId) -> Result { let mut builder = Self::new(request)?; - builder.start(mode); + builder.start(mode, build_id); Ok(builder) } - pub(crate) fn start(&mut self, mode: BuildMode) { + pub(crate) fn start(&mut self, mode: BuildMode, build_id: BuildId) { self.build_task = tokio::spawn({ let request = self.build.clone(); let tx = self.tx.clone(); async move { let ctx = BuildContext { mode, + build_id, tx: tx.clone(), }; request.verify_tooling(&ctx).await?; @@ -290,10 +291,12 @@ impl AppBuilder { update } - pub(crate) fn patch_rebuild(&mut self, changed_files: Vec) { + pub(crate) fn patch_rebuild(&mut self, changed_files: Vec, build_id: BuildId) { // We need the rustc args from the original build to pass to the new build let Some(artifacts) = self.artifacts.as_ref().cloned() else { - tracing::warn!("Ignoring patch rebuild since there is no existing build."); + tracing::warn!( + "Ignoring patch rebuild for {build_id:?} since there is no existing build." + ); return; }; @@ -327,6 +330,7 @@ impl AppBuilder { self.build_task = tokio::spawn({ let request = self.build.clone(); let ctx = BuildContext { + build_id, tx: self.tx.clone(), mode: BuildMode::Thin { changed_files, @@ -340,7 +344,7 @@ impl AppBuilder { } /// Restart this builder with new build arguments. - pub(crate) fn start_rebuild(&mut self, mode: BuildMode) { + pub(crate) fn start_rebuild(&mut self, mode: BuildMode, build_id: BuildId) { // Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit // And then start a new build, resetting our progress/stage to the beginning and replacing the old tokio task self.abort_all(BuildStage::Restarting); @@ -351,6 +355,7 @@ impl AppBuilder { let ctx = BuildContext { tx: self.tx.clone(), mode, + build_id, }; async move { request.build(&ctx).await } }); diff --git a/packages/cli/src/build/context.rs b/packages/cli/src/build/context.rs index 3cf7ac873..3810c9ca3 100644 --- a/packages/cli/src/build/context.rs +++ b/packages/cli/src/build/context.rs @@ -16,6 +16,7 @@ use std::{path::PathBuf, process::ExitStatus}; pub struct BuildContext { pub tx: ProgressTx, pub mode: BuildMode, + pub build_id: BuildId, } pub type ProgressTx = UnboundedSender; @@ -24,8 +25,8 @@ pub type ProgressRx = UnboundedReceiver; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] pub struct BuildId(pub(crate) usize); impl BuildId { - pub const CLIENT: Self = Self(0); - pub const SERVER: Self = Self(1); + pub const PRIMARY: Self = Self(0); + pub const SECONDARY: Self = Self(1); } #[allow(clippy::large_enum_variant)] @@ -78,6 +79,12 @@ pub enum BuilderUpdate { } impl BuildContext { + /// Returns true if this is a client build - basically, is this the primary build? + /// We try not to duplicate work between client and server builds, like asset copying. + pub(crate) fn is_primary_build(&self) -> bool { + self.build_id == BuildId::PRIMARY + } + pub(crate) fn status_wasm_bindgen_start(&self) { _ = self.tx.unbounded_send(BuilderUpdate::Progress { stage: BuildStage::RunningBindgen, diff --git a/packages/cli/src/build/pre_render.rs b/packages/cli/src/build/pre_render.rs index 5b7010d35..c7c87ad00 100644 --- a/packages/cli/src/build/pre_render.rs +++ b/packages/cli/src/build/pre_render.rs @@ -45,7 +45,7 @@ pub(crate) async fn pre_render_static_routes( devserver_ip, Some(fullstack_address), false, - BuildId::SERVER, + BuildId::SECONDARY, ); // Run the server executable let _child = Command::new(&server_exe) diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index da0bca981..d2fc71df0 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -320,9 +320,9 @@ //! - xbuild: use crate::{ - AndroidTools, BuildContext, BundleFormat, DioxusConfig, Error, LinkAction, LinkerFlavor, - Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, WasmOptConfig, - Workspace, DX_RUSTC_WRAPPER_ENV_VAR, + AndroidTools, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction, + LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, + WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR, }; use anyhow::{bail, Context}; use cargo_metadata::diagnostic::Diagnostic; @@ -445,7 +445,6 @@ pub enum BuildMode { #[derive(Clone, Debug)] pub struct BuildArtifacts { pub(crate) root_dir: PathBuf, - pub(crate) bundle: BundleFormat, pub(crate) exe: PathBuf, pub(crate) direct_rustc: RustcArgs, pub(crate) time_start: SystemTime, @@ -454,6 +453,7 @@ pub struct BuildArtifacts { pub(crate) mode: BuildMode, pub(crate) patch_cache: Option>, pub(crate) depinfo: RustcDepInfo, + pub(crate) build_id: BuildId, } impl BuildRequest { @@ -989,10 +989,10 @@ impl BuildRequest { _ = std::fs::File::create_new(self.windows_command_file()); if !matches!(ctx.mode, BuildMode::Thin { .. }) { - self.prepare_build_dir()?; + self.prepare_build_dir(ctx)?; } - if self.bundle == BundleFormat::Server { + if !ctx.is_primary_build() { return Ok(()); } @@ -1250,7 +1250,6 @@ impl BuildRequest { let assets = self.collect_assets(&exe, ctx).await?; let time_end = SystemTime::now(); let mode = ctx.mode.clone(); - let bundle = self.bundle; let depinfo = RustcDepInfo::from_file(&exe.with_extension("d")).unwrap_or_default(); tracing::debug!( @@ -1261,7 +1260,6 @@ impl BuildRequest { Ok(BuildArtifacts { time_end, - bundle, exe, direct_rustc, time_start, @@ -1270,6 +1268,7 @@ impl BuildRequest { depinfo, root_dir: self.root_dir(), patch_cache: None, + build_id: ctx.build_id, }) } @@ -1460,7 +1459,7 @@ impl BuildRequest { /// Should be the same on all platforms - just copy over the assets from the manifest into the output directory async fn write_assets(&self, ctx: &BuildContext, assets: &AssetManifest) -> Result<()> { // Server doesn't need assets - web will provide them - if self.bundle == BundleFormat::Server { + if !ctx.is_primary_build() { return Ok(()); } @@ -4320,14 +4319,14 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ /// - extra scaffolding /// /// It's not guaranteed that they're different from any other folder - pub(crate) fn prepare_build_dir(&self) -> Result<()> { + pub(crate) fn prepare_build_dir(&self, ctx: &BuildContext) -> Result<()> { use std::fs::{create_dir_all, remove_dir_all}; use std::sync::OnceLock; static INITIALIZED: OnceLock> = OnceLock::new(); let success = INITIALIZED.get_or_init(|| { - if self.bundle != BundleFormat::Server { + if ctx.is_primary_build() { _ = remove_dir_all(self.exe_dir()); } diff --git a/packages/cli/src/cli/build.rs b/packages/cli/src/cli/build.rs index 19cc87d35..68789ed84 100644 --- a/packages/cli/src/cli/build.rs +++ b/packages/cli/src/cli/build.rs @@ -1,8 +1,8 @@ use dioxus_dx_wire_format::StructuredBuildArtifacts; use crate::{ - cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildMode, BuildRequest, BundleFormat, - TargetArgs, Workspace, + cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildId, BuildMode, BuildRequest, BundleFormat, + Platform, TargetArgs, Workspace, }; /// Build the Rust Dioxus app and all of its assets. @@ -128,6 +128,10 @@ impl CommandWithPlatformOverrides { BuildRequest::new(&server_args.build_arguments, workspace.clone()).await?, ); } + None if client_args.platform == Platform::Server => { + // If the user requests a server build with `--server`, then we don't need to build a separate server binary. + // There's no client to use, so even though fullstack is true, we only build the server. + } None => { let mut args = self.shared.build_arguments.clone(); args.platform = crate::Platform::Server; @@ -171,7 +175,7 @@ impl CommandWithPlatformOverrides { request: &BuildRequest, mode: BuildMode, ) -> Result { - AppBuilder::started(request, mode)? + AppBuilder::started(request, mode, BuildId::PRIMARY)? .finish_build() .await .inspect(|_| { @@ -189,7 +193,7 @@ impl CommandWithPlatformOverrides { }; // If the server is present, we need to build it as well - let mut server_build = AppBuilder::started(server, mode)?; + let mut server_build = AppBuilder::started(server, mode, BuildId::SECONDARY)?; let server_artifacts = server_build.finish_build().await?; // Run SSG and cache static routes diff --git a/packages/cli/src/cli/bundle.rs b/packages/cli/src/cli/bundle.rs index 313c0199c..ba6f0e9e3 100644 --- a/packages/cli/src/cli/bundle.rs +++ b/packages/cli/src/cli/bundle.rs @@ -1,4 +1,4 @@ -use crate::{AppBuilder, BuildArgs, BuildMode, BuildRequest, BundleFormat}; +use crate::{AppBuilder, BuildArgs, BuildId, BuildMode, BuildRequest, BundleFormat}; use anyhow::{bail, Context}; use path_absolutize::Absolutize; use std::collections::HashMap; @@ -40,16 +40,17 @@ impl Bundle { let BuildTargets { client, server } = self.args.into_targets().await?; let mut server_artifacts = None; - let client_artifacts = AppBuilder::started(&client, BuildMode::Base { run: false })? - .finish_build() - .await?; + let client_artifacts = + AppBuilder::started(&client, BuildMode::Base { run: false }, BuildId::PRIMARY)? + .finish_build() + .await?; tracing::info!(path = ?client.root_dir(), "Client build completed successfully! 🚀"); if let Some(server) = server.as_ref() { // If the server is present, we need to build it as well server_artifacts = Some( - AppBuilder::started(server, BuildMode::Base { run: false })? + AppBuilder::started(server, BuildMode::Base { run: false }, BuildId::SECONDARY)? .finish_build() .await?, ); diff --git a/packages/cli/src/cli/hotpatch.rs b/packages/cli/src/cli/hotpatch.rs index a2d619afd..77f213c18 100644 --- a/packages/cli/src/cli/hotpatch.rs +++ b/packages/cli/src/cli/hotpatch.rs @@ -1,5 +1,5 @@ use crate::{ - platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildMode, + platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildId, BuildMode, HotpatchModuleCache, Result, StructuredOutput, }; use anyhow::Context; @@ -34,8 +34,16 @@ impl HotpatchTip { pub async fn run(self) -> Result { let targets = self.build_args.into_targets().await?; + let patch_server = self.patch_server.unwrap_or(false); + + let build_id = if patch_server { + BuildId::SECONDARY + } else { + BuildId::PRIMARY + }; + // Select which target to patch - let request = if self.patch_server.unwrap_or_default() { + let request = if patch_server { targets.server.as_ref().context("No server to patch!")? } else { &targets.client @@ -68,7 +76,9 @@ impl HotpatchTip { cache: cache.clone(), }; - let artifacts = AppBuilder::started(request, mode)?.finish_build().await?; + let artifacts = AppBuilder::started(request, mode, build_id)? + .finish_build() + .await?; let patch_exe = request.patch_exe(artifacts.time_start); Ok(StructuredOutput::Hotpatch { diff --git a/packages/cli/src/serve/mod.rs b/packages/cli/src/serve/mod.rs index 7e54e8abf..176e192cd 100644 --- a/packages/cli/src/serve/mod.rs +++ b/packages/cli/src/serve/mod.rs @@ -109,12 +109,12 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &TraceController) -> Resu pid, } => { devserver - .send_hotreload(builder.applied_hot_reload_changes(BuildId::CLIENT)) + .send_hotreload(builder.applied_hot_reload_changes(BuildId::PRIMARY)) .await; if builder.server.is_some() { devserver - .send_hotreload(builder.applied_hot_reload_changes(BuildId::SERVER)) + .send_hotreload(builder.applied_hot_reload_changes(BuildId::SECONDARY)) .await; } diff --git a/packages/cli/src/serve/output.rs b/packages/cli/src/serve/output.rs index e0404315b..e2bd677aa 100644 --- a/packages/cli/src/serve/output.rs +++ b/packages/cli/src/serve/output.rs @@ -268,12 +268,12 @@ impl Output { } KeyCode::Char('D') => { return Ok(Some(ServeUpdate::OpenDebugger { - id: BuildId::SERVER, + id: BuildId::SECONDARY, })); } KeyCode::Char('d') => { return Ok(Some(ServeUpdate::OpenDebugger { - id: BuildId::CLIENT, + id: BuildId::PRIMARY, })); } KeyCode::Char('c') => { diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index 211e5b0b1..e34042b96 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -234,9 +234,9 @@ impl AppServer { false => BuildMode::Base { run: true }, }; - self.client.start(build_mode.clone()); + self.client.start(build_mode.clone(), BuildId::PRIMARY); if let Some(server) = self.server.as_mut() { - server.start(build_mode); + server.start(build_mode, BuildId::SECONDARY); } } @@ -273,14 +273,14 @@ impl AppServer { // Wait for the client to finish client_update = client_wait => { ServeUpdate::BuilderUpdate { - id: BuildId::CLIENT, + id: BuildId::PRIMARY, update: client_update, } } Some(server_update) = server_wait => { ServeUpdate::BuilderUpdate { - id: BuildId::SERVER, + id: BuildId::SECONDARY, update: server_update, } } @@ -491,17 +491,18 @@ impl AppServer { // A full rebuild is required when the user modifies static initializers which we haven't wired up yet. if needs_full_rebuild && self.automatic_rebuilds { if self.use_hotpatch_engine { - self.client.patch_rebuild(files.to_vec()); + self.client.patch_rebuild(files.to_vec(), BuildId::PRIMARY); if let Some(server) = self.server.as_mut() { - server.patch_rebuild(files.to_vec()); + server.patch_rebuild(files.to_vec(), BuildId::SECONDARY); } self.clear_hot_reload_changes(); self.clear_cached_rsx(); server.send_patch_start().await; } else { - self.client.start_rebuild(BuildMode::Base { run: true }); + self.client + .start_rebuild(BuildMode::Base { run: true }, BuildId::PRIMARY); if let Some(server) = self.server.as_mut() { - server.start_rebuild(BuildMode::Base { run: true }); + server.start_rebuild(BuildMode::Base { run: true }, BuildId::SECONDARY); } self.clear_hot_reload_changes(); self.clear_cached_rsx(); @@ -560,13 +561,14 @@ impl AppServer { devserver: &mut WebServer, ) -> Result<()> { // Make sure to save artifacts regardless of if we're opening the app or not - match artifacts.bundle { - BundleFormat::Server => { + match artifacts.build_id { + BuildId::PRIMARY => self.client.artifacts = Some(artifacts.clone()), + BuildId::SECONDARY => { if let Some(server) = self.server.as_mut() { server.artifacts = Some(artifacts.clone()); } } - _ => self.client.artifacts = Some(artifacts.clone()), + _ => {} } let should_open = self.client.stage == BuildStage::Success @@ -634,7 +636,7 @@ impl AppServer { fullstack_address, false, false, - BuildId::SERVER, + BuildId::SECONDARY, &self.server_args, ) .await?; @@ -649,7 +651,7 @@ impl AppServer { fullstack_address, open_browser, self.always_on_top, - BuildId::CLIENT, + BuildId::PRIMARY, &self.client_args, ) .await?; @@ -696,9 +698,10 @@ impl AppServer { false => BuildMode::Base { run: true }, }; - self.client.start_rebuild(build_mode.clone()); + self.client + .start_rebuild(build_mode.clone(), BuildId::PRIMARY); if let Some(s) = self.server.as_mut() { - s.start_rebuild(build_mode) + s.start_rebuild(build_mode, BuildId::SECONDARY); } self.clear_hot_reload_changes(); @@ -719,8 +722,8 @@ impl AppServer { .unwrap_or_default(); let jump_table = match id { - BuildId::CLIENT => self.client.hotpatch(bundle, cache).await, - BuildId::SERVER => { + BuildId::PRIMARY => self.client.hotpatch(bundle, cache).await, + BuildId::SECONDARY => { self.server .as_mut() .context("Server not found")? @@ -730,7 +733,7 @@ impl AppServer { _ => bail!("Invalid build id"), }?; - if id == BuildId::CLIENT { + if id == BuildId::PRIMARY { self.applied_client_hot_reload_message.jump_table = self.client.patches.last().cloned(); } @@ -759,11 +762,16 @@ impl AppServer { .context("Missing server jump table")?; devserver - .send_patch(server_jump_table, elapsed, BuildId::SERVER, server.pid) + .send_patch(server_jump_table, elapsed, BuildId::SECONDARY, server.pid) .await; devserver - .send_patch(client_jump_table, elapsed, BuildId::CLIENT, self.client.pid) + .send_patch( + client_jump_table, + elapsed, + BuildId::PRIMARY, + self.client.pid, + ) .await; } @@ -772,8 +780,8 @@ impl AppServer { pub(crate) fn get_build(&self, id: BuildId) -> Option<&AppBuilder> { match id { - BuildId::CLIENT => Some(&self.client), - BuildId::SERVER => self.server.as_ref(), + BuildId::PRIMARY => Some(&self.client), + BuildId::SECONDARY => self.server.as_ref(), _ => None, } } @@ -791,18 +799,18 @@ impl AppServer { pub(crate) fn applied_hot_reload_changes(&mut self, build: BuildId) -> HotReloadMsg { let mut msg = self.applied_client_hot_reload_message.clone(); - if build == BuildId::CLIENT { + if build == BuildId::PRIMARY { msg.jump_table = self.client.patches.last().cloned(); - msg.for_build_id = Some(BuildId::CLIENT.0 as _); + msg.for_build_id = Some(BuildId::PRIMARY.0 as _); if let Some(lib) = msg.jump_table.as_mut() { lib.lib = PathBuf::from("/").join(lib.lib.clone()); } } - if build == BuildId::SERVER { + if build == BuildId::SECONDARY { if let Some(server) = self.server.as_mut() { msg.jump_table = server.patches.last().cloned(); - msg.for_build_id = Some(BuildId::SERVER.0 as _); + msg.for_build_id = Some(BuildId::SECONDARY.0 as _); } } @@ -828,7 +836,7 @@ impl AppServer { pid: Option, ) { match build_id { - BuildId::CLIENT => { + BuildId::PRIMARY => { // multiple tabs on web can cause this to be called incorrectly, and it doesn't // make any sense anyways if self.client.build.bundle != BundleFormat::Web { @@ -840,7 +848,7 @@ impl AppServer { } } } - BuildId::SERVER => { + BuildId::SECONDARY => { if let Some(server) = self.server.as_mut() { server.aslr_reference = aslr_reference; } @@ -1146,10 +1154,10 @@ impl AppServer { } match build { - BuildId::CLIENT => { + BuildId::PRIMARY => { _ = self.client.open_debugger(dev).await; } - BuildId::SERVER => { + BuildId::SECONDARY => { if let Some(server) = self.server.as_mut() { _ = server.open_debugger(dev).await; } diff --git a/packages/cli/src/serve/server.rs b/packages/cli/src/serve/server.rs index a795f3f51..cc80b52db 100644 --- a/packages/cli/src/serve/server.rs +++ b/packages/cli/src/serve/server.rs @@ -149,7 +149,7 @@ impl WebServer { if let Some(new_socket) = new_hot_reload_socket { let aslr_reference = new_socket.aslr_reference; let pid = new_socket.pid; - let id = new_socket.build_id.unwrap_or(BuildId::CLIENT); + let id = new_socket.build_id.unwrap_or(BuildId::PRIMARY); drop(new_message); self.hot_reload_sockets.push(new_socket); diff --git a/packages/desktop/headless_tests/events.rs b/packages/desktop/headless_tests/events.rs index 286a6dce3..dc5f62929 100644 --- a/packages/desktop/headless_tests/events.rs +++ b/packages/desktop/headless_tests/events.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use dioxus::html::geometry::euclid::Vector3D; use dioxus::prelude::*; use dioxus_desktop::DesktopContext; @@ -448,7 +446,7 @@ fn test_focus_out_div() -> Element { } fn test_form_input() -> Element { - let mut values = use_signal(HashMap::new); + let mut values = use_signal(Vec::new); utils::mock_event_with_extra( "form-username", @@ -464,10 +462,10 @@ fn test_form_input() -> Element { // And then the value the form gives us should also match values.with_mut(|x| { - assert_eq!(x.get("username").unwrap(), "hello"); - assert_eq!(x.get("full-name").unwrap(), "lorem"); - assert_eq!(x.get("password").unwrap(), "ipsum"); - assert_eq!(x.get("color").unwrap(), "red"); + assert_eq!(x.iter().find(|f| f.0 == "username").unwrap().1, "hello"); + assert_eq!(x.iter().find(|f| f.0 == "full-name").unwrap().1, "lorem"); + assert_eq!(x.iter().find(|f| f.0 == "password").unwrap().1, "ipsum"); + assert_eq!(x.iter().find(|f| f.0 == "color").unwrap().1, "red"); }); RECEIVED_EVENTS.with_mut(|x| *x += 1); }; @@ -505,7 +503,7 @@ fn test_form_input() -> Element { } fn test_form_submit() -> Element { - let mut values = use_signal(HashMap::new); + let mut values = use_signal(Vec::new); utils::mock_event_with_extra( "form-submitter", @@ -516,10 +514,10 @@ fn test_form_submit() -> Element { let set_values = move |ev: FormEvent| { values.set(ev.values()); values.with_mut(|x| { - assert_eq!(x.get("username").unwrap(), "goodbye"); - assert_eq!(x.get("full-name").unwrap(), "lorem"); - assert_eq!(x.get("password").unwrap(), "ipsum"); - assert_eq!(x.get("color").unwrap(), "red"); + assert_eq!(x.iter().find(|f| f.0 == "username").unwrap().1, "goodbye"); + assert_eq!(x.iter().find(|f| f.0 == "full-name").unwrap().1, "lorem"); + assert_eq!(x.iter().find(|f| f.0 == "password").unwrap().1, "ipsum"); + assert_eq!(x.iter().find(|f| f.0 == "color").unwrap().1, "red"); }); RECEIVED_EVENTS.with_mut(|x| *x += 1); }; diff --git a/packages/desktop/src/app.rs b/packages/desktop/src/app.rs index dbe772f72..596c0d808 100644 --- a/packages/desktop/src/app.rs +++ b/packages/desktop/src/app.rs @@ -2,17 +2,14 @@ use crate::{ config::{Config, WindowCloseBehaviour}, edits::EditWebsocket, event_handlers::WindowEventHandlers, - file_upload::{DesktopFileUploadForm, FileDialogRequest}, ipc::{IpcMessage, UserWindowEvent}, query::QueryResult, shortcut::ShortcutRegistry, webview::{PendingWebview, WebviewInstance}, }; -use dioxus_core::{consume_context, ElementId, ScopeId, VirtualDom}; +use dioxus_core::{consume_context, ScopeId, VirtualDom}; use dioxus_history::History; -use dioxus_html::PlatformEventData; use std::{ - any::Any, cell::{Cell, RefCell}, collections::HashMap, rc::Rc, @@ -409,35 +406,6 @@ impl App { } } - pub fn handle_file_dialog_msg(&mut self, msg: IpcMessage, window: WindowId) { - let Ok(file_dialog) = serde_json::from_value::(msg.params()) else { - return; - }; - - let id = ElementId(file_dialog.target); - let event_name = &file_dialog.event; - let event_bubbles = file_dialog.bubbles; - let files = file_dialog.get_file_event(); - - let as_any = Box::new(DesktopFileUploadForm { files }); - - let data = Rc::new(PlatformEventData::new(as_any)); - - let Some(view) = self.webviews.get_mut(&window) else { - return; - }; - - let event = dioxus_core::Event::new(data as Rc, event_bubbles); - - let runtime = view.dom.runtime(); - if event_name == "change&input" { - runtime.handle_event("input", event.clone(), id); - runtime.handle_event("change", event, id); - } else { - runtime.handle_event(event_name, event, id); - } - } - /// Poll the virtualdom until it's pending /// /// The waker we give it is connected to the event loop, so it will wake up the event loop when it's ready to be polled again diff --git a/packages/desktop/src/events.rs b/packages/desktop/src/events.rs index 3d863b50e..f5a2f20f2 100644 --- a/packages/desktop/src/events.rs +++ b/packages/desktop/src/events.rs @@ -2,13 +2,34 @@ use crate::{ element::DesktopElement, - file_upload::{DesktopFileDragEvent, DesktopFileUploadForm}, + file_upload::{DesktopFileDragEvent, DesktopFormData}, }; use dioxus_html::*; pub(crate) struct SerializedHtmlEventConverter; impl HtmlEventConverter for SerializedHtmlEventConverter { + /// This impl is special because it relies on the renderer to convert from serialized data to native data + /// as an intermediate step. + fn convert_drag_data(&self, event: &PlatformEventData) -> DragData { + event + .downcast::() + .cloned() + .unwrap() + .into() + } + + /// This impl is special because it relies on the renderer to convert from serialized data to native data + /// as an intermediate step. + fn convert_form_data(&self, event: &PlatformEventData) -> FormData { + event.downcast::().cloned().unwrap().into() + } + + /// This impl is special because it leverages the `DesktopElement` ref between the virtualdom and webview + fn convert_mounted_data(&self, event: &PlatformEventData) -> MountedData { + event.downcast::().cloned().unwrap().into() + } + fn convert_animation_data(&self, event: &PlatformEventData) -> AnimationData { event .downcast::() @@ -41,20 +62,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter { .into() } - fn convert_drag_data(&self, event: &PlatformEventData) -> DragData { - // Attempt a simple serialized data conversion - if let Some(_data) = event.downcast::() { - return _data.clone().into(); - } - - // If that failed then it's a file drag form - event - .downcast::() - .cloned() - .unwrap() - .into() - } - fn convert_focus_data(&self, event: &PlatformEventData) -> FocusData { event .downcast::() @@ -63,20 +70,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter { .into() } - fn convert_form_data(&self, event: &PlatformEventData) -> FormData { - // Attempt a simple serialized form data conversion - if let Some(_data) = event.downcast::() { - return _data.clone().into(); - } - - // If that failed then it's a file upload form - event - .downcast::() - .cloned() - .unwrap() - .into() - } - fn convert_image_data(&self, event: &PlatformEventData) -> ImageData { event .downcast::() @@ -101,10 +94,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter { .into() } - fn convert_mounted_data(&self, event: &PlatformEventData) -> MountedData { - event.downcast::().cloned().unwrap().into() - } - fn convert_mouse_data(&self, event: &PlatformEventData) -> MouseData { event .downcast::() diff --git a/packages/desktop/src/file_upload.rs b/packages/desktop/src/file_upload.rs index 41a874390..582f964b6 100644 --- a/packages/desktop/src/file_upload.rs +++ b/packages/desktop/src/file_upload.rs @@ -1,6 +1,6 @@ #![allow(unused)] -use std::any::Any; +use std::{any::Any, collections::HashMap}; #[cfg(feature = "tokio_runtime")] use tokio::{fs::File, io::AsyncReadExt}; @@ -11,11 +11,11 @@ use dioxus_html::{ point_interaction::{ InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, }, - FileData, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData, - SerializedMouseData, SerializedPointInteraction, + FileData, FormValue, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData, + SerializedFormData, SerializedFormObject, SerializedMouseData, SerializedPointInteraction, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ cell::{Cell, RefCell}, path::PathBuf, @@ -25,7 +25,7 @@ use std::{ }; use wry::DragDropEvent; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub(crate) struct FileDialogRequest { #[serde(default)] accept: Option, @@ -34,6 +34,8 @@ pub(crate) struct FileDialogRequest { pub event: String, pub target: usize, pub bubbles: bool, + pub target_name: String, + pub values: Vec, } #[allow(unused)] @@ -61,57 +63,69 @@ impl FileDialogRequest { target_os = "openbsd" ))] pub(crate) fn get_file_event(&self) -> Vec { - fn get_file_event_for_folder( - request: &FileDialogRequest, - dialog: rfd::FileDialog, - ) -> Vec { - if request.multiple { - dialog.pick_folders().into_iter().flatten().collect() - } else { - dialog.pick_folder().into_iter().collect() - } - } - - fn get_file_event_for_file( - request: &FileDialogRequest, - mut dialog: rfd::FileDialog, - ) -> Vec { - let filters: Vec<_> = request - .accept - .as_deref() - .unwrap_or(".*") - .split(',') - .filter_map(|s| Filters::from_str(s.trim()).ok()) - .collect(); - - let file_extensions: Vec<_> = filters - .iter() - .flat_map(|f| f.as_extensions().into_iter()) - .collect(); - - let filter_name = file_extensions - .iter() - .map(|extension| format!("*.{extension}")) - .collect::>() - .join(", "); - - dialog = dialog.add_filter(filter_name, file_extensions.as_slice()); - - let files: Vec<_> = if request.multiple { - dialog.pick_files().into_iter().flatten().collect() - } else { - dialog.pick_file().into_iter().collect() - }; - - files - } - let dialog = rfd::FileDialog::new(); if self.directory { - get_file_event_for_folder(self, dialog) + self.get_file_event_for_folder(dialog) } else { - get_file_event_for_file(self, dialog) + self.get_file_event_for_file(dialog) + } + } + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + fn get_file_event_for_file(&self, mut dialog: rfd::FileDialog) -> Vec { + let filters: Vec<_> = self + .accept + .as_deref() + .unwrap_or(".*") + .split(',') + .filter_map(|s| Filters::from_str(s.trim()).ok()) + .collect(); + + let file_extensions: Vec<_> = filters + .iter() + .flat_map(|f| f.as_extensions().into_iter()) + .collect(); + + let filter_name = file_extensions + .iter() + .map(|extension| format!("*.{extension}")) + .collect::>() + .join(", "); + + dialog = dialog.add_filter(filter_name, file_extensions.as_slice()); + + let files: Vec<_> = if self.multiple { + dialog.pick_files().into_iter().flatten().collect() + } else { + dialog.pick_file().into_iter().collect() + }; + + files + } + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + fn get_file_event_for_folder(&self, dialog: rfd::FileDialog) -> Vec { + if self.multiple { + dialog.pick_folders().into_iter().flatten().collect() + } else { + dialog.pick_folder().into_iter().collect() } } } @@ -154,24 +168,53 @@ impl FromStr for Filters { } #[derive(Clone)] -pub(crate) struct DesktopFileUploadForm { - pub files: Vec, +pub(crate) struct DesktopFormData { + pub value: String, + pub valid: bool, + pub values: Vec<(String, FormValue)>, } -impl HasFileData for DesktopFileUploadForm { +impl DesktopFormData { + pub fn new(values: Vec<(String, FormValue)>) -> Self { + Self { + value: String::new(), + valid: true, + values, + } + } +} + +impl HasFileData for DesktopFormData { fn files(&self) -> Vec { - self.files + self.values .iter() - .cloned() - .map(|f| FileData::new(DesktopFileData(f))) + .filter_map(|(_, v)| { + if let FormValue::File(Some(f)) = v { + Some(f.clone()) + } else { + None + } + }) .collect() } } -impl HasFormData for DesktopFileUploadForm { +impl HasFormData for DesktopFormData { fn as_any(&self) -> &dyn std::any::Any { self } + + fn value(&self) -> String { + self.value.clone() + } + + fn valid(&self) -> bool { + self.valid + } + + fn values(&self) -> Vec<(String, FormValue)> { + self.values.clone() + } } #[derive(Default, Clone)] diff --git a/packages/desktop/src/ipc.rs b/packages/desktop/src/ipc.rs index 3f744bbba..52908f74a 100644 --- a/packages/desktop/src/ipc.rs +++ b/packages/desktop/src/ipc.rs @@ -57,7 +57,6 @@ pub struct IpcMessage { /// A set of known messages that we need to respond to #[derive(Deserialize, Serialize, Debug, Clone)] pub enum IpcMethod<'a> { - FileDialog, UserEvent, Query, BrowserOpen, @@ -68,7 +67,6 @@ pub enum IpcMethod<'a> { impl IpcMessage { pub(crate) fn method(&self) -> IpcMethod<'_> { match self.method.as_str() { - "file_dialog" => IpcMethod::FileDialog, "user_event" => IpcMethod::UserEvent, "query" => IpcMethod::Query, "browser_open" => IpcMethod::BrowserOpen, diff --git a/packages/desktop/src/launch.rs b/packages/desktop/src/launch.rs index 59dbda9a7..1591806d5 100644 --- a/packages/desktop/src/launch.rs +++ b/packages/desktop/src/launch.rs @@ -91,7 +91,6 @@ pub fn launch_virtual_dom_blocking(virtual_dom: VirtualDom, mut desktop_config: UserWindowEvent::Ipc { id, msg } => match msg.method() { IpcMethod::Initialize => app.handle_initialize_msg(id), - IpcMethod::FileDialog => app.handle_file_dialog_msg(msg, id), IpcMethod::UserEvent => {} IpcMethod::Query => app.handle_query_msg(msg, id), IpcMethod::BrowserOpen => app.handle_browser_open(msg), diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index f4b69f8e4..5268f65d4 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -1,5 +1,8 @@ -use crate::document::NATIVE_EVAL_JS; use crate::{assets::*, webview::WebviewEdits}; +use crate::{document::NATIVE_EVAL_JS, file_upload::FileDialogRequest}; +use base64::prelude::BASE64_STANDARD; +use dioxus_core::AnyhowContext; +use dioxus_html::{SerializedFileData, SerializedFormObject}; use dioxus_interpreter_js::unified_bindings::SLEDGEHAMMER_JS; use dioxus_interpreter_js::NATIVE_JS; use wry::{ @@ -8,13 +11,13 @@ use wry::{ }; #[cfg(target_os = "android")] -const EVENTS_PATH: &str = "https://dioxus.index.html/__events"; +const BASE_URI: &str = "https://dioxus.index.html/"; #[cfg(target_os = "windows")] -const EVENTS_PATH: &str = "http://dioxus.index.html/__events"; +const BASE_URI: &str = "http://dioxus.index.html/"; #[cfg(not(any(target_os = "android", target_os = "windows")))] -const EVENTS_PATH: &str = "dioxus://index.html/__events"; +const BASE_URI: &str = "dioxus://index.html/"; #[cfg(debug_assertions)] static DEFAULT_INDEX: &str = include_str!("./assets/dev.index.html"); @@ -58,6 +61,14 @@ pub(super) fn desktop_handler( return edit_state.handle_event(request, responder); } + // If the request is asking for a file dialog, handle that, returning the list of files selected + if trimmed_uri == "__file_dialog" { + if let Err(err) = file_dialog_responder(request, responder) { + tracing::error!("Failed to handle file dialog request: {err:?}"); + } + return; + } + // todo: we want to move the custom assets onto a different protocol or something if let Some(name) = request.uri().path().split('/').nth(1) { if asset_handlers.has_handler(name) { @@ -104,7 +115,6 @@ fn index_request( // Insert a custom head if provided // We look just for the closing head tag. If a user provided a custom index with weird syntax, this might fail - if let Some(head) = custom_head { index.insert_str(index.find("").expect("Head element to exist"), &head); } @@ -147,14 +157,14 @@ fn module_loader(root_id: &str, headless: bool, edit_state: &WebviewEdits) -> St {NATIVE_JS} // The native interpreter extends the sledgehammer interpreter with a few extra methods that we use for IPC - window.interpreter = new NativeInterpreter("{EVENTS_PATH}", {headless}); + window.interpreter = new NativeInterpreter("{BASE_URI}", {headless}); // Wait for the page to load before sending the initialize message window.onload = function() {{ let root_element = window.document.getElementById("{root_id}"); if (root_element != null) {{ window.interpreter.initialize(root_element); - window.ipc.postMessage(window.interpreter.serializeIpcMessage("initialize")); + window.interpreter.sendIpcMessage("initialize"); }} window.interpreter.waitForRequest("{edits_path}", "{expected_key}"); }} @@ -166,3 +176,77 @@ fn module_loader(root_id: &str, headless: bool, edit_state: &WebviewEdits) -> St "# ) } + +fn file_dialog_responder( + request: wry::http::Request>, + responder: wry::RequestAsyncResponder, +) -> dioxus_core::Result<()> { + // Handle the file dialog request + // We can't use the body, just the headers + let header = request + .headers() + .get("x-dioxus-data") + .context("Failed to get x-dioxus-data header")?; + + let data_from_header = base64::Engine::decode(&BASE64_STANDARD, header.as_bytes()) + .context("Failed to decode x-dioxus-data header from base64")?; + + let mut file_dialog: FileDialogRequest = serde_json::from_slice(&data_from_header) + .context("Failed to parse x-dioxus-data header as JSON")?; + + let file_list = file_dialog.get_file_event(); + + let position_of_entry = file_dialog + .values + .iter() + .position(|x| x.key == file_dialog.target_name) + .context("Failed to find target_name in values")?; + + // Remove any existing entries + file_dialog + .values + .retain(|x| x.key != file_dialog.target_name); + + // And then insert the new ones + for path in file_list { + let file = std::fs::metadata(&path).context("Failed to get file metadata")?; + file_dialog.values.insert( + position_of_entry, + SerializedFormObject { + key: file_dialog.target_name.clone(), + text: None, + file: Some(SerializedFileData { + size: file.len(), + last_modified: file + .modified() + .context("Failed to get file modified time")? + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default() as _, + content_type: Some( + dioxus_asset_resolver::native::get_mime_from_ext( + path.extension().and_then(|s| s.to_str()), + ) + .to_string(), + ), + contents: Default::default(), + path, + }), + }, + ); + } + + // And then respond with the updated file dialog + let response_data = serde_json::to_vec(&file_dialog) + .context("Failed to serialize FileDialogRequest to JSON")?; + + responder.respond( + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(response_data) + .context("Failed to build response")?, + ); + + Ok(()) +} diff --git a/packages/desktop/src/webview.rs b/packages/desktop/src/webview.rs index 456e217d5..481f0d2ba 100644 --- a/packages/desktop/src/webview.rs +++ b/packages/desktop/src/webview.rs @@ -1,5 +1,4 @@ -use crate::element::DesktopElement; -use crate::file_upload::DesktopFileDragEvent; +use crate::file_upload::{DesktopFileData, DesktopFileDragEvent}; use crate::menubar::DioxusMenu; use crate::PendingDesktopContext; use crate::WindowCloseBehaviour; @@ -9,12 +8,13 @@ use crate::{ DesktopContext, DesktopService, }; use crate::{document::DesktopDocument, WeakDesktopContext}; +use crate::{element::DesktopElement, file_upload::DesktopFormData}; use base64::prelude::BASE64_STANDARD; use dioxus_core::{consume_context, provide_context, Runtime, ScopeId, VirtualDom}; use dioxus_document::Document; use dioxus_history::{History, MemoryHistory}; use dioxus_hooks::to_owned; -use dioxus_html::{HtmlEvent, PlatformEventData}; +use dioxus_html::{FileData, FormValue, HtmlEvent, PlatformEventData}; use futures_util::{pin_mut, FutureExt}; use std::sync::atomic::AtomicBool; use std::{cell::OnceCell, time::Duration}; @@ -115,7 +115,7 @@ impl WebviewEdits { let desktop_context = desktop_context.upgrade().unwrap(); let query = desktop_context.query.clone(); - let recent_file = desktop_context.file_hover.clone(); + let hovered_file = desktop_context.file_hover.clone(); // check for a mounted event placeholder and replace it with a desktop specific element let as_any = match data { @@ -123,9 +123,39 @@ impl WebviewEdits { let element = DesktopElement::new(element, desktop_context.clone(), query.clone()); Rc::new(PlatformEventData::new(Box::new(element))) } + dioxus_html::EventData::Form(form) => { + Rc::new(PlatformEventData::new(Box::new(DesktopFormData { + value: form.value, + valid: form.valid, + values: form + .values + .into_iter() + .map(|obj| { + if let Some(text) = obj.text { + return (obj.key, FormValue::Text(text)); + } + + if let Some(file_data) = obj.file { + if file_data.path.capacity() == 0 { + return (obj.key, FormValue::File(None)); + } + + return ( + obj.key, + FormValue::File(Some(FileData::new(DesktopFileData( + file_data.path, + )))), + ); + }; + + (obj.key, FormValue::Text(String::new())) + }) + .collect(), + }))) + } dioxus_html::EventData::Drag(ref drag) => { // we want to override this with a native file engine, provided by the most recent drag event - let file_event = recent_file.current().unwrap(); + let file_event = hovered_file.current().unwrap(); let paths = match file_event { wry::DragDropEvent::Enter { paths, .. } => paths, wry::DragDropEvent::Drop { paths, .. } => paths, diff --git a/packages/fullstack-core/src/error.rs b/packages/fullstack-core/src/error.rs index 7660c3516..5781eaf6b 100644 --- a/packages/fullstack-core/src/error.rs +++ b/packages/fullstack-core/src/error.rs @@ -1,4 +1,5 @@ use axum_core::response::IntoResponse; +use futures_util::TryStreamExt; use http::StatusCode; use serde::{Deserialize, Serialize}; use std::fmt::Debug; @@ -91,6 +92,28 @@ impl ServerFnError { code: None, } } + + /// Create a new server error (status code 500) with a message and details. + pub async fn from_axum_response(resp: axum_core::response::Response) -> Self { + let status = resp.status(); + let message = resp + .into_body() + .into_data_stream() + .try_fold(Vec::new(), |mut acc, chunk| async move { + acc.extend_from_slice(&chunk); + Ok(acc) + }) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_else(|| status.canonical_reason().unwrap_or("").to_string()); + + ServerFnError::ServerError { + message, + code: Some(status.as_u16()), + details: None, + } + } } impl From for ServerFnError { @@ -159,7 +182,64 @@ impl From for ServerFnError { impl IntoResponse for ServerFnError { fn into_response(self) -> axum_core::response::Response { - todo!() + match self { + Self::ServerError { + message, + code, + details, + } => { + let status = code + .and_then(|c| StatusCode::from_u16(c).ok()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let body = if let Some(details) = details { + serde_json::json!({ + "error": message, + "details": details, + }) + } else { + serde_json::json!({ + "error": message, + }) + }; + let body = axum_core::body::Body::from( + serde_json::to_string(&body) + .unwrap_or_else(|_| "{\"error\":\"Internal Server Error\"}".to_string()), + ); + axum_core::response::Response::builder() + .status(status) + .header("Content-Type", "application/json") + .body(body) + .unwrap_or_else(|_| { + axum_core::response::Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(axum_core::body::Body::from( + "{\"error\":\"Internal Server Error\"}", + )) + .unwrap() + }) + } + _ => { + let status: StatusCode = self.clone().into(); + let body = axum_core::body::Body::from( + serde_json::json!({ + "error": self.to_string(), + }) + .to_string(), + ); + axum_core::response::Response::builder() + .status(status) + .header("Content-Type", "application/json") + .body(body) + .unwrap_or_else(|_| { + axum_core::response::Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(axum_core::body::Body::from( + "{\"error\":\"Internal Server Error\"}", + )) + .unwrap() + }) + } + } } } diff --git a/packages/fullstack-core/src/streaming.rs b/packages/fullstack-core/src/streaming.rs index eebcaee57..19164b6a3 100644 --- a/packages/fullstack-core/src/streaming.rs +++ b/packages/fullstack-core/src/streaming.rs @@ -1,5 +1,9 @@ +use crate::ServerFnError; +use axum_core::{extract::FromRequest, response::IntoResponse}; use dioxus_core::try_consume_context; use dioxus_signals::{ReadableExt, Signal, WritableExt}; +use http::request::Parts; +use std::{cell::RefCell, rc::Rc}; /// The status of the streaming response #[derive(Clone, Copy, Debug, PartialEq)] @@ -13,23 +17,26 @@ pub enum StreamingStatus { } /// The context dioxus fullstack provides for the status of streaming responses on the server -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct StreamingContext { current_status: Signal, + request_headers: Rc>, } -impl Default for StreamingContext { - fn default() -> Self { - Self::new() +impl PartialEq for StreamingContext { + fn eq(&self, other: &Self) -> bool { + self.current_status == other.current_status + && Rc::ptr_eq(&self.request_headers, &other.request_headers) } } impl StreamingContext { /// Create a new streaming context. You should not need to call this directly. Dioxus fullstack will /// provide this context for you. - pub fn new() -> Self { + pub fn new(parts: Parts) -> Self { Self { current_status: Signal::new(StreamingStatus::RenderingInitialChunk), + request_headers: Rc::new(RefCell::new(parts)), } } @@ -48,6 +55,42 @@ impl StreamingContext { pub fn current_status(&self) -> StreamingStatus { *self.current_status.read() } + + /// Access the http request parts mutably. This will allow you to modify headers and other parts of the request. + pub fn parts_mut(&self) -> std::cell::RefMut<'_, http::request::Parts> { + self.request_headers.borrow_mut() + } + + /// Extract an axum extractor from the current request. This will always use an empty body for the request, + /// since it's assumed that rendering the app is done under a `GET` request. + pub async fn extract, M>() -> Result { + let this = Self::current() + .ok_or_else(|| ServerFnError::new("No StreamingContext found".to_string()))?; + + let parts = this.request_headers.borrow_mut().clone(); + let request = + axum_core::extract::Request::from_parts(parts, axum_core::body::Body::empty()); + match T::from_request(request, &()).await { + Ok(res) => Ok(res), + Err(err) => { + let resp = err.into_response(); + Err(ServerFnError::from_axum_response(resp).await) + } + } + } + + /// Get the current `StreamingContext` if it exists. This will return `None` if called on the client + /// or outside of a streaming response on the server. + pub fn current() -> Option { + if let Some(rt) = dioxus_core::Runtime::try_current() { + let id = rt.try_current_scope_id()?; + if let Some(ctx) = rt.consume_context::(id) { + return Some(ctx); + } + } + + None + } } /// Commit the initial chunk of the response. This will be called automatically if you are using the diff --git a/packages/fullstack-macro/src/lib.rs b/packages/fullstack-macro/src/lib.rs index 855e8a039..ef160fce8 100644 --- a/packages/fullstack-macro/src/lib.rs +++ b/packages/fullstack-macro/src/lib.rs @@ -305,6 +305,14 @@ fn route_impl_with_route( }) .collect::>(); + let server_types = server_args + .iter() + .map(|pat_type| match pat_type { + FnArg::Receiver(_) => parse_quote! { () }, + FnArg::Typed(pat_type) => (*pat_type.ty).clone(), + }) + .collect::>(); + let body_struct_impl = { let tys = body_json_types .iter() @@ -334,28 +342,13 @@ fn route_impl_with_route( }; // there's no active request on the server, so we just create a dummy one - let server_defaults = server_args.iter().map(|arg| { - let name = match arg { - FnArg::Receiver(_) => panic!("Server args cannot be receiver"), - FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { - Pat::Ident(pat_ident) => &pat_ident.ident, - _ => panic!("Expected Pat::Ident"), - }, - }; - - let ty = match arg { - FnArg::Receiver(_) => panic!("Server args cannot be receiver"), - FnArg::Typed(pat_type) => (*pat_type.ty).clone(), - }; - + let server_defaults = if server_args.is_empty() { + quote! {} + } else { quote! { - let #name = { - use dioxus_fullstack::axum::extract::FromRequest; - let __request = dioxus_fullstack::axum::extract::Request::new(dioxus_fullstack::axum::body::Body::empty()); - <#ty as FromRequest<_, _>>::from_request(__request, &()).await.unwrap() - }; + let (#(#server_names,)*) = dioxus_fullstack::StreamingContext::extract::<(#(#server_types,)*), _>().await?; } - }); + }; let as_axum_path = route.to_axum_path_string(); @@ -519,7 +512,7 @@ fn route_impl_with_route( ) } - #(#server_defaults)* + #server_defaults return #fn_name #ty_generics( #(#extracted_idents,)* @@ -1214,6 +1207,15 @@ impl PathParam { ty, Brace(span), ) + } else if str.starts_with(':') && str.len() > 1 { + let str = str.strip_prefix(':').unwrap(); + Self::Capture( + LitStr::new(str, span), + Brace(span), + Ident::new(str, span), + ty, + Brace(span), + ) } else { Self::Static(LitStr::new(str, span)) }; diff --git a/packages/fullstack-server/src/server.rs b/packages/fullstack-server/src/server.rs index 97f77ce89..344adbbd7 100644 --- a/packages/fullstack-server/src/server.rs +++ b/packages/fullstack-server/src/server.rs @@ -13,7 +13,7 @@ use axum::{ }; use dioxus_core::{Element, VirtualDom}; use futures::Stream; -use http::header::*; +use http::{header::*, request::Parts}; use std::path::Path; use std::sync::Arc; use tower::util::MapResponse; @@ -297,13 +297,8 @@ impl RenderHandleState { }; let (parts, _) = request.into_parts(); - let url = parts - .uri - .path_and_query() - .ok_or(StatusCode::BAD_REQUEST)? - .to_string(); - match state.render(url, cfg, build_virtual_dom).await { + match state.render(parts, cfg, build_virtual_dom).await { Ok((freshness, rx)) => { let mut response = axum::response::Html::from(Body::from_stream(rx)).into_response(); @@ -338,7 +333,7 @@ impl RenderHandleState { /// Render the application to HTML. pub async fn render<'a>( &'a self, - route: String, + parts: Parts, cfg: &'a ServeConfig, virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, ) -> Result< @@ -350,7 +345,7 @@ impl RenderHandleState { > { self.renderers .clone() - .render_to(cfg, route, virtual_dom_factory) + .render_to(parts, cfg, virtual_dom_factory) .await } } diff --git a/packages/fullstack-server/src/ssr.rs b/packages/fullstack-server/src/ssr.rs index 3aabb791b..bcd4ebce2 100644 --- a/packages/fullstack-server/src/ssr.rs +++ b/packages/fullstack-server/src/ssr.rs @@ -17,7 +17,7 @@ use dioxus_router::ParseRouteError; use dioxus_ssr::Renderer; use futures_channel::mpsc::Sender; use futures_util::{Stream, StreamExt}; -use http::StatusCode; +use http::{request::Parts, StatusCode}; use std::{ collections::HashMap, fmt::Write, @@ -98,8 +98,8 @@ impl SsrRendererPool { /// The streaming is canceled when the stream the function returns is dropped pub(crate) async fn render_to( self: Arc, + parts: Parts, cfg: &ServeConfig, - route: String, virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, ) -> Result< ( @@ -133,6 +133,15 @@ impl SsrRendererPool { } } + let route = parts + .uri + .path_and_query() + .ok_or_else(|| SSRError::HttpError { + status: StatusCode::BAD_REQUEST, + message: None, + })? + .to_string(); + let (mut into, rx) = futures_channel::mpsc::channel::>(1000); @@ -177,9 +186,10 @@ impl SsrRendererPool { }; // Provide the document and streaming context to the root of the app - let streaming_context = virtual_dom.in_scope(ScopeId::ROOT, StreamingContext::new); + let streaming_context = + virtual_dom.in_scope(ScopeId::ROOT, || StreamingContext::new(parts)); virtual_dom.provide_root_context(document.clone() as Rc); - virtual_dom.provide_root_context(streaming_context); + virtual_dom.provide_root_context(streaming_context.clone()); virtual_dom.in_scope(ScopeId::ROOT, || { // Wrap the memory history in a fullstack history provider to provide the initial route for hydration diff --git a/packages/fullstack/Cargo.toml b/packages/fullstack/Cargo.toml index 6ed89471d..0060fa726 100644 --- a/packages/fullstack/Cargo.toml +++ b/packages/fullstack/Cargo.toml @@ -37,7 +37,7 @@ serde_json = { workspace = true } pin-project = { workspace = true } serde_qs = { workspace = true, default-features = true} mime = "0.3.17" -reqwest = { workspace = true, features = ["json", "rustls-tls", "cookies", "stream"] } +reqwest = { workspace = true, features = ["json", "rustls-tls", "cookies", "stream", "multipart"] } url = { workspace = true } serde_urlencoded = { workspace = true } form_urlencoded = { workspace = true } diff --git a/packages/fullstack/src/client.rs b/packages/fullstack/src/client.rs index 6f50c64ac..48ae4076e 100644 --- a/packages/fullstack/src/client.rs +++ b/packages/fullstack/src/client.rs @@ -161,6 +161,49 @@ impl ClientRequest { builder } + /// Sends the request with multipart/form-data body constructed from the given FormData. + #[cfg(not(target_arch = "wasm32"))] + pub async fn send_multipart( + self, + form: &dioxus_html::FormData, + ) -> Result { + let mut outgoing = reqwest::multipart::Form::new(); + + for (key, value) in form.values() { + match value { + dioxus_html::FormValue::Text(text) => { + outgoing = outgoing.text(key.to_string(), text.to_string()); + } + dioxus_html::FormValue::File(Some(file_data)) => { + outgoing = outgoing + .file(key.to_string(), file_data.path()) + .await + .map_err(|e| { + RequestError::Builder(format!( + "Failed to add file to multipart form: {e}", + )) + })?; + } + dioxus_html::FormValue::File(None) => { + // No file was selected for this input, so we skip it. + outgoing = outgoing.part(key.to_string(), reqwest::multipart::Part::bytes(b"")); + } + } + } + + let res = self + .new_reqwest_request() + .multipart(outgoing) + .send() + .await + .map_err(reqwest_error_to_request_error)?; + + Ok(ClientResponse { + response: Box::new(res), + extensions: self.extensions, + }) + } + pub async fn send_form(self, data: &impl Serialize) -> Result { // For GET and HEAD requests, we encode the form data as query parameters. // For other request methods, we encode the form data as the request body. diff --git a/packages/fullstack/src/payloads/multipart.rs b/packages/fullstack/src/payloads/multipart.rs index 54fe163e8..97ee83701 100644 --- a/packages/fullstack/src/payloads/multipart.rs +++ b/packages/fullstack/src/payloads/multipart.rs @@ -95,7 +95,11 @@ impl IntoRequest for MultipartFormData { // and construct a multipart form body manually. #[cfg(not(target_arch = "wasm32"))] { - todo!("Properly implement multipart form data for non-web wasm32 clients"); + let data = self._client.clone().ok_or_else(|| { + RequestError::Builder("Failed to get FormData from event".into()) + })?; + + return _req.send_multipart(&data).await; } unimplemented!("Non web wasm32 clients are not supported yet") diff --git a/packages/html/src/events/form.rs b/packages/html/src/events/form.rs index b405829d6..4ce8ff45b 100644 --- a/packages/html/src/events/form.rs +++ b/packages/html/src/events/form.rs @@ -1,77 +1,16 @@ use crate::file_data::HasFileData; use crate::FileData; -use std::{collections::HashMap, fmt::Debug, ops::Deref}; - -#[cfg(feature = "serialize")] -use crate::SerializedFileData; +use std::fmt::Debug; use dioxus_core::Event; pub type FormEvent = Event; -/// A form value that may either be a list of values or a single value -#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Default, Clone, PartialEq)] -pub struct FormValue(pub Vec); - -impl Deref for FormValue { - type Target = [String]; - - fn deref(&self) -> &Self::Target { - self.as_slice() - } -} - -impl FormValue { - /// Convenient way to represent Value as slice - pub fn as_slice(&self) -> &[String] { - &self.0 - } - - /// Return the first value, panicking if there are none - pub fn as_value(&self) -> String { - self.0.first().unwrap().clone() - } - - /// Convert into [`Vec`] - pub fn to_vec(self) -> Vec { - self.0.clone() - } -} - -impl PartialEq for FormValue { - fn eq(&self, other: &str) -> bool { - self.0.len() == 1 && self.0.first().map(|s| s.as_str()) == Some(other) - } -} - /* DOMEvent: Send + SyncTarget relatedTarget */ pub struct FormData { inner: Box, } -impl From for FormData { - fn from(e: E) -> Self { - Self { inner: Box::new(e) } - } -} - -impl PartialEq for FormData { - fn eq(&self, other: &Self) -> bool { - self.value() == other.value() && self.values() == other.values() - } -} - -impl Debug for FormData { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FormEvent") - .field("value", &self.value()) - .field("values", &self.values()) - .field("valid", &self.valid()) - .finish() - } -} - impl FormData { /// Create a new form event pub fn new(event: impl HasFormData + 'static) -> Self { @@ -104,10 +43,25 @@ impl FormData { /// Collect all the named form values from the containing form. /// /// Every input must be named! - pub fn values(&self) -> HashMap { + pub fn values(&self) -> Vec<(String, FormValue)> { self.inner.values() } + /// Get the first value with the given name + pub fn get_first(&self, name: &str) -> Option { + self.values() + .into_iter() + .find_map(|(k, v)| if k == name { Some(v) } else { None }) + } + + /// Get all values with the given name + pub fn get(&self, name: &str) -> Vec { + self.values() + .into_iter() + .filter_map(|(k, v)| if k == name { Some(v) } else { None }) + .collect() + } + /// Get the files of the form event pub fn files(&self) -> Vec { self.inner.files() @@ -125,146 +79,69 @@ impl FormData { } } -/// An object that has all the data for a form event -pub trait HasFormData: HasFileData + std::any::Any { - fn value(&self) -> String { - Default::default() - } - - fn valid(&self) -> bool { - true - } - - fn values(&self) -> HashMap { - Default::default() - } - - /// return self as Any - fn as_any(&self) -> &dyn std::any::Any; -} - impl FormData { - #[cfg(feature = "serialize")] /// Parse the values into a struct with one field per value + #[cfg(feature = "serialize")] pub fn parsed_values(&self) -> Result where T: serde::de::DeserializeOwned, { + use crate::SerializedFileData; + let values = &self.values(); let mut map = serde_json::Map::new(); for (key, value) in values { - if value.0.len() == 1 { - map.insert(key.clone(), serde_json::Value::String(value.0[0].clone())); - } else { - let arr = value - .0 - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(); - map.insert(key.clone(), serde_json::Value::Array(arr)); + let entry = map + .entry(key.clone()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + + match value { + FormValue::Text(text) => { + entry + .as_array_mut() + .expect("entry should be an array") + .push(serde_json::Value::String(text.clone())); + } + // we create the serialized variant with no bytes + // SerializedFileData, if given a real path, will read the bytes from disk (synchronously) + FormValue::File(Some(file_data)) => { + let serialized = SerializedFileData { + path: file_data.path().to_owned(), + size: file_data.size(), + last_modified: file_data.last_modified(), + content_type: file_data.content_type(), + contents: None, + }; + entry + .as_array_mut() + .expect("entry should be an array") + .push(serde_json::to_value(&serialized).unwrap_or(serde_json::Value::Null)); + } + FormValue::File(None) => { + entry + .as_array_mut() + .expect("entry should be an array") + .push( + serde_json::to_value(SerializedFileData::empty()) + .unwrap_or(serde_json::Value::Null), + ); + } } } - serde_json::from_value(serde_json::Value::Object(map)) - } -} - -#[cfg(feature = "serialize")] -/// A serialized form data object -#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] -pub struct SerializedFormData { - #[serde(default)] - value: String, - - #[serde(default)] - values: HashMap, - - #[serde(default)] - valid: bool, - - #[serde(default)] - files: Vec, -} - -#[cfg(feature = "serialize")] -impl SerializedFormData { - /// Create a new serialized form data object - pub fn new(value: String, values: HashMap) -> Self { - Self { - value, - values, - valid: true, - files: vec![], - } - } - - /// Add files to the serialized form data object - pub fn with_files(mut self, files: Vec) -> Self { - self.files = files; - self - } - - /// Create a new serialized form data object from a traditional form data object - pub async fn async_from(data: &FormData) -> Result { - Ok(Self { - value: data.value(), - values: data.values(), - valid: data.valid(), - files: { - let mut out = vec![]; - for file in data.files() { - out.push(SerializedFileData { - name: file.name(), - size: file.size(), - content_type: file.content_type(), - last_modified: file.last_modified(), - path: file.pathbuf(), - contents: file.read_bytes().await?, - }); + // Go through the map and convert single-element arrays to just the element + let map = map + .into_iter() + .map(|(k, v)| match v { + serde_json::Value::Array(arr) if arr.len() == 1 => { + (k, arr.into_iter().next().unwrap()) } - out - }, - }) - } + _ => (k, v), + }) + .collect::>(); - fn from_lossy(data: &FormData) -> Self { - Self { - value: data.value(), - values: data.values(), - valid: data.valid(), - files: vec![], - } - } -} - -#[cfg(feature = "serialize")] -impl HasFormData for SerializedFormData { - fn value(&self) -> String { - self.value.clone() - } - - fn values(&self) -> HashMap { - self.values.clone() - } - - fn valid(&self) -> bool { - self.valid - } - - fn as_any(&self) -> &dyn std::any::Any { - self - } -} - -#[cfg(feature = "serialize")] -impl HasFileData for SerializedFormData { - fn files(&self) -> Vec { - self.files - .iter() - .map(|f| FileData::new(f.clone())) - .collect() + serde_json::from_value(serde_json::Value::Object(map)) } } @@ -274,20 +151,186 @@ impl HasFileData for FormData { } } -#[cfg(feature = "serialize")] -impl serde::Serialize for FormData { - fn serialize(&self, serializer: S) -> Result { - SerializedFormData::from_lossy(self).serialize(serializer) +impl From for FormData { + fn from(e: E) -> Self { + Self { inner: Box::new(e) } } } +impl PartialEq for FormData { + fn eq(&self, other: &Self) -> bool { + self.value() == other.value() && self.values() == other.values() + } +} + +impl Debug for FormData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FormEvent") + .field("value", &self.value()) + .field("values", &self.values()) + .field("valid", &self.valid()) + .finish() + } +} + +/// A value in a form, either text or a file +#[derive(Debug, Clone, PartialEq)] +pub enum FormValue { + Text(String), + File(Option), +} + +impl PartialEq for FormValue { + fn eq(&self, other: &str) -> bool { + match self { + FormValue::Text(s) => s == other, + FormValue::File(_f) => false, + } + } +} + +impl PartialEq<&str> for FormValue { + fn eq(&self, other: &&str) -> bool { + match self { + FormValue::Text(s) => s == other, + FormValue::File(_f) => false, + } + } +} + +/// An object that has all the data for a form event +pub trait HasFormData: HasFileData + std::any::Any { + fn value(&self) -> String; + + fn valid(&self) -> bool; + + fn values(&self) -> Vec<(String, FormValue)>; + + /// return self as Any + fn as_any(&self) -> &dyn std::any::Any; +} + #[cfg(feature = "serialize")] -impl<'de> serde::Deserialize<'de> for FormData { - fn deserialize>(deserializer: D) -> Result { - let data = SerializedFormData::deserialize(deserializer)?; - Ok(Self { - inner: Box::new(data), - }) +pub use serialize::*; + +#[cfg(feature = "serialize")] +mod serialize { + use crate::SerializedFileData; + + use super::*; + + /// A serialized form data object + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] + pub struct SerializedFormData { + #[serde(default)] + pub value: String, + + #[serde(default)] + pub values: Vec, + + #[serde(default)] + pub valid: bool, + } + + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] + pub struct SerializedFormObject { + pub key: String, + pub text: Option, + pub file: Option, + } + + #[cfg(feature = "serialize")] + impl SerializedFormData { + /// Create a new serialized form data object + pub fn new(value: String, values: Vec) -> Self { + Self { + value, + values, + valid: true, + } + } + + /// Create a serialized form data object from a form data object + fn from_form_lossy(data: &FormData) -> Self { + if let Some(data) = data.downcast::() { + return data.clone(); + } + + let values = data + .values() + .iter() + .map(|(key, value)| match value { + FormValue::Text(s) => SerializedFormObject { + key: key.clone(), + text: Some(s.to_string()), + file: None, + }, + FormValue::File(f) => SerializedFormObject { + key: key.clone(), + text: None, + file: if let Some(f) = f { + Some(SerializedFileData { + path: f.path(), + size: f.size(), + last_modified: f.last_modified(), + content_type: f.content_type(), + contents: None, + }) + } else { + Some(SerializedFileData::empty()) + }, + }, + }) + .collect(); + + Self { + values, + value: data.value(), + valid: data.valid(), + } + } + } + + impl HasFormData for SerializedFormData { + fn value(&self) -> String { + self.value.clone() + } + + fn values(&self) -> Vec<(String, FormValue)> { + todo!() + } + + fn valid(&self) -> bool { + self.valid + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + } + + impl HasFileData for SerializedFormData { + fn files(&self) -> Vec { + self.values + .iter() + .filter_map(|v| v.file.as_ref().map(|f| FileData::new(f.clone()))) + .collect() + } + } + + impl serde::Serialize for FormData { + fn serialize(&self, serializer: S) -> Result { + SerializedFormData::from_form_lossy(self).serialize(serializer) + } + } + + impl<'de> serde::Deserialize<'de> for FormData { + fn deserialize>(deserializer: D) -> Result { + let data = SerializedFormData::deserialize(deserializer)?; + Ok(Self { + inner: Box::new(data), + }) + } } } diff --git a/packages/html/src/file_data.rs b/packages/html/src/file_data.rs index 7af8da924..b5e5b5339 100644 --- a/packages/html/src/file_data.rs +++ b/packages/html/src/file_data.rs @@ -1,7 +1,6 @@ -use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future}; - use bytes::Bytes; use futures_util::Stream; +use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future}; #[derive(Clone)] pub struct FileData { @@ -49,11 +48,20 @@ impl FileData { self.inner.inner() } - pub fn pathbuf(&self) -> PathBuf { + pub fn path(&self) -> PathBuf { self.inner.path() } } +impl PartialEq for FileData { + fn eq(&self, other: &Self) -> bool { + self.name() == other.name() + && self.size() == other.size() + && self.last_modified() == other.last_modified() + && self.path() == other.path() + } +} + pub trait NativeFileData: Send + Sync { fn name(&self) -> String; fn size(&self) -> u64; @@ -82,75 +90,141 @@ impl std::fmt::Debug for FileData { } } -impl std::cmp::PartialEq for FileData { - fn eq(&self, other: &Self) -> bool { - self.inner.name() == other.inner.name() - && self.inner.size() == other.inner.size() - && self.inner.last_modified() == other.inner.last_modified() - } -} - pub trait HasFileData: std::any::Any { fn files(&self) -> Vec; } -/// A serializable representation of file data\ #[cfg(feature = "serialize")] -#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] -pub struct SerializedFileData { - pub name: String, - pub path: PathBuf, - pub size: u64, - pub last_modified: u64, - pub content_type: Option, - pub contents: bytes::Bytes, -} +pub use serialize::*; #[cfg(feature = "serialize")] -impl NativeFileData for SerializedFileData { - fn name(&self) -> String { - self.name.clone() +mod serialize { + use super::*; + + /// A serializable representation of file data + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] + pub struct SerializedFileData { + pub path: PathBuf, + pub size: u64, + pub last_modified: u64, + pub content_type: Option, + pub contents: Option, } - fn size(&self) -> u64 { - self.size + impl SerializedFileData { + /// Create a new empty serialized file data object + pub fn empty() -> Self { + Self { + path: PathBuf::new(), + size: 0, + last_modified: 0, + content_type: None, + contents: None, + } + } } - fn last_modified(&self) -> u64 { - self.last_modified + impl NativeFileData for SerializedFileData { + fn name(&self) -> String { + self.path() + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + } + + fn size(&self) -> u64 { + self.size + } + + fn last_modified(&self) -> u64 { + self.last_modified + } + + fn read_bytes( + &self, + ) -> Pin> + 'static>> { + let contents = self.contents.clone(); + let path = self.path.clone(); + + Box::pin(async move { + if let Some(contents) = contents { + return Ok(contents); + } + + #[cfg(not(target_arch = "wasm32"))] + if path.exists() { + return Ok(std::fs::read(path).map(Bytes::from)?); + } + + Err(dioxus_core::Error::msg("File contents not available")) + }) + } + + fn read_string( + &self, + ) -> Pin> + 'static>> { + let contents = self.contents.clone(); + let path = self.path.clone(); + + Box::pin(async move { + if let Some(contents) = contents { + return Ok(String::from_utf8(contents.to_vec())?); + } + + #[cfg(not(target_arch = "wasm32"))] + if path.exists() { + return Ok(std::fs::read_to_string(path)?); + } + + Err(dioxus_core::Error::msg("File contents not available")) + }) + } + + fn byte_stream( + &self, + ) -> Pin< + Box< + dyn futures_util::Stream> + 'static + Send, + >, + > { + let contents = self.contents.clone(); + let path = self.path.clone(); + + Box::pin(futures_util::stream::once(async move { + if let Some(contents) = contents { + return Ok(contents); + } + + #[cfg(not(target_arch = "wasm32"))] + if path.exists() { + return Ok(std::fs::read(path).map(Bytes::from)?); + } + + Err(dioxus_core::Error::msg("File contents not available")) + })) + } + + fn inner(&self) -> &dyn std::any::Any { + self + } + + fn path(&self) -> PathBuf { + self.path.clone() + } + + fn content_type(&self) -> Option { + self.content_type.clone() + } } - fn read_bytes( - &self, - ) -> Pin> + 'static>> { - let contents = self.contents.clone(); - Box::pin(async move { Ok(contents) }) - } - - fn read_string( - &self, - ) -> Pin> + 'static>> { - let contents = self.contents.clone(); - Box::pin(async move { Ok(String::from_utf8(contents.to_vec())?) }) - } - - fn byte_stream( - &self, - ) -> Pin> + 'static + Send>> - { - let contents = self.contents.clone(); - Box::pin(futures_util::stream::once(async move { Ok(contents) })) - } - - fn inner(&self) -> &dyn std::any::Any { - self - } - - fn path(&self) -> PathBuf { - self.path.clone() - } - - fn content_type(&self) -> Option { - self.content_type.clone() + impl<'de> serde::Deserialize<'de> for FileData { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let sfd = SerializedFileData::deserialize(deserializer)?; + Ok(FileData::new(sfd)) + } } } diff --git a/packages/interpreter/build.rs b/packages/interpreter/build.rs index abee61a2c..1409569f0 100644 --- a/packages/interpreter/build.rs +++ b/packages/interpreter/build.rs @@ -2,7 +2,7 @@ fn main() { // If any TS files change, re-run the build script lazy_js_bundle::LazyTypeScriptBindings::new() .with_watching("./src/ts") - .with_binding("./src/ts/common.ts", "./src/js/common.js") + .with_binding("./src/ts/set_attribute.ts", "./src/js/set_attribute.js") .with_binding("./src/ts/native.ts", "./src/js/native.js") .with_binding("./src/ts/core.ts", "./src/js/core.js") .with_binding("./src/ts/hydrate.ts", "./src/js/hydrate.js") diff --git a/packages/interpreter/src/js/common.js b/packages/interpreter/src/js/common.js index 28a205686..1e08ed056 100644 --- a/packages/interpreter/src/js/common.js +++ b/packages/interpreter/src/js/common.js @@ -1 +1 @@ -function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.tagName==="OPTION")setAttributeDefault(node,field,value);else if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;case"style":let existingStyles={};for(let i=0;i{if(value instanceof File){contents[key]=[value.name];return}if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}export{setAttributeInner,retrieveFormValues}; +function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.tagName==="OPTION")setAttributeDefault(node,field,value);else if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;case"style":let existingStyles={};for(let i=0;i{if(value instanceof File){let fileData={name:value.name,path:value.webkitRelativePath,size:value.size,last_modified:value.lastModified,content_type:value.type};contents.push([key,fileData])}else contents.push([key,value])}),{valid:form.checkValidity(),values:contents}}export{setAttributeInner,retrieveFormValues}; diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 4825e8b9f..ba3f16643 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1 @@ -[6449103750905854967, 17669692872757955279, 4701826235737143966, 11420464406527728232, 3770103091118609057, 5444526391971481782, 14692042304579321130, 5052021921702764563, 7041565893588933645, 11339769846046015954] \ No newline at end of file +[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 8889858244860485542, 5052021921702764563, 5113938988622536969, 11339769846046015954] \ No newline at end of file diff --git a/packages/interpreter/src/js/native.js b/packages/interpreter/src/js/native.js index 48118876e..5316e7708 100644 --- a/packages/interpreter/src/js/native.js +++ b/packages/interpreter/src/js/native.js @@ -1 +1 @@ -function retrieveValues(event,target){let contents={values:{}},form=target.closest("form");if(form){if(event.type==="input"||event.type==="change"||event.type==="submit"||event.type==="reset"||event.type==="click")contents=retrieveFormValues(form)}return contents}function retrieveFormValues(form){let formData=new FormData(form),contents={};return formData.forEach((value,key)=>{if(value instanceof File){contents[key]=[value.name];return}if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;icontents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent!=="undefined"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent);if(event.type==="scroll")extend(serializeScrollEvent(event));return contents}function toSerializableResizeObserverSize(size,is_inline_width){return[is_inline_width?size.inlineSize:size.blockSize,is_inline_width?size.blockSize:size.inlineSize]}function serializeResizeEventDetail(detail){let is_inline_width=!0;if(detail.target instanceof HTMLElement){if(window.getComputedStyle(detail.target).getPropertyValue("writing-mode")!=="horizontal-tb")is_inline_width=!1}return{border_box_size:detail.borderBoxSize!==void 0?toSerializableResizeObserverSize(detail.borderBoxSize[0],is_inline_width):detail.contentRect,content_box_size:detail.contentBoxSize!==void 0?toSerializableResizeObserverSize(detail.contentBoxSize[0],is_inline_width):detail.contentRect,content_rect:detail.contentRect}}function serializeIntersectionEventDetail(detail){return{bounding_client_rect:detail.boundingClientRect,intersection_ratio:detail.intersectionRatio,intersection_rect:detail.intersectionRect,is_intersecting:detail.isIntersecting,root_bounds:detail.rootBounds,time_ms:Math.floor(Date.now()+detail.time)}}function serializeInputEvent(event,target){let contents={};if(target instanceof HTMLElement){let values=retrieveValues(event,target);contents.values=values.values,contents.valid=values.valid}if(event.target instanceof HTMLInputElement){let target2=event.target,value=target2.value??target2.textContent??"";if(target2.type==="checkbox")value=target2.checked?"true":"false";else if(target2.type==="radio")value=target2.value;contents.value=value}if(event.target instanceof HTMLTextAreaElement)contents.value=event.target.value;if(event.target instanceof HTMLSelectElement)contents.value=retrieveSelectValue(event.target).join(",");if(contents.value===void 0)contents.value="";return contents}function serializeWheelEvent(event){return{delta_x:event.deltaX,delta_y:event.deltaY,delta_z:event.deltaZ,delta_mode:event.deltaMode}}function serializeTouchEvent(event){return{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,changed_touches:serializeTouchList(event.changedTouches),target_touches:serializeTouchList(event.targetTouches),touches:serializeTouchList(event.touches)}}function serializePointerEvent(event){return{alt_key:event.altKey,button:event.button,buttons:event.buttons,client_x:event.clientX,client_y:event.clientY,ctrl_key:event.ctrlKey,meta_key:event.metaKey,page_x:event.pageX,page_y:event.pageY,screen_x:event.screenX,screen_y:event.screenY,shift_key:event.shiftKey,pointer_id:event.pointerId,width:event.width,height:event.height,pressure:event.pressure,tangential_pressure:event.tangentialPressure,tilt_x:event.tiltX,tilt_y:event.tiltY,twist:event.twist,pointer_type:event.pointerType,is_primary:event.isPrimary}}function serializeTouchList(touchList){let serializedTouches=[];for(let i=0;i0)files={files:{placeholder:[]}};return{mouse:{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,...serializeMouseEvent(event)},files}}function serializeScrollEvent(event){let scrollLeft=0,scrollTop=0,scrollWidth=0,scrollHeight=0,clientWidth=0,clientHeight=0;if(event.target instanceof Element)scrollLeft=event.target.scrollLeft,scrollTop=event.target.scrollTop,scrollWidth=event.target.scrollWidth,scrollHeight=event.target.scrollHeight,clientWidth=event.target.clientWidth,clientHeight=event.target.clientHeight;else if(event.target===document)scrollLeft=window.scrollX||document.documentElement.scrollLeft,scrollTop=window.scrollY||document.documentElement.scrollTop,scrollWidth=document.documentElement.scrollWidth,scrollHeight=document.documentElement.scrollHeight,clientWidth=document.documentElement.clientWidth,clientHeight=document.documentElement.clientHeight;return{scroll_left:scrollLeft,scroll_top:scrollTop,scroll_width:scrollWidth,scroll_height:scrollHeight,client_width:clientWidth,client_height:clientHeight}}var JSChannel_;if(RawInterpreter!==void 0&&RawInterpreter!==null)JSChannel_=RawInterpreter;class NativeInterpreter extends JSChannel_{intercept_link_redirects;ipc;edits;eventsPath;headless;kickStylesheets;queuedBytes=[];liveview;constructor(eventsPath,headless){super();this.eventsPath=eventsPath,this.kickStylesheets=!1,this.headless=headless}initialize(root){this.intercept_link_redirects=!0,this.liveview=!1,window.addEventListener("dragover",function(e){if(e.target instanceof Element&&e.target.tagName!="INPUT")e.preventDefault()},!1),window.addEventListener("drop",function(e){if(!(e.target instanceof Element))return;e.preventDefault()},!1),window.addEventListener("click",(event)=>{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){let message=this.serializeIpcMessage("file_dialog",{event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles});this.ipc.postMessage(message),event.preventDefault()}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,!0);super.initialize(root,handler)}serializeIpcMessage(method,params={}){return JSON.stringify({method,params})}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;icontents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent<"u"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent){let files=[];if(event.dataTransfer&&event.dataTransfer.files)for(let i=0;i0)files={files:{placeholder:[]}};return{mouse:{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,...serializeMouseEvent(event)},files}}function serializeScrollEvent(event){let scrollLeft=0,scrollTop=0,scrollWidth=0,scrollHeight=0,clientWidth=0,clientHeight=0;if(event.target instanceof Element)scrollLeft=event.target.scrollLeft,scrollTop=event.target.scrollTop,scrollWidth=event.target.scrollWidth,scrollHeight=event.target.scrollHeight,clientWidth=event.target.clientWidth,clientHeight=event.target.clientHeight;else if(event.target===document)scrollLeft=window.scrollX||document.documentElement.scrollLeft,scrollTop=window.scrollY||document.documentElement.scrollTop,scrollWidth=document.documentElement.scrollWidth,scrollHeight=document.documentElement.scrollHeight,clientWidth=document.documentElement.clientWidth,clientHeight=document.documentElement.clientHeight;return{scroll_left:scrollLeft,scroll_top:scrollTop,scroll_width:scrollWidth,scroll_height:scrollHeight,client_width:clientWidth,client_height:clientHeight}}function extractSerializedFormValues(event,target){let contents={values:[]},form=target.closest("form");if(form){if(event.type==="input"||event.type==="change"||event.type==="submit"||event.type==="reset"||event.type==="click")contents=retrieveFormValues(form)}return contents}function retrieveFormValues(form){let formData=new FormData(form),contents=[];return formData.forEach((value,key)=>{if(value instanceof File){let fileData={path:value.name,size:value.size,last_modified:value.lastModified,content_type:value.type};contents.push({key,file:fileData})}else contents.push({key,text:value})}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;i{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){event.preventDefault();let contents=serializeEvent(event,target),target_name=target.getAttribute("name")||"",requestData={event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles,target_name,values:contents.values};this.fetchAgainstHost("__file_dialog",requestData).then((response)=>response.json()).then((resp)=>{let formObjects=resp.values,dataTransfer=new DataTransfer;for(let formObject of formObjects)if(formObject.key==target_name&&formObject.file!=null){let file=new File([],formObject.file.path,{type:formObject.file.content_type,lastModified:formObject.file.last_modified});dataTransfer.items.add(file)}target.files=dataTransfer.files;let body={data:contents,element:target_id,bubbles:event.bubbles};contents.values=formObjects,this.sendSerializedEvent({...body,name:"input"}),this.sendSerializedEvent({...body,name:"change"})});return}}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,!0);super.initialize(root,handler)}fetchAgainstHost(path,data){let encoded_data=new TextEncoder().encode(JSON.stringify(data)),base64data=btoa(String.fromCharCode.apply(null,Array.from(encoded_data)));return fetch(`${this.baseUri}/${path}`,{method:"GET",headers:{"x-dioxus-data":base64data}})}sendIpcMessage(method,params={}){let body=JSON.stringify({method,params});this.ipc.postMessage(body)}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;i); - - /// Roll up all the values from the node into a JS object that we can deserialize - pub fn retrieveFormValues(node: JsValue) -> JsValue; } #[wasm_bindgen(module = "/src/js/hydrate.js")] diff --git a/packages/interpreter/src/ts/common.ts b/packages/interpreter/src/ts/common.ts deleted file mode 100644 index 643f36436..000000000 --- a/packages/interpreter/src/ts/common.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { setAttributeInner } from "./set_attribute"; -export { retrieveFormValues } from "./form"; diff --git a/packages/interpreter/src/ts/form.ts b/packages/interpreter/src/ts/form.ts deleted file mode 100644 index ac7fb0288..000000000 --- a/packages/interpreter/src/ts/form.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Consistently deserialize forms and form elements for use across web/desktop/mobile - -type FormValues = { - valid?: boolean; - values: { [key: string]: FormDataEntryValue[] }; -} - -export function retrieveValues(event: Event, target: HTMLElement): FormValues { - let contents: FormValues = { - values: {} - }; - - // If there's a form... - let form = target.closest("form"); - - // If the target is an input, and the event is input or change, we want to get the value without going through the form - if (form) { - if ( - event.type === "input" - || event.type === "change" - || event.type === "submit" - || event.type === "reset" - || event.type === "click" - ) { - contents = retrieveFormValues(form); - } - } - - return contents; -} - -// todo: maybe encode spaces or something? -// We encode select multiple as a comma separated list which breaks... when there's commas in the values -export function retrieveFormValues(form: HTMLFormElement): FormValues { - const formData = new FormData(form); - const contents: { [key: string]: FormDataEntryValue[] } = {}; - formData.forEach((value, key) => { - if (value instanceof File) { - contents[key] = [value.name]; - return; - } - - if (contents[key]) { - contents[key].push(value); - } else { - contents[key] = [value]; - } - }); - return { - valid: form.checkValidity(), - values: contents - }; -} - -export function retrieveSelectValue(target: HTMLSelectElement): string[] { - // there might be multiple... - let options = target.selectedOptions; - let values = []; - for (let i = 0; i < options.length; i++) { - values.push(options[i].value); - } - return values; -} diff --git a/packages/interpreter/src/ts/native.ts b/packages/interpreter/src/ts/native.ts index 253917c1d..9b3f21ad6 100644 --- a/packages/interpreter/src/ts/native.ts +++ b/packages/interpreter/src/ts/native.ts @@ -4,7 +4,7 @@ // provide since it doesn't have access to the dom. import { BaseInterpreter, NodeId } from "./core"; -import { SerializedEvent, serializeEvent } from "./serialize"; +import { SerializedEvent, serializeEvent, SerializedFileData, extractSerializedFormValues, SerializedFormObject } from "./serialize"; // okay so, we've got this JSChannel thing from sledgehammer, implicitly imported into our scope // we want to extend it, and it technically extends base interpreter. To make typescript happy, @@ -21,6 +21,7 @@ export class NativeInterpreter extends JSChannel_ { intercept_link_redirects: boolean; ipc: any; edits: WebSocket; + baseUri: string; eventsPath: string; headless: boolean; kickStylesheets: boolean; @@ -30,9 +31,10 @@ export class NativeInterpreter extends JSChannel_ { // however, for now we need to support it since WebSockets in fullstack doesn't exist yet liveview: boolean; - constructor(eventsPath: string, headless: boolean) { + constructor(baseUri: string, headless: boolean) { super(); - this.eventsPath = eventsPath; + this.baseUri = baseUri; + this.eventsPath = `${baseUri}/__events`; this.kickStylesheets = false; this.headless = headless; } @@ -79,20 +81,76 @@ export class NativeInterpreter extends JSChannel_ { // Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it let target_id = getTargetId(target); if (target_id !== null) { - const message = this.serializeIpcMessage("file_dialog", { - event: "change&input", - accept: target.getAttribute("accept"), - directory: target.getAttribute("webkitdirectory") === "true", - multiple: target.hasAttribute("multiple"), - target: target_id, - bubbles: event.bubbles, - }); - this.ipc.postMessage(message); - event.preventDefault(); + // Handle file inputs specifically, since we want to get the real file inputs from the native + // dialog and then set the form inputs accordingly. + if ( + target instanceof HTMLInputElement && + target.getAttribute("type") === "file" + ) { + // Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it + event.preventDefault(); + + const contents = serializeEvent(event, target); + + const target_name = target.getAttribute("name") || ""; + + let requestData = { + event: "change&input", + accept: target.getAttribute("accept"), + directory: target.getAttribute("webkitdirectory") === "true", + multiple: target.hasAttribute("multiple"), + target: target_id, + bubbles: event.bubbles, + target_name, + values: contents.values, + }; + + this.fetchAgainstHost("__file_dialog", requestData).then(response => response.json()).then(resp => { + const formObjects: SerializedFormObject[] = resp.values; + + // Create a new DataTransfer to hold the files + const dataTransfer = new DataTransfer(); + + // We name the file the path, so we can just use the path as the name later on. + for (let formObject of formObjects) { + if (formObject.key == target_name && formObject.file != null) { + const file = new File([], formObject.file.path, { + type: formObject.file.content_type, + lastModified: formObject.file.last_modified, + }); + dataTransfer.items.add(file); + } + } + + // Set the files on the input + target.files = dataTransfer.files; + + let body = { + data: contents, + element: target_id, + bubbles: event.bubbles, + }; + + // And then dispatch the actual event against the dom + contents.values = formObjects; + this.sendSerializedEvent({ + ...body, + name: "input", + }); + this.sendSerializedEvent({ + ...body, + name: "change", + }); + }); + + return; + } + } } }); + // @ts-ignore - wry gives us this this.ipc = window.ipc; @@ -103,8 +161,23 @@ export class NativeInterpreter extends JSChannel_ { super.initialize(root, handler); } - serializeIpcMessage(method: string, params = {}) { - return JSON.stringify({ method, params }); + fetchAgainstHost(path: string, data: { [key: string]: any }): Promise { + let encoded_data = new TextEncoder().encode(JSON.stringify(data)); + const base64data = btoa( + String.fromCharCode.apply(null, Array.from(encoded_data)) + ); + + return fetch(`${this.baseUri}/${path}`, { + method: "GET", + headers: { + "x-dioxus-data": base64data + } + }); + } + + sendIpcMessage(method: string, params = {}) { + const body = JSON.stringify({ method, params }); + this.ipc.postMessage(body); } scrollTo(id: NodeId, options: ScrollIntoViewOptions): boolean { @@ -181,11 +254,14 @@ export class NativeInterpreter extends JSChannel_ { // Windows drag-n-drop fix code. Called by wry drag-n-drop handler over the event loop. handleWindowsDragDrop() { + // @ts-ignore if (window.dxDragLastElement) { const dragLeaveEvent = new DragEvent("dragleave", { bubbles: true, cancelable: true, }); + + // @ts-ignore window.dxDragLastElement.dispatchEvent(dragLeaveEvent); let data = new DataTransfer(); @@ -199,23 +275,29 @@ export class NativeInterpreter extends JSChannel_ { cancelable: true, dataTransfer: data, }); + + // @ts-ignore window.dxDragLastElement.dispatchEvent(dragDropEvent); + // @ts-ignore window.dxDragLastElement = null; } } - handleWindowsDragOver(xPos, yPos) { + handleWindowsDragOver(xPos: number, yPos: number) { const displayScaleFactor = window.devicePixelRatio || 1; xPos /= displayScaleFactor; yPos /= displayScaleFactor; const element = document.elementFromPoint(xPos, yPos); + // @ts-ignore if (element != window.dxDragLastElement) { + // @ts-ignore if (window.dxDragLastElement) { const dragLeaveEvent = new DragEvent("dragleave", { bubbles: true, cancelable: true, }); + // @ts-ignore window.dxDragLastElement.dispatchEvent(dragLeaveEvent); } @@ -224,17 +306,21 @@ export class NativeInterpreter extends JSChannel_ { cancelable: true, }); element.dispatchEvent(dragOverEvent); + // @ts-ignore window.dxDragLastElement = element; } } handleWindowsDragLeave() { + // @ts-ignore if (window.dxDragLastElement) { const dragLeaveEvent = new DragEvent("dragleave", { bubbles: true, cancelable: true, }); + // @ts-ignore window.dxDragLastElement.dispatchEvent(dragLeaveEvent); + // @ts-ignore window.dxDragLastElement = null; } } @@ -266,31 +352,30 @@ export class NativeInterpreter extends JSChannel_ { handleEvent(event: Event, name: string, bubbles: boolean) { const target = event.target!; - const realId = getTargetId(target)!; + const element = getTargetId(target)!; const contents = serializeEvent(event, target); // Handle the event on the virtualdom and then preventDefault if it also preventsDefault // Some listeners let body = { - name: name, + name, data: contents, - element: realId, + element, bubbles, }; // liveview does not have synchronous event handling, so we need to send the event to the host - if (this.liveview) { - // Okay, so the user might've requested some files to be read - if ( - target instanceof HTMLInputElement && - (event.type === "change" || event.type === "input") - ) { - if (target.getAttribute("type") === "file") { - this.readFiles(target, contents, bubbles, realId, name); - return; - } + if ( + this.liveview && + target instanceof HTMLInputElement && + (event.type === "change" || event.type === "input") + ) { + if (target.getAttribute("type") === "file") { + this.readFiles(target, contents, bubbles, element, name); + return; } } + const response = this.sendSerializedEvent(body); // capture/prevent default of the event if the virtualdom wants to if (response) { @@ -316,8 +401,7 @@ export class NativeInterpreter extends JSChannel_ { bubbles: boolean; }): EventSyncResult | void { if (this.liveview) { - const message = this.serializeIpcMessage("user_event", body); - this.ipc.postMessage(message); + this.sendIpcMessage("user_event", body); } else { // Run the event handler on the virtualdom return handleVirtualdomEventSync(this.eventsPath, JSON.stringify(body)); @@ -337,9 +421,7 @@ export class NativeInterpreter extends JSChannel_ { const href = a_element.getAttribute("href"); if (href !== "" && href !== null && href !== undefined) { - this.ipc.postMessage( - this.serializeIpcMessage("browser_open", { href }) - ); + this.sendIpcMessage("browser_open", { href }) } } } diff --git a/packages/interpreter/src/ts/serialize.ts b/packages/interpreter/src/ts/serialize.ts index 9decbd2c2..5efb2e8fd 100644 --- a/packages/interpreter/src/ts/serialize.ts +++ b/packages/interpreter/src/ts/serialize.ts @@ -1,11 +1,13 @@ -// Handle serialization of the event data across the IPC boundarytype SerializedEvent = {}; - -import { retrieveSelectValue, retrieveValues } from "./form"; +// Handle serialization of the event data across the IPC boundary +// +// Note that for files, we don't serialize their contents, expecting the renderer itself to handle +// reading the file contents if needed. Reading files is an async operation, and should be done lazily +// if the app needs it. export type AppTouchEvent = TouchEvent; export type SerializedEvent = { - values?: { [key: string]: FormDataEntryValue[] }; + values?: SerializedFormObject[]; value?: string; [key: string]: any; }; @@ -17,7 +19,7 @@ export function serializeEvent( let contents = {}; // merge the object into the contents - let extend = (obj: any) => (contents = { ...contents, ...obj }); + let extend = (obj: SerializedEvent) => (contents = { ...contents, ...obj }); if (event instanceof WheelEvent) { extend(serializeWheelEvent(event)); @@ -84,22 +86,20 @@ export function serializeEvent( // If there's any files, we need to serialize them if (event instanceof DragEvent) { - // let files: { [key: string]: Uint8Array } = {}; - // if (event.dataTransfer && event.dataTransfer.files) { - // files["a"] = new Uint8Array(0); - // // files = { - // // entries: Array.from(event.dataTransfer.files).map((file) => { - // // return { - // // name: file.name, - // // type: file.type, - // // size: file.size, - // // last_modified: file.lastModified, - // // }; - // // } - // // }; - // // files = await serializeFileList(event.dataTransfer.files); - // } - // extend({ files: files }); + let files: SerializedFormObject[] = []; + if (event.dataTransfer && event.dataTransfer.files) { + for (let i = 0; i < event.dataTransfer.files.length; i++) { + let file = event.dataTransfer.files[i]; + let data: SerializedFileData = { + path: file.name, + size: file.size, + last_modified: file.lastModified, + content_type: file.type, + } + files.push({ key: file.name, file: data }); + } + } + extend({ files }); } if (event.type === "scroll") { @@ -173,7 +173,7 @@ function serializeInputEvent( // Attempt to retrieve the values from the form if (target instanceof HTMLElement) { - let values = retrieveValues(event, target); + let values = extractSerializedFormValues(event, target); contents.values = values.values; contents.valid = values.valid; } @@ -376,4 +376,85 @@ function serializeScrollEvent(event: Event): SerializedEvent { client_width: clientWidth, client_height: clientHeight, }; -} \ No newline at end of file +} + + +export type SerializedFormData = { + valid?: boolean; + values: SerializedFormObject[]; +} + +export type SerializedFormObject = { + key: string; + text?: string; + file?: SerializedFileData; +} +export type SerializedFileData = { + // we store the real path of the file as the name. + // This makes it possible to simply read the file during event handling + path: string; + size?: number; + last_modified?: number; + content_type?: string; + contents?: string; // base64 encoded, if present. not required to br present +}; + +export function extractSerializedFormValues(event: Event, target: HTMLElement): SerializedFormData { + let contents: SerializedFormData = { + values: [] + }; + + // If there's a form... + let form = target.closest("form"); + + // If the target is an input, and the event is input or change, we want to get the value without going through the form + if (form) { + if ( + event.type === "input" + || event.type === "change" + || event.type === "submit" + || event.type === "reset" + || event.type === "click" + ) { + contents = retrieveFormValues(form); + } + } + + return contents; +} + +// todo: maybe encode spaces or something? +// We encode select multiple as a comma separated list which breaks... when there's commas in the values +function retrieveFormValues(form: HTMLFormElement): SerializedFormData { + const formData = new FormData(form); + const contents: SerializedFormObject[] = []; + + formData.forEach((value, key) => { + if (value instanceof File) { + let fileData: SerializedFileData = { + path: value.name, + size: value.size, + last_modified: value.lastModified, + content_type: value.type, + }; + contents.push({ key, file: fileData }); + } else { + contents.push({ key, text: value }); + } + }); + + return { + valid: form.checkValidity(), + values: contents + }; +} + +export function retrieveSelectValue(target: HTMLSelectElement): string[] { + // there might be multiple... + let options = target.selectedOptions; + let values = []; + for (let i = 0; i < options.length; i++) { + values.push(options[i].value); + } + return values; +} diff --git a/packages/native-dom/src/dioxus_document.rs b/packages/native-dom/src/dioxus_document.rs index 23ea5e12e..dfd7496d6 100644 --- a/packages/native-dom/src/dioxus_document.rs +++ b/packages/native-dom/src/dioxus_document.rs @@ -14,7 +14,7 @@ use futures_util::{pin_mut, FutureExt}; use std::ops::{Deref, DerefMut}; use std::sync::LazyLock; use std::task::{Context as TaskContext, Waker}; -use std::{any::Any, collections::HashMap, rc::Rc}; +use std::{any::Any, rc::Rc}; fn wrap_event_data(value: T) -> Rc { Rc::new(PlatformEventData::new(Box::new(value))) @@ -262,7 +262,7 @@ impl EventHandler for DioxusEventHandler<'_> { DomEventData::Input(data) => Some(wrap_event_data(NativeFormData { value: data.value.clone(), - values: HashMap::new(), + values: vec![], })), // TODO: Implement IME handling diff --git a/packages/native-dom/src/events.rs b/packages/native-dom/src/events.rs index 208a4a7cd..c96be3c27 100644 --- a/packages/native-dom/src/events.rs +++ b/packages/native-dom/src/events.rs @@ -13,7 +13,6 @@ use dioxus_html::{ }; use keyboard_types::{Code, Key, Location, Modifiers}; use std::any::Any; -use std::collections::HashMap; pub struct NativeConverter {} @@ -110,7 +109,7 @@ impl HtmlEventConverter for NativeConverter { #[derive(Clone, Debug)] pub struct NativeFormData { pub value: String, - pub values: HashMap, + pub values: Vec<(String, FormValue)>, } impl HasFormData for NativeFormData { @@ -122,9 +121,13 @@ impl HasFormData for NativeFormData { self.value.clone() } - fn values(&self) -> HashMap { + fn values(&self) -> Vec<(String, FormValue)> { self.values.clone() } + fn valid(&self) -> bool { + // todo: actually implement validation here. + true + } } impl HasFileData for NativeFormData { diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 55e9d5d5a..1c9194c2d 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -85,7 +85,8 @@ features = [ "File", "FileList", "FileReader", - "ReadableStream" + "ReadableStream", + "FormData" ] [build-dependencies] diff --git a/packages/web/src/events/form.rs b/packages/web/src/events/form.rs index 4dd34c301..31dae7753 100644 --- a/packages/web/src/events/form.rs +++ b/packages/web/src/events/form.rs @@ -1,12 +1,10 @@ -use std::{any::Any, collections::HashMap}; - +use super::WebEventExt; +use crate::WebFileData; use dioxus_html::{FileData, FormValue, HasFileData, HasFormData}; use js_sys::Array; -use wasm_bindgen::JsValue; +use std::any::Any; use wasm_bindgen::{prelude::wasm_bindgen, JsCast}; -use web_sys::{Element, Event}; - -use super::WebEventExt; +use web_sys::{Element, Event, FileReader}; pub(crate) struct WebFormData { element: Element, @@ -67,26 +65,29 @@ impl HasFormData for WebFormData { .expect("only an InputElement or TextAreaElement or an element with contenteditable=true can have an oninput event listener") } - fn values(&self) -> HashMap { - let mut values = HashMap::new(); - - fn insert_value(map: &mut HashMap, key: String, new_value: String) { - map.entry(key.clone()).or_default().0.push(new_value); - } + fn values(&self) -> Vec<(String, FormValue)> { + let mut values = Vec::new(); // try to fill in form values if let Some(form) = self.element.dyn_ref::() { - let form_data = get_form_data(form); - for value in form_data.entries().into_iter().flatten() { - if let Ok(array) = value.dyn_into::() { + let form_data = web_sys::FormData::new_with_form(form).unwrap(); + + for entry in form_data.entries().into_iter().flatten() { + if let Ok(array) = entry.dyn_into::() { if let Some(name) = array.get(0).as_string() { - if let Ok(item_values) = array.get(1).dyn_into::() { - item_values - .iter() - .filter_map(|v| v.as_string()) - .for_each(|v| insert_value(&mut values, name.clone(), v)); - } else if let Ok(item_value) = array.get(1).dyn_into::() { - insert_value(&mut values, name, item_value.as_string().unwrap()); + let value = array.get(1); + if let Some(file) = value.dyn_ref::() { + if file.name().is_empty() { + values.push((name, FormValue::File(None))); + } else { + let data = + WebFileData::new(file.clone(), FileReader::new().unwrap()); + let as_file = FileData::new(data); + + values.push((name, FormValue::File(Some(as_file)))); + } + } else if let Some(s) = value.as_string() { + values.push((name, FormValue::Text(s))); } } } @@ -94,7 +95,9 @@ impl HasFormData for WebFormData { } else if let Some(select) = self.element.dyn_ref::() { // try to fill in select element values let options = get_select_data(select); - values.insert("options".to_string(), FormValue(options)); + for option in &options { + values.push((select.name(), FormValue::Text(option.clone()))); + } } values @@ -103,6 +106,14 @@ impl HasFormData for WebFormData { fn as_any(&self) -> &dyn Any { &self.event as &dyn Any } + + fn valid(&self) -> bool { + self.event + .target() + .and_then(|t| t.dyn_into::().ok()) + .map(|input| input.check_validity()) + .unwrap_or(true) + } } impl HasFileData for WebFormData { @@ -135,20 +146,3 @@ export function get_select_data(select) { extern "C" { fn get_select_data(select: &web_sys::HtmlSelectElement) -> Vec; } - -// web-sys does not expose the keys api for form data, so we need to manually bind to it -#[wasm_bindgen(inline_js = r#" -export function get_form_data(form) { - let values = new Map(); - const formData = new FormData(form); - - for (let name of formData.keys()) { - values.set(name, formData.getAll(name)); - } - - return values; -} -"#)] -extern "C" { - fn get_form_data(form: &web_sys::HtmlFormElement) -> js_sys::Map; -}