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
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-10-07 23:07:43 -07:00
1 parent fc01a002fd
commit 92792aded9
47 files changed
+1100 -562

No files matched your search

Generated
+1
View File
@@ -14089,6 +14089,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
+7 -4
View File
@@ -52,11 +52,14 @@ fn app() -> Element {
#[post("/api/upload-multipart")] #[post("/api/upload-multipart")]
async fn upload(mut form: MultipartFormData) -> Result<()> { async fn upload(mut form: MultipartFormData) -> Result<()> {
while let Ok(Some(field)) = form.next_field().await { while let Ok(Some(field)) = form.next_field().await {
let name = field.name().unwrap_or("<none>").to_string();
let file_name = field.file_name().unwrap_or("<none>").to_string();
let content_type = field.content_type().unwrap_or("<none>").to_string();
let size = field.bytes().await.unwrap().len();
info!( info!(
"Field name: {:?}, filename: {:?}, content_type: {:?}", "Field name: {:?}, filename: {:?}, content_type: {:?}, size: {:?}",
field.name().to_owned(), name, file_name, content_type, size
field.file_name(),
field.content_type(),
); );
} }
+1
View File
@@ -17,6 +17,7 @@ Option 1:
For simple, synchronous, thread-safe data, we can use statics with atomic types or mutexes. 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 The `LazyLock` type from the standard library is a great choice for simple, synchronous data
*/ */
#[cfg(feature = "server")]
static MESSAGES: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new())); static MESSAGES: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
#[post("/api/messages")] #[post("/api/messages")]
+6 -4
View File
@@ -4,15 +4,14 @@
//! in the "values" field. //! in the "values" field.
use dioxus::prelude::*; use dioxus::prelude::*;
use std::collections::HashMap;
fn main() { fn main() {
dioxus::launch(app); dioxus::launch(app);
} }
fn app() -> Element { fn app() -> Element {
let mut values = use_signal(HashMap::new); let mut values = use_signal(Vec::new);
let mut submitted_values = use_signal(HashMap::new); let mut submitted_values = use_signal(Vec::new);
rsx! { rsx! {
div { style: "display: flex", div { style: "display: flex",
@@ -63,7 +62,7 @@ fn app() -> Element {
input { r#type: "text", name: "full-name" } input { r#type: "text", name: "full-name" }
label { r#for: "email", "Email (matching <name>@example.com)" } label { r#for: "email", "Email (matching <name>@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" } label { r#for: "password", "Password" }
input { r#type: "password", name: "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" } 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. // Buttons will submit your form by default.
button { r#type: "submit", value: "Submit", "Submit the form" } button { r#type: "submit", value: "Submit", "Submit the form" }
+11 -6
View File
@@ -154,19 +154,20 @@ impl AppBuilder {
} }
/// Create a new `AppBuilder` and immediately start a build process. /// Create a new `AppBuilder` and immediately start a build process.
pub fn started(request: &BuildRequest, mode: BuildMode) -> Result<Self> { pub fn started(request: &BuildRequest, mode: BuildMode, build_id: BuildId) -> Result<Self> {
let mut builder = Self::new(request)?; let mut builder = Self::new(request)?;
builder.start(mode); builder.start(mode, build_id);
Ok(builder) 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({ self.build_task = tokio::spawn({
let request = self.build.clone(); let request = self.build.clone();
let tx = self.tx.clone(); let tx = self.tx.clone();
async move { async move {
let ctx = BuildContext { let ctx = BuildContext {
mode, mode,
build_id,
tx: tx.clone(), tx: tx.clone(),
}; };
request.verify_tooling(&ctx).await?; request.verify_tooling(&ctx).await?;
@@ -290,10 +291,12 @@ impl AppBuilder {
update update
} }
pub(crate) fn patch_rebuild(&mut self, changed_files: Vec<PathBuf>) { pub(crate) fn patch_rebuild(&mut self, changed_files: Vec<PathBuf>, build_id: BuildId) {
// We need the rustc args from the original build to pass to the new build // We need the rustc args from the original build to pass to the new build
let Some(artifacts) = self.artifacts.as_ref().cloned() else { 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; return;
}; };
@@ -327,6 +330,7 @@ impl AppBuilder {
self.build_task = tokio::spawn({ self.build_task = tokio::spawn({
let request = self.build.clone(); let request = self.build.clone();
let ctx = BuildContext { let ctx = BuildContext {
build_id,
tx: self.tx.clone(), tx: self.tx.clone(),
mode: BuildMode::Thin { mode: BuildMode::Thin {
changed_files, changed_files,
@@ -340,7 +344,7 @@ impl AppBuilder {
} }
/// Restart this builder with new build arguments. /// 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 // 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 // And then start a new build, resetting our progress/stage to the beginning and replacing the old tokio task
self.abort_all(BuildStage::Restarting); self.abort_all(BuildStage::Restarting);
@@ -351,6 +355,7 @@ impl AppBuilder {
let ctx = BuildContext { let ctx = BuildContext {
tx: self.tx.clone(), tx: self.tx.clone(),
mode, mode,
build_id,
}; };
async move { request.build(&ctx).await } async move { request.build(&ctx).await }
}); });
+9 -2
View File
@@ -16,6 +16,7 @@ use std::{path::PathBuf, process::ExitStatus};
pub struct BuildContext { pub struct BuildContext {
pub tx: ProgressTx, pub tx: ProgressTx,
pub mode: BuildMode, pub mode: BuildMode,
pub build_id: BuildId,
} }
pub type ProgressTx = UnboundedSender<BuilderUpdate>; pub type ProgressTx = UnboundedSender<BuilderUpdate>;
@@ -24,8 +25,8 @@ pub type ProgressRx = UnboundedReceiver<BuilderUpdate>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub struct BuildId(pub(crate) usize); pub struct BuildId(pub(crate) usize);
impl BuildId { impl BuildId {
pub const CLIENT: Self = Self(0); pub const PRIMARY: Self = Self(0);
pub const SERVER: Self = Self(1); pub const SECONDARY: Self = Self(1);
} }
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
@@ -78,6 +79,12 @@ pub enum BuilderUpdate {
} }
impl BuildContext { 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) { pub(crate) fn status_wasm_bindgen_start(&self) {
_ = self.tx.unbounded_send(BuilderUpdate::Progress { _ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::RunningBindgen, stage: BuildStage::RunningBindgen,
+1 -1
View File
@@ -45,7 +45,7 @@ pub(crate) async fn pre_render_static_routes(
devserver_ip, devserver_ip,
Some(fullstack_address), Some(fullstack_address),
false, false,
BuildId::SERVER, BuildId::SECONDARY,
); );
// Run the server executable // Run the server executable
let _child = Command::new(&server_exe) let _child = Command::new(&server_exe)
+10 -11
View File
@@ -320,9 +320,9 @@
//! - xbuild: <https://github.com/rust-mobile/xbuild/blob/master/xbuild/src/command/build.rs> //! - xbuild: <https://github.com/rust-mobile/xbuild/blob/master/xbuild/src/command/build.rs>
use crate::{ use crate::{
AndroidTools, BuildContext, BundleFormat, DioxusConfig, Error, LinkAction, LinkerFlavor, AndroidTools, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction,
Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, WasmOptConfig, LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen,
Workspace, DX_RUSTC_WRAPPER_ENV_VAR, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
}; };
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use cargo_metadata::diagnostic::Diagnostic; use cargo_metadata::diagnostic::Diagnostic;
@@ -445,7 +445,6 @@ pub enum BuildMode {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct BuildArtifacts { pub struct BuildArtifacts {
pub(crate) root_dir: PathBuf, pub(crate) root_dir: PathBuf,
pub(crate) bundle: BundleFormat,
pub(crate) exe: PathBuf, pub(crate) exe: PathBuf,
pub(crate) direct_rustc: RustcArgs, pub(crate) direct_rustc: RustcArgs,
pub(crate) time_start: SystemTime, pub(crate) time_start: SystemTime,
@@ -454,6 +453,7 @@ pub struct BuildArtifacts {
pub(crate) mode: BuildMode, pub(crate) mode: BuildMode,
pub(crate) patch_cache: Option<Arc<HotpatchModuleCache>>, pub(crate) patch_cache: Option<Arc<HotpatchModuleCache>>,
pub(crate) depinfo: RustcDepInfo, pub(crate) depinfo: RustcDepInfo,
pub(crate) build_id: BuildId,
} }
impl BuildRequest { impl BuildRequest {
@@ -989,10 +989,10 @@ impl BuildRequest {
_ = std::fs::File::create_new(self.windows_command_file()); _ = std::fs::File::create_new(self.windows_command_file());
if !matches!(ctx.mode, BuildMode::Thin { .. }) { 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(()); return Ok(());
} }
@@ -1250,7 +1250,6 @@ impl BuildRequest {
let assets = self.collect_assets(&exe, ctx).await?; let assets = self.collect_assets(&exe, ctx).await?;
let time_end = SystemTime::now(); let time_end = SystemTime::now();
let mode = ctx.mode.clone(); let mode = ctx.mode.clone();
let bundle = self.bundle;
let depinfo = RustcDepInfo::from_file(&exe.with_extension("d")).unwrap_or_default(); let depinfo = RustcDepInfo::from_file(&exe.with_extension("d")).unwrap_or_default();
tracing::debug!( tracing::debug!(
@@ -1261,7 +1260,6 @@ impl BuildRequest {
Ok(BuildArtifacts { Ok(BuildArtifacts {
time_end, time_end,
bundle,
exe, exe,
direct_rustc, direct_rustc,
time_start, time_start,
@@ -1270,6 +1268,7 @@ impl BuildRequest {
depinfo, depinfo,
root_dir: self.root_dir(), root_dir: self.root_dir(),
patch_cache: None, 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 /// 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<()> { async fn write_assets(&self, ctx: &BuildContext, assets: &AssetManifest) -> Result<()> {
// Server doesn't need assets - web will provide them // Server doesn't need assets - web will provide them
if self.bundle == BundleFormat::Server { if !ctx.is_primary_build() {
return Ok(()); return Ok(());
} }
@@ -4320,14 +4319,14 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
/// - extra scaffolding /// - extra scaffolding
/// ///
/// It's not guaranteed that they're different from any other folder /// 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::fs::{create_dir_all, remove_dir_all};
use std::sync::OnceLock; use std::sync::OnceLock;
static INITIALIZED: OnceLock<Result<()>> = OnceLock::new(); static INITIALIZED: OnceLock<Result<()>> = OnceLock::new();
let success = INITIALIZED.get_or_init(|| { let success = INITIALIZED.get_or_init(|| {
if self.bundle != BundleFormat::Server { if ctx.is_primary_build() {
_ = remove_dir_all(self.exe_dir()); _ = remove_dir_all(self.exe_dir());
} }
+8 -4
View File
@@ -1,8 +1,8 @@
use dioxus_dx_wire_format::StructuredBuildArtifacts; use dioxus_dx_wire_format::StructuredBuildArtifacts;
use crate::{ use crate::{
cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildMode, BuildRequest, BundleFormat, cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildId, BuildMode, BuildRequest, BundleFormat,
TargetArgs, Workspace, Platform, TargetArgs, Workspace,
}; };
/// Build the Rust Dioxus app and all of its assets. /// Build the Rust Dioxus app and all of its assets.
@@ -128,6 +128,10 @@ impl CommandWithPlatformOverrides<BuildArgs> {
BuildRequest::new(&server_args.build_arguments, workspace.clone()).await?, 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 => { None => {
let mut args = self.shared.build_arguments.clone(); let mut args = self.shared.build_arguments.clone();
args.platform = crate::Platform::Server; args.platform = crate::Platform::Server;
@@ -171,7 +175,7 @@ impl CommandWithPlatformOverrides<BuildArgs> {
request: &BuildRequest, request: &BuildRequest,
mode: BuildMode, mode: BuildMode,
) -> Result<BuildArtifacts> { ) -> Result<BuildArtifacts> {
AppBuilder::started(request, mode)? AppBuilder::started(request, mode, BuildId::PRIMARY)?
.finish_build() .finish_build()
.await .await
.inspect(|_| { .inspect(|_| {
@@ -189,7 +193,7 @@ impl CommandWithPlatformOverrides<BuildArgs> {
}; };
// If the server is present, we need to build it as well // 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?; let server_artifacts = server_build.finish_build().await?;
// Run SSG and cache static routes // Run SSG and cache static routes
+4 -3
View File
@@ -1,4 +1,4 @@
use crate::{AppBuilder, BuildArgs, BuildMode, BuildRequest, BundleFormat}; use crate::{AppBuilder, BuildArgs, BuildId, BuildMode, BuildRequest, BundleFormat};
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use path_absolutize::Absolutize; use path_absolutize::Absolutize;
use std::collections::HashMap; use std::collections::HashMap;
@@ -40,7 +40,8 @@ impl Bundle {
let BuildTargets { client, server } = self.args.into_targets().await?; let BuildTargets { client, server } = self.args.into_targets().await?;
let mut server_artifacts = None; let mut server_artifacts = None;
let client_artifacts = AppBuilder::started(&client, BuildMode::Base { run: false })? let client_artifacts =
AppBuilder::started(&client, BuildMode::Base { run: false }, BuildId::PRIMARY)?
.finish_build() .finish_build()
.await?; .await?;
@@ -49,7 +50,7 @@ impl Bundle {
if let Some(server) = server.as_ref() { if let Some(server) = server.as_ref() {
// If the server is present, we need to build it as well // If the server is present, we need to build it as well
server_artifacts = Some( server_artifacts = Some(
AppBuilder::started(server, BuildMode::Base { run: false })? AppBuilder::started(server, BuildMode::Base { run: false }, BuildId::SECONDARY)?
.finish_build() .finish_build()
.await?, .await?,
); );
+13 -3
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildMode, platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildId, BuildMode,
HotpatchModuleCache, Result, StructuredOutput, HotpatchModuleCache, Result, StructuredOutput,
}; };
use anyhow::Context; use anyhow::Context;
@@ -34,8 +34,16 @@ impl HotpatchTip {
pub async fn run(self) -> Result<StructuredOutput> { pub async fn run(self) -> Result<StructuredOutput> {
let targets = self.build_args.into_targets().await?; 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 // 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!")? targets.server.as_ref().context("No server to patch!")?
} else { } else {
&targets.client &targets.client
@@ -68,7 +76,9 @@ impl HotpatchTip {
cache: cache.clone(), 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); let patch_exe = request.patch_exe(artifacts.time_start);
Ok(StructuredOutput::Hotpatch { Ok(StructuredOutput::Hotpatch {
+2 -2
View File
@@ -109,12 +109,12 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &TraceController) -> Resu
pid, pid,
} => { } => {
devserver devserver
.send_hotreload(builder.applied_hot_reload_changes(BuildId::CLIENT)) .send_hotreload(builder.applied_hot_reload_changes(BuildId::PRIMARY))
.await; .await;
if builder.server.is_some() { if builder.server.is_some() {
devserver devserver
.send_hotreload(builder.applied_hot_reload_changes(BuildId::SERVER)) .send_hotreload(builder.applied_hot_reload_changes(BuildId::SECONDARY))
.await; .await;
} }
+2 -2
View File
@@ -268,12 +268,12 @@ impl Output {
} }
KeyCode::Char('D') => { KeyCode::Char('D') => {
return Ok(Some(ServeUpdate::OpenDebugger { return Ok(Some(ServeUpdate::OpenDebugger {
id: BuildId::SERVER, id: BuildId::SECONDARY,
})); }));
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
return Ok(Some(ServeUpdate::OpenDebugger { return Ok(Some(ServeUpdate::OpenDebugger {
id: BuildId::CLIENT, id: BuildId::PRIMARY,
})); }));
} }
KeyCode::Char('c') => { KeyCode::Char('c') => {
+38 -30
View File
@@ -234,9 +234,9 @@ impl AppServer {
false => BuildMode::Base { run: true }, 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() { 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 // Wait for the client to finish
client_update = client_wait => { client_update = client_wait => {
ServeUpdate::BuilderUpdate { ServeUpdate::BuilderUpdate {
id: BuildId::CLIENT, id: BuildId::PRIMARY,
update: client_update, update: client_update,
} }
} }
Some(server_update) = server_wait => { Some(server_update) = server_wait => {
ServeUpdate::BuilderUpdate { ServeUpdate::BuilderUpdate {
id: BuildId::SERVER, id: BuildId::SECONDARY,
update: server_update, 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. // 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 needs_full_rebuild && self.automatic_rebuilds {
if self.use_hotpatch_engine { 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() { 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_hot_reload_changes();
self.clear_cached_rsx(); self.clear_cached_rsx();
server.send_patch_start().await; server.send_patch_start().await;
} else { } 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() { 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_hot_reload_changes();
self.clear_cached_rsx(); self.clear_cached_rsx();
@@ -560,13 +561,14 @@ impl AppServer {
devserver: &mut WebServer, devserver: &mut WebServer,
) -> Result<()> { ) -> Result<()> {
// Make sure to save artifacts regardless of if we're opening the app or not // Make sure to save artifacts regardless of if we're opening the app or not
match artifacts.bundle { match artifacts.build_id {
BundleFormat::Server => { BuildId::PRIMARY => self.client.artifacts = Some(artifacts.clone()),
BuildId::SECONDARY => {
if let Some(server) = self.server.as_mut() { if let Some(server) = self.server.as_mut() {
server.artifacts = Some(artifacts.clone()); server.artifacts = Some(artifacts.clone());
} }
} }
_ => self.client.artifacts = Some(artifacts.clone()), _ => {}
} }
let should_open = self.client.stage == BuildStage::Success let should_open = self.client.stage == BuildStage::Success
@@ -634,7 +636,7 @@ impl AppServer {
fullstack_address, fullstack_address,
false, false,
false, false,
BuildId::SERVER, BuildId::SECONDARY,
&self.server_args, &self.server_args,
) )
.await?; .await?;
@@ -649,7 +651,7 @@ impl AppServer {
fullstack_address, fullstack_address,
open_browser, open_browser,
self.always_on_top, self.always_on_top,
BuildId::CLIENT, BuildId::PRIMARY,
&self.client_args, &self.client_args,
) )
.await?; .await?;
@@ -696,9 +698,10 @@ impl AppServer {
false => BuildMode::Base { run: true }, 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() { 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(); self.clear_hot_reload_changes();
@@ -719,8 +722,8 @@ impl AppServer {
.unwrap_or_default(); .unwrap_or_default();
let jump_table = match id { let jump_table = match id {
BuildId::CLIENT => self.client.hotpatch(bundle, cache).await, BuildId::PRIMARY => self.client.hotpatch(bundle, cache).await,
BuildId::SERVER => { BuildId::SECONDARY => {
self.server self.server
.as_mut() .as_mut()
.context("Server not found")? .context("Server not found")?
@@ -730,7 +733,7 @@ impl AppServer {
_ => bail!("Invalid build id"), _ => 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(); self.applied_client_hot_reload_message.jump_table = self.client.patches.last().cloned();
} }
@@ -759,11 +762,16 @@ impl AppServer {
.context("Missing server jump table")?; .context("Missing server jump table")?;
devserver devserver
.send_patch(server_jump_table, elapsed, BuildId::SERVER, server.pid) .send_patch(server_jump_table, elapsed, BuildId::SECONDARY, server.pid)
.await; .await;
devserver devserver
.send_patch(client_jump_table, elapsed, BuildId::CLIENT, self.client.pid) .send_patch(
client_jump_table,
elapsed,
BuildId::PRIMARY,
self.client.pid,
)
.await; .await;
} }
@@ -772,8 +780,8 @@ impl AppServer {
pub(crate) fn get_build(&self, id: BuildId) -> Option<&AppBuilder> { pub(crate) fn get_build(&self, id: BuildId) -> Option<&AppBuilder> {
match id { match id {
BuildId::CLIENT => Some(&self.client), BuildId::PRIMARY => Some(&self.client),
BuildId::SERVER => self.server.as_ref(), BuildId::SECONDARY => self.server.as_ref(),
_ => None, _ => None,
} }
} }
@@ -791,18 +799,18 @@ impl AppServer {
pub(crate) fn applied_hot_reload_changes(&mut self, build: BuildId) -> HotReloadMsg { pub(crate) fn applied_hot_reload_changes(&mut self, build: BuildId) -> HotReloadMsg {
let mut msg = self.applied_client_hot_reload_message.clone(); 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.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() { if let Some(lib) = msg.jump_table.as_mut() {
lib.lib = PathBuf::from("/").join(lib.lib.clone()); lib.lib = PathBuf::from("/").join(lib.lib.clone());
} }
} }
if build == BuildId::SERVER { if build == BuildId::SECONDARY {
if let Some(server) = self.server.as_mut() { if let Some(server) = self.server.as_mut() {
msg.jump_table = server.patches.last().cloned(); 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<u32>, pid: Option<u32>,
) { ) {
match build_id { match build_id {
BuildId::CLIENT => { BuildId::PRIMARY => {
// multiple tabs on web can cause this to be called incorrectly, and it doesn't // multiple tabs on web can cause this to be called incorrectly, and it doesn't
// make any sense anyways // make any sense anyways
if self.client.build.bundle != BundleFormat::Web { 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() { if let Some(server) = self.server.as_mut() {
server.aslr_reference = aslr_reference; server.aslr_reference = aslr_reference;
} }
@@ -1146,10 +1154,10 @@ impl AppServer {
} }
match build { match build {
BuildId::CLIENT => { BuildId::PRIMARY => {
_ = self.client.open_debugger(dev).await; _ = self.client.open_debugger(dev).await;
} }
BuildId::SERVER => { BuildId::SECONDARY => {
if let Some(server) = self.server.as_mut() { if let Some(server) = self.server.as_mut() {
_ = server.open_debugger(dev).await; _ = server.open_debugger(dev).await;
} }
+1 -1
View File
@@ -149,7 +149,7 @@ impl WebServer {
if let Some(new_socket) = new_hot_reload_socket { if let Some(new_socket) = new_hot_reload_socket {
let aslr_reference = new_socket.aslr_reference; let aslr_reference = new_socket.aslr_reference;
let pid = new_socket.pid; 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); drop(new_message);
self.hot_reload_sockets.push(new_socket); self.hot_reload_sockets.push(new_socket);
+10 -12
View File
@@ -1,5 +1,3 @@
use std::collections::HashMap;
use dioxus::html::geometry::euclid::Vector3D; use dioxus::html::geometry::euclid::Vector3D;
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_desktop::DesktopContext; use dioxus_desktop::DesktopContext;
@@ -448,7 +446,7 @@ fn test_focus_out_div() -> Element {
} }
fn test_form_input() -> 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( utils::mock_event_with_extra(
"form-username", "form-username",
@@ -464,10 +462,10 @@ fn test_form_input() -> Element {
// And then the value the form gives us should also match // And then the value the form gives us should also match
values.with_mut(|x| { values.with_mut(|x| {
assert_eq!(x.get("username").unwrap(), "hello"); assert_eq!(x.iter().find(|f| f.0 == "username").unwrap().1, "hello");
assert_eq!(x.get("full-name").unwrap(), "lorem"); assert_eq!(x.iter().find(|f| f.0 == "full-name").unwrap().1, "lorem");
assert_eq!(x.get("password").unwrap(), "ipsum"); assert_eq!(x.iter().find(|f| f.0 == "password").unwrap().1, "ipsum");
assert_eq!(x.get("color").unwrap(), "red"); assert_eq!(x.iter().find(|f| f.0 == "color").unwrap().1, "red");
}); });
RECEIVED_EVENTS.with_mut(|x| *x += 1); RECEIVED_EVENTS.with_mut(|x| *x += 1);
}; };
@@ -505,7 +503,7 @@ fn test_form_input() -> Element {
} }
fn test_form_submit() -> 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( utils::mock_event_with_extra(
"form-submitter", "form-submitter",
@@ -516,10 +514,10 @@ fn test_form_submit() -> Element {
let set_values = move |ev: FormEvent| { let set_values = move |ev: FormEvent| {
values.set(ev.values()); values.set(ev.values());
values.with_mut(|x| { values.with_mut(|x| {
assert_eq!(x.get("username").unwrap(), "goodbye"); assert_eq!(x.iter().find(|f| f.0 == "username").unwrap().1, "goodbye");
assert_eq!(x.get("full-name").unwrap(), "lorem"); assert_eq!(x.iter().find(|f| f.0 == "full-name").unwrap().1, "lorem");
assert_eq!(x.get("password").unwrap(), "ipsum"); assert_eq!(x.iter().find(|f| f.0 == "password").unwrap().1, "ipsum");
assert_eq!(x.get("color").unwrap(), "red"); assert_eq!(x.iter().find(|f| f.0 == "color").unwrap().1, "red");
}); });
RECEIVED_EVENTS.with_mut(|x| *x += 1); RECEIVED_EVENTS.with_mut(|x| *x += 1);
}; };
+1 -33
View File
@@ -2,17 +2,14 @@ use crate::{
config::{Config, WindowCloseBehaviour}, config::{Config, WindowCloseBehaviour},
edits::EditWebsocket, edits::EditWebsocket,
event_handlers::WindowEventHandlers, event_handlers::WindowEventHandlers,
file_upload::{DesktopFileUploadForm, FileDialogRequest},
ipc::{IpcMessage, UserWindowEvent}, ipc::{IpcMessage, UserWindowEvent},
query::QueryResult, query::QueryResult,
shortcut::ShortcutRegistry, shortcut::ShortcutRegistry,
webview::{PendingWebview, WebviewInstance}, webview::{PendingWebview, WebviewInstance},
}; };
use dioxus_core::{consume_context, ElementId, ScopeId, VirtualDom}; use dioxus_core::{consume_context, ScopeId, VirtualDom};
use dioxus_history::History; use dioxus_history::History;
use dioxus_html::PlatformEventData;
use std::{ use std::{
any::Any,
cell::{Cell, RefCell}, cell::{Cell, RefCell},
collections::HashMap, collections::HashMap,
rc::Rc, 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::<FileDialogRequest>(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<dyn Any>, 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 /// 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 /// 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
+22 -33
View File
@@ -2,13 +2,34 @@
use crate::{ use crate::{
element::DesktopElement, element::DesktopElement,
file_upload::{DesktopFileDragEvent, DesktopFileUploadForm}, file_upload::{DesktopFileDragEvent, DesktopFormData},
}; };
use dioxus_html::*; use dioxus_html::*;
pub(crate) struct SerializedHtmlEventConverter; pub(crate) struct SerializedHtmlEventConverter;
impl HtmlEventConverter for 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::<DesktopFileDragEvent>()
.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::<DesktopFormData>().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::<DesktopElement>().cloned().unwrap().into()
}
fn convert_animation_data(&self, event: &PlatformEventData) -> AnimationData { fn convert_animation_data(&self, event: &PlatformEventData) -> AnimationData {
event event
.downcast::<SerializedAnimationData>() .downcast::<SerializedAnimationData>()
@@ -41,20 +62,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter {
.into() .into()
} }
fn convert_drag_data(&self, event: &PlatformEventData) -> DragData {
// Attempt a simple serialized data conversion
if let Some(_data) = event.downcast::<SerializedDragData>() {
return _data.clone().into();
}
// If that failed then it's a file drag form
event
.downcast::<DesktopFileDragEvent>()
.cloned()
.unwrap()
.into()
}
fn convert_focus_data(&self, event: &PlatformEventData) -> FocusData { fn convert_focus_data(&self, event: &PlatformEventData) -> FocusData {
event event
.downcast::<SerializedFocusData>() .downcast::<SerializedFocusData>()
@@ -63,20 +70,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter {
.into() .into()
} }
fn convert_form_data(&self, event: &PlatformEventData) -> FormData {
// Attempt a simple serialized form data conversion
if let Some(_data) = event.downcast::<SerializedFormData>() {
return _data.clone().into();
}
// If that failed then it's a file upload form
event
.downcast::<DesktopFileUploadForm>()
.cloned()
.unwrap()
.into()
}
fn convert_image_data(&self, event: &PlatformEventData) -> ImageData { fn convert_image_data(&self, event: &PlatformEventData) -> ImageData {
event event
.downcast::<SerializedImageData>() .downcast::<SerializedImageData>()
@@ -101,10 +94,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter {
.into() .into()
} }
fn convert_mounted_data(&self, event: &PlatformEventData) -> MountedData {
event.downcast::<DesktopElement>().cloned().unwrap().into()
}
fn convert_mouse_data(&self, event: &PlatformEventData) -> MouseData { fn convert_mouse_data(&self, event: &PlatformEventData) -> MouseData {
event event
.downcast::<SerializedMouseData>() .downcast::<SerializedMouseData>()
+73 -30
View File
@@ -1,6 +1,6 @@
#![allow(unused)] #![allow(unused)]
use std::any::Any; use std::{any::Any, collections::HashMap};
#[cfg(feature = "tokio_runtime")] #[cfg(feature = "tokio_runtime")]
use tokio::{fs::File, io::AsyncReadExt}; use tokio::{fs::File, io::AsyncReadExt};
@@ -11,11 +11,11 @@ use dioxus_html::{
point_interaction::{ point_interaction::{
InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction,
}, },
FileData, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData, FileData, FormValue, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData,
SerializedMouseData, SerializedPointInteraction, SerializedFormData, SerializedFormObject, SerializedMouseData, SerializedPointInteraction,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::{ use std::{
cell::{Cell, RefCell}, cell::{Cell, RefCell},
path::PathBuf, path::PathBuf,
@@ -25,7 +25,7 @@ use std::{
}; };
use wry::DragDropEvent; use wry::DragDropEvent;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Serialize)]
pub(crate) struct FileDialogRequest { pub(crate) struct FileDialogRequest {
#[serde(default)] #[serde(default)]
accept: Option<String>, accept: Option<String>,
@@ -34,6 +34,8 @@ pub(crate) struct FileDialogRequest {
pub event: String, pub event: String,
pub target: usize, pub target: usize,
pub bubbles: bool, pub bubbles: bool,
pub target_name: String,
pub values: Vec<SerializedFormObject>,
} }
#[allow(unused)] #[allow(unused)]
@@ -61,22 +63,26 @@ impl FileDialogRequest {
target_os = "openbsd" target_os = "openbsd"
))] ))]
pub(crate) fn get_file_event(&self) -> Vec<PathBuf> { pub(crate) fn get_file_event(&self) -> Vec<PathBuf> {
fn get_file_event_for_folder( let dialog = rfd::FileDialog::new();
request: &FileDialogRequest,
dialog: rfd::FileDialog, if self.directory {
) -> Vec<PathBuf> { self.get_file_event_for_folder(dialog)
if request.multiple {
dialog.pick_folders().into_iter().flatten().collect()
} else { } else {
dialog.pick_folder().into_iter().collect() self.get_file_event_for_file(dialog)
} }
} }
fn get_file_event_for_file( #[cfg(any(
request: &FileDialogRequest, target_os = "windows",
mut dialog: rfd::FileDialog, target_os = "macos",
) -> Vec<PathBuf> { target_os = "linux",
let filters: Vec<_> = request target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
fn get_file_event_for_file(&self, mut dialog: rfd::FileDialog) -> Vec<PathBuf> {
let filters: Vec<_> = self
.accept .accept
.as_deref() .as_deref()
.unwrap_or(".*") .unwrap_or(".*")
@@ -97,7 +103,7 @@ impl FileDialogRequest {
dialog = dialog.add_filter(filter_name, file_extensions.as_slice()); dialog = dialog.add_filter(filter_name, file_extensions.as_slice());
let files: Vec<_> = if request.multiple { let files: Vec<_> = if self.multiple {
dialog.pick_files().into_iter().flatten().collect() dialog.pick_files().into_iter().flatten().collect()
} else { } else {
dialog.pick_file().into_iter().collect() dialog.pick_file().into_iter().collect()
@@ -106,12 +112,20 @@ impl FileDialogRequest {
files files
} }
let dialog = rfd::FileDialog::new(); #[cfg(any(
target_os = "windows",
if self.directory { target_os = "macos",
get_file_event_for_folder(self, dialog) 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<PathBuf> {
if self.multiple {
dialog.pick_folders().into_iter().flatten().collect()
} else { } else {
get_file_event_for_file(self, dialog) dialog.pick_folder().into_iter().collect()
} }
} }
} }
@@ -154,24 +168,53 @@ impl FromStr for Filters {
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct DesktopFileUploadForm { pub(crate) struct DesktopFormData {
pub files: Vec<PathBuf>, 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<FileData> { fn files(&self) -> Vec<FileData> {
self.files self.values
.iter() .iter()
.cloned() .filter_map(|(_, v)| {
.map(|f| FileData::new(DesktopFileData(f))) if let FormValue::File(Some(f)) = v {
Some(f.clone())
} else {
None
}
})
.collect() .collect()
} }
} }
impl HasFormData for DesktopFileUploadForm { impl HasFormData for DesktopFormData {
fn as_any(&self) -> &dyn std::any::Any { fn as_any(&self) -> &dyn std::any::Any {
self 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)] #[derive(Default, Clone)]
-2
View File
@@ -57,7 +57,6 @@ pub struct IpcMessage {
/// A set of known messages that we need to respond to /// A set of known messages that we need to respond to
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub enum IpcMethod<'a> { pub enum IpcMethod<'a> {
FileDialog,
UserEvent, UserEvent,
Query, Query,
BrowserOpen, BrowserOpen,
@@ -68,7 +67,6 @@ pub enum IpcMethod<'a> {
impl IpcMessage { impl IpcMessage {
pub(crate) fn method(&self) -> IpcMethod<'_> { pub(crate) fn method(&self) -> IpcMethod<'_> {
match self.method.as_str() { match self.method.as_str() {
"file_dialog" => IpcMethod::FileDialog,
"user_event" => IpcMethod::UserEvent, "user_event" => IpcMethod::UserEvent,
"query" => IpcMethod::Query, "query" => IpcMethod::Query,
"browser_open" => IpcMethod::BrowserOpen, "browser_open" => IpcMethod::BrowserOpen,
-1
View File
@@ -91,7 +91,6 @@ pub fn launch_virtual_dom_blocking(virtual_dom: VirtualDom, mut desktop_config:
UserWindowEvent::Ipc { id, msg } => match msg.method() { UserWindowEvent::Ipc { id, msg } => match msg.method() {
IpcMethod::Initialize => app.handle_initialize_msg(id), IpcMethod::Initialize => app.handle_initialize_msg(id),
IpcMethod::FileDialog => app.handle_file_dialog_msg(msg, id),
IpcMethod::UserEvent => {} IpcMethod::UserEvent => {}
IpcMethod::Query => app.handle_query_msg(msg, id), IpcMethod::Query => app.handle_query_msg(msg, id),
IpcMethod::BrowserOpen => app.handle_browser_open(msg), IpcMethod::BrowserOpen => app.handle_browser_open(msg),
+91 -7
View File
@@ -1,5 +1,8 @@
use crate::document::NATIVE_EVAL_JS;
use crate::{assets::*, webview::WebviewEdits}; 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::unified_bindings::SLEDGEHAMMER_JS;
use dioxus_interpreter_js::NATIVE_JS; use dioxus_interpreter_js::NATIVE_JS;
use wry::{ use wry::{
@@ -8,13 +11,13 @@ use wry::{
}; };
#[cfg(target_os = "android")] #[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")] #[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")))] #[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)] #[cfg(debug_assertions)]
static DEFAULT_INDEX: &str = include_str!("./assets/dev.index.html"); 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); 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 // 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 let Some(name) = request.uri().path().split('/').nth(1) {
if asset_handlers.has_handler(name) { if asset_handlers.has_handler(name) {
@@ -104,7 +115,6 @@ fn index_request(
// Insert a custom head if provided // 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 // 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 { if let Some(head) = custom_head {
index.insert_str(index.find("</head>").expect("Head element to exist"), &head); index.insert_str(index.find("</head>").expect("Head element to exist"), &head);
} }
@@ -147,14 +157,14 @@ fn module_loader(root_id: &str, headless: bool, edit_state: &WebviewEdits) -> St
{NATIVE_JS} {NATIVE_JS}
// The native interpreter extends the sledgehammer interpreter with a few extra methods that we use for IPC // 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 // Wait for the page to load before sending the initialize message
window.onload = function() {{ window.onload = function() {{
let root_element = window.document.getElementById("{root_id}"); let root_element = window.document.getElementById("{root_id}");
if (root_element != null) {{ if (root_element != null) {{
window.interpreter.initialize(root_element); window.interpreter.initialize(root_element);
window.ipc.postMessage(window.interpreter.serializeIpcMessage("initialize")); window.interpreter.sendIpcMessage("initialize");
}} }}
window.interpreter.waitForRequest("{edits_path}", "{expected_key}"); 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<Vec<u8>>,
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(())
}
+35 -5
View File
@@ -1,5 +1,4 @@
use crate::element::DesktopElement; use crate::file_upload::{DesktopFileData, DesktopFileDragEvent};
use crate::file_upload::DesktopFileDragEvent;
use crate::menubar::DioxusMenu; use crate::menubar::DioxusMenu;
use crate::PendingDesktopContext; use crate::PendingDesktopContext;
use crate::WindowCloseBehaviour; use crate::WindowCloseBehaviour;
@@ -9,12 +8,13 @@ use crate::{
DesktopContext, DesktopService, DesktopContext, DesktopService,
}; };
use crate::{document::DesktopDocument, WeakDesktopContext}; use crate::{document::DesktopDocument, WeakDesktopContext};
use crate::{element::DesktopElement, file_upload::DesktopFormData};
use base64::prelude::BASE64_STANDARD; use base64::prelude::BASE64_STANDARD;
use dioxus_core::{consume_context, provide_context, Runtime, ScopeId, VirtualDom}; use dioxus_core::{consume_context, provide_context, Runtime, ScopeId, VirtualDom};
use dioxus_document::Document; use dioxus_document::Document;
use dioxus_history::{History, MemoryHistory}; use dioxus_history::{History, MemoryHistory};
use dioxus_hooks::to_owned; use dioxus_hooks::to_owned;
use dioxus_html::{HtmlEvent, PlatformEventData}; use dioxus_html::{FileData, FormValue, HtmlEvent, PlatformEventData};
use futures_util::{pin_mut, FutureExt}; use futures_util::{pin_mut, FutureExt};
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::{cell::OnceCell, time::Duration}; use std::{cell::OnceCell, time::Duration};
@@ -115,7 +115,7 @@ impl WebviewEdits {
let desktop_context = desktop_context.upgrade().unwrap(); let desktop_context = desktop_context.upgrade().unwrap();
let query = desktop_context.query.clone(); 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 // check for a mounted event placeholder and replace it with a desktop specific element
let as_any = match data { let as_any = match data {
@@ -123,9 +123,39 @@ impl WebviewEdits {
let element = DesktopElement::new(element, desktop_context.clone(), query.clone()); let element = DesktopElement::new(element, desktop_context.clone(), query.clone());
Rc::new(PlatformEventData::new(Box::new(element))) 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) => { dioxus_html::EventData::Drag(ref drag) => {
// we want to override this with a native file engine, provided by the most recent drag event // 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 { let paths = match file_event {
wry::DragDropEvent::Enter { paths, .. } => paths, wry::DragDropEvent::Enter { paths, .. } => paths,
wry::DragDropEvent::Drop { paths, .. } => paths, wry::DragDropEvent::Drop { paths, .. } => paths,
+81 -1
View File
@@ -1,4 +1,5 @@
use axum_core::response::IntoResponse; use axum_core::response::IntoResponse;
use futures_util::TryStreamExt;
use http::StatusCode; use http::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Debug; use std::fmt::Debug;
@@ -91,6 +92,28 @@ impl ServerFnError {
code: None, 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<anyhow::Error> for ServerFnError { impl From<anyhow::Error> for ServerFnError {
@@ -159,7 +182,64 @@ impl From<HttpError> for ServerFnError {
impl IntoResponse for ServerFnError { impl IntoResponse for ServerFnError {
fn into_response(self) -> axum_core::response::Response { 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()
})
}
}
} }
} }
+48 -5
View File
@@ -1,5 +1,9 @@
use crate::ServerFnError;
use axum_core::{extract::FromRequest, response::IntoResponse};
use dioxus_core::try_consume_context; use dioxus_core::try_consume_context;
use dioxus_signals::{ReadableExt, Signal, WritableExt}; use dioxus_signals::{ReadableExt, Signal, WritableExt};
use http::request::Parts;
use std::{cell::RefCell, rc::Rc};
/// The status of the streaming response /// The status of the streaming response
#[derive(Clone, Copy, Debug, PartialEq)] #[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 /// 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 { pub struct StreamingContext {
current_status: Signal<StreamingStatus>, current_status: Signal<StreamingStatus>,
request_headers: Rc<RefCell<http::request::Parts>>,
} }
impl Default for StreamingContext { impl PartialEq for StreamingContext {
fn default() -> Self { fn eq(&self, other: &Self) -> bool {
Self::new() self.current_status == other.current_status
&& Rc::ptr_eq(&self.request_headers, &other.request_headers)
} }
} }
impl StreamingContext { impl StreamingContext {
/// Create a new streaming context. You should not need to call this directly. Dioxus fullstack will /// Create a new streaming context. You should not need to call this directly. Dioxus fullstack will
/// provide this context for you. /// provide this context for you.
pub fn new() -> Self { pub fn new(parts: Parts) -> Self {
Self { Self {
current_status: Signal::new(StreamingStatus::RenderingInitialChunk), 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 { pub fn current_status(&self) -> StreamingStatus {
*self.current_status.read() *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<T: FromRequest<(), M>, M>() -> Result<T, ServerFnError> {
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<Self> {
if let Some(rt) = dioxus_core::Runtime::try_current() {
let id = rt.try_current_scope_id()?;
if let Some(ctx) = rt.consume_context::<StreamingContext>(id) {
return Some(ctx);
}
}
None
}
} }
/// Commit the initial chunk of the response. This will be called automatically if you are using the /// Commit the initial chunk of the response. This will be called automatically if you are using the
+23 -21
View File
@@ -305,6 +305,14 @@ fn route_impl_with_route(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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::<Vec<_>>();
let body_struct_impl = { let body_struct_impl = {
let tys = body_json_types let tys = body_json_types
.iter() .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 // there's no active request on the server, so we just create a dummy one
let server_defaults = server_args.iter().map(|arg| { let server_defaults = if server_args.is_empty() {
let name = match arg { quote! {}
FnArg::Receiver(_) => panic!("Server args cannot be receiver"), } else {
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(),
};
quote! { quote! {
let #name = { let (#(#server_names,)*) = dioxus_fullstack::StreamingContext::extract::<(#(#server_types,)*), _>().await?;
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 as_axum_path = route.to_axum_path_string(); 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( return #fn_name #ty_generics(
#(#extracted_idents,)* #(#extracted_idents,)*
@@ -1214,6 +1207,15 @@ impl PathParam {
ty, ty,
Brace(span), 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 { } else {
Self::Static(LitStr::new(str, span)) Self::Static(LitStr::new(str, span))
}; };
+4 -9
View File
@@ -13,7 +13,7 @@ use axum::{
}; };
use dioxus_core::{Element, VirtualDom}; use dioxus_core::{Element, VirtualDom};
use futures::Stream; use futures::Stream;
use http::header::*; use http::{header::*, request::Parts};
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tower::util::MapResponse; use tower::util::MapResponse;
@@ -297,13 +297,8 @@ impl RenderHandleState {
}; };
let (parts, _) = request.into_parts(); 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)) => { Ok((freshness, rx)) => {
let mut response = let mut response =
axum::response::Html::from(Body::from_stream(rx)).into_response(); axum::response::Html::from(Body::from_stream(rx)).into_response();
@@ -338,7 +333,7 @@ impl RenderHandleState {
/// Render the application to HTML. /// Render the application to HTML.
pub async fn render<'a>( pub async fn render<'a>(
&'a self, &'a self,
route: String, parts: Parts,
cfg: &'a ServeConfig, cfg: &'a ServeConfig,
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
) -> Result< ) -> Result<
@@ -350,7 +345,7 @@ impl RenderHandleState {
> { > {
self.renderers self.renderers
.clone() .clone()
.render_to(cfg, route, virtual_dom_factory) .render_to(parts, cfg, virtual_dom_factory)
.await .await
} }
} }
+14 -4
View File
@@ -17,7 +17,7 @@ use dioxus_router::ParseRouteError;
use dioxus_ssr::Renderer; use dioxus_ssr::Renderer;
use futures_channel::mpsc::Sender; use futures_channel::mpsc::Sender;
use futures_util::{Stream, StreamExt}; use futures_util::{Stream, StreamExt};
use http::StatusCode; use http::{request::Parts, StatusCode};
use std::{ use std::{
collections::HashMap, collections::HashMap,
fmt::Write, fmt::Write,
@@ -98,8 +98,8 @@ impl SsrRendererPool {
/// The streaming is canceled when the stream the function returns is dropped /// The streaming is canceled when the stream the function returns is dropped
pub(crate) async fn render_to( pub(crate) async fn render_to(
self: Arc<Self>, self: Arc<Self>,
parts: Parts,
cfg: &ServeConfig, cfg: &ServeConfig,
route: String,
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
) -> Result< ) -> 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) = let (mut into, rx) =
futures_channel::mpsc::channel::<Result<String, IncrementalRendererError>>(1000); futures_channel::mpsc::channel::<Result<String, IncrementalRendererError>>(1000);
@@ -177,9 +186,10 @@ impl SsrRendererPool {
}; };
// Provide the document and streaming context to the root of the app // 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<dyn dioxus_document::Document>); virtual_dom.provide_root_context(document.clone() as Rc<dyn dioxus_document::Document>);
virtual_dom.provide_root_context(streaming_context); virtual_dom.provide_root_context(streaming_context.clone());
virtual_dom.in_scope(ScopeId::ROOT, || { virtual_dom.in_scope(ScopeId::ROOT, || {
// Wrap the memory history in a fullstack history provider to provide the initial route for hydration // Wrap the memory history in a fullstack history provider to provide the initial route for hydration
+1 -1
View File
@@ -37,7 +37,7 @@ serde_json = { workspace = true }
pin-project = { workspace = true } pin-project = { workspace = true }
serde_qs = { workspace = true, default-features = true} serde_qs = { workspace = true, default-features = true}
mime = "0.3.17" 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 } url = { workspace = true }
serde_urlencoded = { workspace = true } serde_urlencoded = { workspace = true }
form_urlencoded = { workspace = true } form_urlencoded = { workspace = true }
+43
View File
@@ -161,6 +161,49 @@ impl ClientRequest {
builder 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<ClientResponse, RequestError> {
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<ClientResponse, RequestError> { pub async fn send_form(self, data: &impl Serialize) -> Result<ClientResponse, RequestError> {
// For GET and HEAD requests, we encode the form data as query parameters. // 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. // For other request methods, we encode the form data as the request body.
+5 -1
View File
@@ -95,7 +95,11 @@ impl<S> IntoRequest for MultipartFormData<S> {
// and construct a multipart form body manually. // and construct a multipart form body manually.
#[cfg(not(target_arch = "wasm32"))] #[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") unimplemented!("Non web wasm32 clients are not supported yet")
+184 -141
View File
@@ -1,77 +1,16 @@
use crate::file_data::HasFileData; use crate::file_data::HasFileData;
use crate::FileData; use crate::FileData;
use std::{collections::HashMap, fmt::Debug, ops::Deref}; use std::fmt::Debug;
#[cfg(feature = "serialize")]
use crate::SerializedFileData;
use dioxus_core::Event; use dioxus_core::Event;
pub type FormEvent = Event<FormData>; pub type FormEvent = Event<FormData>;
/// 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<String>);
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<String>`]
pub fn to_vec(self) -> Vec<String> {
self.0.clone()
}
}
impl PartialEq<str> 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 */ /* DOMEvent: Send + SyncTarget relatedTarget */
pub struct FormData { pub struct FormData {
inner: Box<dyn HasFormData>, inner: Box<dyn HasFormData>,
} }
impl<E: HasFormData> From<E> 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 { impl FormData {
/// Create a new form event /// Create a new form event
pub fn new(event: impl HasFormData + 'static) -> Self { pub fn new(event: impl HasFormData + 'static) -> Self {
@@ -104,10 +43,25 @@ impl FormData {
/// Collect all the named form values from the containing form. /// Collect all the named form values from the containing form.
/// ///
/// Every input must be named! /// Every input must be named!
pub fn values(&self) -> HashMap<String, FormValue> { pub fn values(&self) -> Vec<(String, FormValue)> {
self.inner.values() self.inner.values()
} }
/// Get the first value with the given name
pub fn get_first(&self, name: &str) -> Option<FormValue> {
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<FormValue> {
self.values()
.into_iter()
.filter_map(|(k, v)| if k == name { Some(v) } else { None })
.collect()
}
/// Get the files of the form event /// Get the files of the form event
pub fn files(&self) -> Vec<FileData> { pub fn files(&self) -> Vec<FileData> {
self.inner.files() self.inner.files()
@@ -125,128 +79,225 @@ 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<String, FormValue> {
Default::default()
}
/// return self as Any
fn as_any(&self) -> &dyn std::any::Any;
}
impl FormData { impl FormData {
#[cfg(feature = "serialize")]
/// Parse the values into a struct with one field per value /// Parse the values into a struct with one field per value
#[cfg(feature = "serialize")]
pub fn parsed_values<T>(&self) -> Result<T, serde_json::Error> pub fn parsed_values<T>(&self) -> Result<T, serde_json::Error>
where where
T: serde::de::DeserializeOwned, T: serde::de::DeserializeOwned,
{ {
use crate::SerializedFileData;
let values = &self.values(); let values = &self.values();
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for (key, value) in values { for (key, value) in values {
if value.0.len() == 1 { let entry = map
map.insert(key.clone(), serde_json::Value::String(value.0[0].clone())); .entry(key.clone())
} else { .or_insert_with(|| serde_json::Value::Array(Vec::new()));
let arr = value
.0 match value {
.iter() FormValue::Text(text) => {
.cloned() entry
.map(serde_json::Value::String) .as_array_mut()
.collect(); .expect("entry should be an array")
map.insert(key.clone(), serde_json::Value::Array(arr)); .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),
);
} }
} }
}
// 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())
}
_ => (k, v),
})
.collect::<serde_json::Map<String, serde_json::Value>>();
serde_json::from_value(serde_json::Value::Object(map)) serde_json::from_value(serde_json::Value::Object(map))
} }
} }
impl HasFileData for FormData {
fn files(&self) -> Vec<FileData> {
self.inner.files()
}
}
impl<E: HasFormData> From<E> 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<FileData>),
}
impl PartialEq<str> 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")] #[cfg(feature = "serialize")]
pub use serialize::*;
#[cfg(feature = "serialize")]
mod serialize {
use crate::SerializedFileData;
use super::*;
/// A serialized form data object /// A serialized form data object
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
pub struct SerializedFormData { pub struct SerializedFormData {
#[serde(default)] #[serde(default)]
value: String, pub value: String,
#[serde(default)] #[serde(default)]
values: HashMap<String, FormValue>, pub values: Vec<SerializedFormObject>,
#[serde(default)] #[serde(default)]
valid: bool, pub valid: bool,
}
#[serde(default)] #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
files: Vec<SerializedFileData>, pub struct SerializedFormObject {
pub key: String,
pub text: Option<String>,
pub file: Option<SerializedFileData>,
} }
#[cfg(feature = "serialize")] #[cfg(feature = "serialize")]
impl SerializedFormData { impl SerializedFormData {
/// Create a new serialized form data object /// Create a new serialized form data object
pub fn new(value: String, values: HashMap<String, FormValue>) -> Self { pub fn new(value: String, values: Vec<SerializedFormObject>) -> Self {
Self { Self {
value, value,
values, values,
valid: true, valid: true,
files: vec![],
} }
} }
/// Add files to the serialized form data object /// Create a serialized form data object from a form data object
pub fn with_files(mut self, files: Vec<SerializedFileData>) -> Self { fn from_form_lossy(data: &FormData) -> Self {
self.files = files; if let Some(data) = data.downcast::<SerializedFormData>() {
self return data.clone();
} }
/// Create a new serialized form data object from a traditional form data object let values = data
pub async fn async_from(data: &FormData) -> Result<Self, dioxus_core::Error> { .values()
Ok(Self { .iter()
value: data.value(), .map(|(key, value)| match value {
values: data.values(), FormValue::Text(s) => SerializedFormObject {
valid: data.valid(), key: key.clone(),
files: { text: Some(s.to_string()),
let mut out = vec![]; file: None,
for file in data.files() { },
out.push(SerializedFileData { FormValue::File(f) => SerializedFormObject {
name: file.name(), key: key.clone(),
size: file.size(), text: None,
content_type: file.content_type(), file: if let Some(f) = f {
last_modified: file.last_modified(), Some(SerializedFileData {
path: file.pathbuf(), path: f.path(),
contents: file.read_bytes().await?, size: f.size(),
}); last_modified: f.last_modified(),
} content_type: f.content_type(),
out contents: None,
})
} else {
Some(SerializedFileData::empty())
},
}, },
}) })
} .collect();
fn from_lossy(data: &FormData) -> Self {
Self { Self {
values,
value: data.value(), value: data.value(),
values: data.values(),
valid: data.valid(), valid: data.valid(),
files: vec![],
} }
} }
} }
#[cfg(feature = "serialize")]
impl HasFormData for SerializedFormData { impl HasFormData for SerializedFormData {
fn value(&self) -> String { fn value(&self) -> String {
self.value.clone() self.value.clone()
} }
fn values(&self) -> HashMap<String, FormValue> { fn values(&self) -> Vec<(String, FormValue)> {
self.values.clone() todo!()
} }
fn valid(&self) -> bool { fn valid(&self) -> bool {
@@ -258,30 +309,21 @@ impl HasFormData for SerializedFormData {
} }
} }
#[cfg(feature = "serialize")]
impl HasFileData for SerializedFormData { impl HasFileData for SerializedFormData {
fn files(&self) -> Vec<FileData> { fn files(&self) -> Vec<FileData> {
self.files self.values
.iter() .iter()
.map(|f| FileData::new(f.clone())) .filter_map(|v| v.file.as_ref().map(|f| FileData::new(f.clone())))
.collect() .collect()
} }
} }
impl HasFileData for FormData {
fn files(&self) -> Vec<FileData> {
self.inner.files()
}
}
#[cfg(feature = "serialize")]
impl serde::Serialize for FormData { impl serde::Serialize for FormData {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
SerializedFormData::from_lossy(self).serialize(serializer) SerializedFormData::from_form_lossy(self).serialize(serializer)
} }
} }
#[cfg(feature = "serialize")]
impl<'de> serde::Deserialize<'de> for FormData { impl<'de> serde::Deserialize<'de> for FormData {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let data = SerializedFormData::deserialize(deserializer)?; let data = SerializedFormData::deserialize(deserializer)?;
@@ -290,6 +332,7 @@ impl<'de> serde::Deserialize<'de> for FormData {
}) })
} }
} }
}
impl_event! { impl_event! {
FormData; FormData;
+95 -21
View File
@@ -1,7 +1,6 @@
use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future};
use bytes::Bytes; use bytes::Bytes;
use futures_util::Stream; use futures_util::Stream;
use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future};
#[derive(Clone)] #[derive(Clone)]
pub struct FileData { pub struct FileData {
@@ -49,11 +48,20 @@ impl FileData {
self.inner.inner() self.inner.inner()
} }
pub fn pathbuf(&self) -> PathBuf { pub fn path(&self) -> PathBuf {
self.inner.path() 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 { pub trait NativeFileData: Send + Sync {
fn name(&self) -> String; fn name(&self) -> String;
fn size(&self) -> u64; fn size(&self) -> u64;
@@ -82,34 +90,47 @@ 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 { pub trait HasFileData: std::any::Any {
fn files(&self) -> Vec<FileData>; fn files(&self) -> Vec<FileData>;
} }
/// A serializable representation of file data\
#[cfg(feature = "serialize")] #[cfg(feature = "serialize")]
pub use serialize::*;
#[cfg(feature = "serialize")]
mod serialize {
use super::*;
/// A serializable representation of file data
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
pub struct SerializedFileData { pub struct SerializedFileData {
pub name: String,
pub path: PathBuf, pub path: PathBuf,
pub size: u64, pub size: u64,
pub last_modified: u64, pub last_modified: u64,
pub content_type: Option<String>, pub content_type: Option<String>,
pub contents: bytes::Bytes, pub contents: Option<bytes::Bytes>,
}
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,
}
}
} }
#[cfg(feature = "serialize")]
impl NativeFileData for SerializedFileData { impl NativeFileData for SerializedFileData {
fn name(&self) -> String { fn name(&self) -> String {
self.name.clone() self.path()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
} }
fn size(&self) -> u64 { fn size(&self) -> u64 {
@@ -124,22 +145,64 @@ impl NativeFileData for SerializedFileData {
&self, &self,
) -> Pin<Box<dyn Future<Output = Result<Bytes, dioxus_core::Error>> + 'static>> { ) -> Pin<Box<dyn Future<Output = Result<Bytes, dioxus_core::Error>> + 'static>> {
let contents = self.contents.clone(); let contents = self.contents.clone();
Box::pin(async move { Ok(contents) }) 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( fn read_string(
&self, &self,
) -> Pin<Box<dyn Future<Output = Result<String, dioxus_core::Error>> + 'static>> { ) -> Pin<Box<dyn Future<Output = Result<String, dioxus_core::Error>> + 'static>> {
let contents = self.contents.clone(); let contents = self.contents.clone();
Box::pin(async move { Ok(String::from_utf8(contents.to_vec())?) }) 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( fn byte_stream(
&self, &self,
) -> Pin<Box<dyn futures_util::Stream<Item = Result<Bytes, dioxus_core::Error>> + 'static + Send>> ) -> Pin<
{ Box<
dyn futures_util::Stream<Item = Result<Bytes, dioxus_core::Error>> + 'static + Send,
>,
> {
let contents = self.contents.clone(); let contents = self.contents.clone();
Box::pin(futures_util::stream::once(async move { Ok(contents) })) 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 { fn inner(&self) -> &dyn std::any::Any {
@@ -154,3 +217,14 @@ impl NativeFileData for SerializedFileData {
self.content_type.clone() self.content_type.clone()
} }
} }
impl<'de> serde::Deserialize<'de> for FileData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let sfd = SerializedFileData::deserialize(deserializer)?;
Ok(FileData::new(sfd))
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ fn main() {
// If any TS files change, re-run the build script // If any TS files change, re-run the build script
lazy_js_bundle::LazyTypeScriptBindings::new() lazy_js_bundle::LazyTypeScriptBindings::new()
.with_watching("./src/ts") .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/native.ts", "./src/js/native.js")
.with_binding("./src/ts/core.ts", "./src/js/core.js") .with_binding("./src/ts/core.ts", "./src/js/core.js")
.with_binding("./src/ts/hydrate.ts", "./src/js/hydrate.js") .with_binding("./src/ts/hydrate.ts", "./src/js/hydrate.js")
+1 -1
View File
@@ -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<node.style.length;i++){let prop=node.style[i];existingStyles[prop]=node.style.getPropertyValue(prop)}node.setAttribute(field,value);for(let prop in existingStyles)if(!node.style.getPropertyValue(prop))node.style.setProperty(prop,existingStyles[prop]);break;case"multiple":if(setAttributeDefault(node,field,value),node.options!==null&&node.options!==void 0){let options=node.options;for(let option of options)option.selected=option.defaultSelected}break;default:setAttributeDefault(node,field,value)}}function setAttributeDefault(node,field,value){if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}function truthy(val){return val==="true"||val===!0}function isBoolAttr(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}}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}}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<node.style.length;i++){let prop=node.style[i];existingStyles[prop]=node.style.getPropertyValue(prop)}node.setAttribute(field,value);for(let prop in existingStyles)if(!node.style.getPropertyValue(prop))node.style.setProperty(prop,existingStyles[prop]);break;case"multiple":if(setAttributeDefault(node,field,value),node.options!==null&&node.options!==void 0){let options=node.options;for(let option of options)option.selected=option.defaultSelected}break;default:setAttributeDefault(node,field,value)}}function setAttributeDefault(node,field,value){if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}function truthy(val){return val==="true"||val===!0}function isBoolAttr(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}}function retrieveFormValues(form){let formData=new FormData(form),contents=[];return formData.forEach((value,key)=>{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};
+1 -1
View File
@@ -1 +1 @@
[6449103750905854967, 17669692872757955279, 4701826235737143966, 11420464406527728232, 3770103091118609057, 5444526391971481782, 14692042304579321130, 5052021921702764563, 7041565893588933645, 11339769846046015954] [17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 8889858244860485542, 5052021921702764563, 5113938988622536969, 11339769846046015954]
+1 -1
View File
@@ -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;i<options.length;i++)values.push(options[i].value);return values}function serializeEvent(event,target){let contents={},extend=(obj)=>contents={...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;i<touchList.length;i++){let touch=touchLisLine truncated function serializeEvent(event,target){let contents={},extend=(obj)=>contents={...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;i<event.dataTransfer.files.length;i++){let file=event.dataTransfer.files[i],data={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")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=extractSerializedFormValues(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;i<touchList.length;i++){let touch=touchList[i];serializedTouches.push({identifier:touch.identifier,client_x:touch.clientX,client_y:touch.clientY,page_x:touch.pageX,page_y:touch.pageY,screen_x:touch.screenX,screen_y:touch.screenY,radius_x:touch.radiusX,radius_y:touch.radiusY,rotation_angle:touch.rotationAngle,force:touch.force})}return serializedTouches}function serializeMouseEvent(event){return{alt_key:event.altKey,button:event.button,buttons:event.buttonsLine truncated
@@ -0,0 +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<node.style.length;i++){let prop=node.style[i];existingStyles[prop]=node.style.getPropertyValue(prop)}node.setAttribute(field,value);for(let prop in existingStyles)if(!node.style.getPropertyValue(prop))node.style.setProperty(prop,existingStyles[prop]);break;case"multiple":if(setAttributeDefault(node,field,value),node.options!==null&&node.options!==void 0){let options=node.options;for(let option of options)option.selected=option.defaultSelected}break;default:setAttributeDefault(node,field,value)}}function setAttributeDefault(node,field,value){if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}function truthy(val){return val==="true"||val===!0}function isBoolAttr(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}}export{setAttributeInner};
+1 -4
View File
@@ -32,13 +32,10 @@ pub mod minimal_bindings {
/// Some useful snippets that we use to share common functionality between the different platforms we support. /// Some useful snippets that we use to share common functionality between the different platforms we support.
/// ///
/// This maintains some sort of consistency between web, desktop, and liveview /// This maintains some sort of consistency between web, desktop, and liveview
#[wasm_bindgen(module = "/src/js/common.js")] #[wasm_bindgen(module = "/src/js/set_attribute.js")]
extern "C" { extern "C" {
/// Set the attribute of the node /// Set the attribute of the node
pub fn setAttributeInner(node: JsValue, name: &str, value: JsValue, ns: Option<&str>); pub fn setAttributeInner(node: JsValue, name: &str, value: JsValue, ns: Option<&str>);
/// 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")] #[wasm_bindgen(module = "/src/js/hydrate.js")]
-2
View File
@@ -1,2 +0,0 @@
export { setAttributeInner } from "./set_attribute";
export { retrieveFormValues } from "./form";
-63
View File
@@ -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;
}
+103 -21
View File
@@ -4,7 +4,7 @@
// provide since it doesn't have access to the dom. // provide since it doesn't have access to the dom.
import { BaseInterpreter, NodeId } from "./core"; 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 // 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, // 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; intercept_link_redirects: boolean;
ipc: any; ipc: any;
edits: WebSocket; edits: WebSocket;
baseUri: string;
eventsPath: string; eventsPath: string;
headless: boolean; headless: boolean;
kickStylesheets: 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 // however, for now we need to support it since WebSockets in fullstack doesn't exist yet
liveview: boolean; liveview: boolean;
constructor(eventsPath: string, headless: boolean) { constructor(baseUri: string, headless: boolean) {
super(); super();
this.eventsPath = eventsPath; this.baseUri = baseUri;
this.eventsPath = `${baseUri}/__events`;
this.kickStylesheets = false; this.kickStylesheets = false;
this.headless = headless; 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 // 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); let target_id = getTargetId(target);
if (target_id !== null) { if (target_id !== null) {
const message = this.serializeIpcMessage("file_dialog", { // 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", event: "change&input",
accept: target.getAttribute("accept"), accept: target.getAttribute("accept"),
directory: target.getAttribute("webkitdirectory") === "true", directory: target.getAttribute("webkitdirectory") === "true",
multiple: target.hasAttribute("multiple"), multiple: target.hasAttribute("multiple"),
target: target_id, target: target_id,
bubbles: event.bubbles, 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,
}); });
this.ipc.postMessage(message); dataTransfer.items.add(file);
event.preventDefault(); }
}
// 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 // @ts-ignore - wry gives us this
this.ipc = window.ipc; this.ipc = window.ipc;
@@ -103,8 +161,23 @@ export class NativeInterpreter extends JSChannel_ {
super.initialize(root, handler); super.initialize(root, handler);
} }
serializeIpcMessage(method: string, params = {}) { fetchAgainstHost(path: string, data: { [key: string]: any }): Promise<Response> {
return JSON.stringify({ method, params }); 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 { 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. // Windows drag-n-drop fix code. Called by wry drag-n-drop handler over the event loop.
handleWindowsDragDrop() { handleWindowsDragDrop() {
// @ts-ignore
if (window.dxDragLastElement) { if (window.dxDragLastElement) {
const dragLeaveEvent = new DragEvent("dragleave", { const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragLeaveEvent); window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
let data = new DataTransfer(); let data = new DataTransfer();
@@ -199,23 +275,29 @@ export class NativeInterpreter extends JSChannel_ {
cancelable: true, cancelable: true,
dataTransfer: data, dataTransfer: data,
}); });
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragDropEvent); window.dxDragLastElement.dispatchEvent(dragDropEvent);
// @ts-ignore
window.dxDragLastElement = null; window.dxDragLastElement = null;
} }
} }
handleWindowsDragOver(xPos, yPos) { handleWindowsDragOver(xPos: number, yPos: number) {
const displayScaleFactor = window.devicePixelRatio || 1; const displayScaleFactor = window.devicePixelRatio || 1;
xPos /= displayScaleFactor; xPos /= displayScaleFactor;
yPos /= displayScaleFactor; yPos /= displayScaleFactor;
const element = document.elementFromPoint(xPos, yPos); const element = document.elementFromPoint(xPos, yPos);
// @ts-ignore
if (element != window.dxDragLastElement) { if (element != window.dxDragLastElement) {
// @ts-ignore
if (window.dxDragLastElement) { if (window.dxDragLastElement) {
const dragLeaveEvent = new DragEvent("dragleave", { const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragLeaveEvent); window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
} }
@@ -224,17 +306,21 @@ export class NativeInterpreter extends JSChannel_ {
cancelable: true, cancelable: true,
}); });
element.dispatchEvent(dragOverEvent); element.dispatchEvent(dragOverEvent);
// @ts-ignore
window.dxDragLastElement = element; window.dxDragLastElement = element;
} }
} }
handleWindowsDragLeave() { handleWindowsDragLeave() {
// @ts-ignore
if (window.dxDragLastElement) { if (window.dxDragLastElement) {
const dragLeaveEvent = new DragEvent("dragleave", { const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragLeaveEvent); window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
// @ts-ignore
window.dxDragLastElement = null; window.dxDragLastElement = null;
} }
} }
@@ -266,31 +352,30 @@ export class NativeInterpreter extends JSChannel_ {
handleEvent(event: Event, name: string, bubbles: boolean) { handleEvent(event: Event, name: string, bubbles: boolean) {
const target = event.target!; const target = event.target!;
const realId = getTargetId(target)!; const element = getTargetId(target)!;
const contents = serializeEvent(event, target); const contents = serializeEvent(event, target);
// Handle the event on the virtualdom and then preventDefault if it also preventsDefault // Handle the event on the virtualdom and then preventDefault if it also preventsDefault
// Some listeners // Some listeners
let body = { let body = {
name: name, name,
data: contents, data: contents,
element: realId, element,
bubbles, bubbles,
}; };
// liveview does not have synchronous event handling, so we need to send the event to the host // 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 ( if (
this.liveview &&
target instanceof HTMLInputElement && target instanceof HTMLInputElement &&
(event.type === "change" || event.type === "input") (event.type === "change" || event.type === "input")
) { ) {
if (target.getAttribute("type") === "file") { if (target.getAttribute("type") === "file") {
this.readFiles(target, contents, bubbles, realId, name); this.readFiles(target, contents, bubbles, element, name);
return; return;
} }
} }
}
const response = this.sendSerializedEvent(body); const response = this.sendSerializedEvent(body);
// capture/prevent default of the event if the virtualdom wants to // capture/prevent default of the event if the virtualdom wants to
if (response) { if (response) {
@@ -316,8 +401,7 @@ export class NativeInterpreter extends JSChannel_ {
bubbles: boolean; bubbles: boolean;
}): EventSyncResult | void { }): EventSyncResult | void {
if (this.liveview) { if (this.liveview) {
const message = this.serializeIpcMessage("user_event", body); this.sendIpcMessage("user_event", body);
this.ipc.postMessage(message);
} else { } else {
// Run the event handler on the virtualdom // Run the event handler on the virtualdom
return handleVirtualdomEventSync(this.eventsPath, JSON.stringify(body)); return handleVirtualdomEventSync(this.eventsPath, JSON.stringify(body));
@@ -337,9 +421,7 @@ export class NativeInterpreter extends JSChannel_ {
const href = a_element.getAttribute("href"); const href = a_element.getAttribute("href");
if (href !== "" && href !== null && href !== undefined) { if (href !== "" && href !== null && href !== undefined) {
this.ipc.postMessage( this.sendIpcMessage("browser_open", { href })
this.serializeIpcMessage("browser_open", { href })
);
} }
} }
} }
+103 -22
View File
@@ -1,11 +1,13 @@
// Handle serialization of the event data across the IPC boundarytype SerializedEvent = {}; // Handle serialization of the event data across the IPC boundary
//
import { retrieveSelectValue, retrieveValues } from "./form"; // 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 AppTouchEvent = TouchEvent;
export type SerializedEvent = { export type SerializedEvent = {
values?: { [key: string]: FormDataEntryValue[] }; values?: SerializedFormObject[];
value?: string; value?: string;
[key: string]: any; [key: string]: any;
}; };
@@ -17,7 +19,7 @@ export function serializeEvent(
let contents = {}; let contents = {};
// merge the object into the 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) { if (event instanceof WheelEvent) {
extend(serializeWheelEvent(event)); extend(serializeWheelEvent(event));
@@ -84,22 +86,20 @@ export function serializeEvent(
// If there's any files, we need to serialize them // If there's any files, we need to serialize them
if (event instanceof DragEvent) { if (event instanceof DragEvent) {
// let files: { [key: string]: Uint8Array } = {}; let files: SerializedFormObject[] = [];
// if (event.dataTransfer && event.dataTransfer.files) { if (event.dataTransfer && event.dataTransfer.files) {
// files["a"] = new Uint8Array(0); for (let i = 0; i < event.dataTransfer.files.length; i++) {
// // files = { let file = event.dataTransfer.files[i];
// // entries: Array.from(event.dataTransfer.files).map((file) => { let data: SerializedFileData = {
// // return { path: file.name,
// // name: file.name, size: file.size,
// // type: file.type, last_modified: file.lastModified,
// // size: file.size, content_type: file.type,
// // last_modified: file.lastModified, }
// // }; files.push({ key: file.name, file: data });
// // } }
// // }; }
// // files = await serializeFileList(event.dataTransfer.files); extend({ files });
// }
// extend({ files: files });
} }
if (event.type === "scroll") { if (event.type === "scroll") {
@@ -173,7 +173,7 @@ function serializeInputEvent(
// Attempt to retrieve the values from the form // Attempt to retrieve the values from the form
if (target instanceof HTMLElement) { if (target instanceof HTMLElement) {
let values = retrieveValues(event, target); let values = extractSerializedFormValues(event, target);
contents.values = values.values; contents.values = values.values;
contents.valid = values.valid; contents.valid = values.valid;
} }
@@ -377,3 +377,84 @@ function serializeScrollEvent(event: Event): SerializedEvent {
client_height: clientHeight, client_height: clientHeight,
}; };
} }
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;
}
+2 -2
View File
@@ -14,7 +14,7 @@ use futures_util::{pin_mut, FutureExt};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::task::{Context as TaskContext, Waker}; 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<T: Any>(value: T) -> Rc<dyn Any> { fn wrap_event_data<T: Any>(value: T) -> Rc<dyn Any> {
Rc::new(PlatformEventData::new(Box::new(value))) Rc::new(PlatformEventData::new(Box::new(value)))
@@ -262,7 +262,7 @@ impl EventHandler for DioxusEventHandler<'_> {
DomEventData::Input(data) => Some(wrap_event_data(NativeFormData { DomEventData::Input(data) => Some(wrap_event_data(NativeFormData {
value: data.value.clone(), value: data.value.clone(),
values: HashMap::new(), values: vec![],
})), })),
// TODO: Implement IME handling // TODO: Implement IME handling
+6 -3
View File
@@ -13,7 +13,6 @@ use dioxus_html::{
}; };
use keyboard_types::{Code, Key, Location, Modifiers}; use keyboard_types::{Code, Key, Location, Modifiers};
use std::any::Any; use std::any::Any;
use std::collections::HashMap;
pub struct NativeConverter {} pub struct NativeConverter {}
@@ -110,7 +109,7 @@ impl HtmlEventConverter for NativeConverter {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct NativeFormData { pub struct NativeFormData {
pub value: String, pub value: String,
pub values: HashMap<String, FormValue>, pub values: Vec<(String, FormValue)>,
} }
impl HasFormData for NativeFormData { impl HasFormData for NativeFormData {
@@ -122,9 +121,13 @@ impl HasFormData for NativeFormData {
self.value.clone() self.value.clone()
} }
fn values(&self) -> HashMap<String, FormValue> { fn values(&self) -> Vec<(String, FormValue)> {
self.values.clone() self.values.clone()
} }
fn valid(&self) -> bool {
// todo: actually implement validation here.
true
}
} }
impl HasFileData for NativeFormData { impl HasFileData for NativeFormData {
+2 -1
View File
@@ -85,7 +85,8 @@ features = [
"File", "File",
"FileList", "FileList",
"FileReader", "FileReader",
"ReadableStream" "ReadableStream",
"FormData"
] ]
[build-dependencies] [build-dependencies]
+34 -40
View File
@@ -1,12 +1,10 @@
use std::{any::Any, collections::HashMap}; use super::WebEventExt;
use crate::WebFileData;
use dioxus_html::{FileData, FormValue, HasFileData, HasFormData}; use dioxus_html::{FileData, FormValue, HasFileData, HasFormData};
use js_sys::Array; use js_sys::Array;
use wasm_bindgen::JsValue; use std::any::Any;
use wasm_bindgen::{prelude::wasm_bindgen, JsCast}; use wasm_bindgen::{prelude::wasm_bindgen, JsCast};
use web_sys::{Element, Event}; use web_sys::{Element, Event, FileReader};
use super::WebEventExt;
pub(crate) struct WebFormData { pub(crate) struct WebFormData {
element: Element, 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") .expect("only an InputElement or TextAreaElement or an element with contenteditable=true can have an oninput event listener")
} }
fn values(&self) -> HashMap<String, FormValue> { fn values(&self) -> Vec<(String, FormValue)> {
let mut values = HashMap::new(); let mut values = Vec::new();
fn insert_value(map: &mut HashMap<String, FormValue>, key: String, new_value: String) {
map.entry(key.clone()).or_default().0.push(new_value);
}
// try to fill in form values // try to fill in form values
if let Some(form) = self.element.dyn_ref::<web_sys::HtmlFormElement>() { if let Some(form) = self.element.dyn_ref::<web_sys::HtmlFormElement>() {
let form_data = get_form_data(form); let form_data = web_sys::FormData::new_with_form(form).unwrap();
for value in form_data.entries().into_iter().flatten() {
if let Ok(array) = value.dyn_into::<Array>() { for entry in form_data.entries().into_iter().flatten() {
if let Ok(array) = entry.dyn_into::<Array>() {
if let Some(name) = array.get(0).as_string() { if let Some(name) = array.get(0).as_string() {
if let Ok(item_values) = array.get(1).dyn_into::<Array>() { let value = array.get(1);
item_values if let Some(file) = value.dyn_ref::<web_sys::File>() {
.iter() if file.name().is_empty() {
.filter_map(|v| v.as_string()) values.push((name, FormValue::File(None)));
.for_each(|v| insert_value(&mut values, name.clone(), v)); } else {
} else if let Ok(item_value) = array.get(1).dyn_into::<JsValue>() { let data =
insert_value(&mut values, name, item_value.as_string().unwrap()); 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::<web_sys::HtmlSelectElement>() { } else if let Some(select) = self.element.dyn_ref::<web_sys::HtmlSelectElement>() {
// try to fill in select element values // try to fill in select element values
let options = get_select_data(select); 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 values
@@ -103,6 +106,14 @@ impl HasFormData for WebFormData {
fn as_any(&self) -> &dyn Any { fn as_any(&self) -> &dyn Any {
&self.event as &dyn Any &self.event as &dyn Any
} }
fn valid(&self) -> bool {
self.event
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|input| input.check_validity())
.unwrap_or(true)
}
} }
impl HasFileData for WebFormData { impl HasFileData for WebFormData {
@@ -135,20 +146,3 @@ export function get_select_data(select) {
extern "C" { extern "C" {
fn get_select_data(select: &web_sys::HtmlSelectElement) -> Vec<String>; fn get_select_data(select: &web_sys::HtmlSelectElement) -> Vec<String>;
} }
// 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;
}