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:
1 parent
fc01a002fd
commit
92792aded9
47 files changed
+1157
-619
No files matched your search
Generated
+1
@@ -14089,6 +14089,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
|
||||
@@ -52,11 +52,14 @@ fn app() -> Element {
|
||||
#[post("/api/upload-multipart")]
|
||||
async fn upload(mut form: MultipartFormData) -> Result<()> {
|
||||
while let Ok(Some(field)) = form.next_field().await {
|
||||
let name = field.name().unwrap_or("<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!(
|
||||
"Field name: {:?}, filename: {:?}, content_type: {:?}",
|
||||
field.name().to_owned(),
|
||||
field.file_name(),
|
||||
field.content_type(),
|
||||
"Field name: {:?}, filename: {:?}, content_type: {:?}, size: {:?}",
|
||||
name, file_name, content_type, size
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ Option 1:
|
||||
For simple, synchronous, thread-safe data, we can use statics with atomic types or mutexes.
|
||||
The `LazyLock` type from the standard library is a great choice for simple, synchronous data
|
||||
*/
|
||||
#[cfg(feature = "server")]
|
||||
static MESSAGES: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
#[post("/api/messages")]
|
||||
|
||||
@@ -4,15 +4,14 @@
|
||||
//! in the "values" field.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut values = use_signal(HashMap::new);
|
||||
let mut submitted_values = use_signal(HashMap::new);
|
||||
let mut values = use_signal(Vec::new);
|
||||
let mut submitted_values = use_signal(Vec::new);
|
||||
|
||||
rsx! {
|
||||
div { style: "display: flex",
|
||||
@@ -63,7 +62,7 @@ fn app() -> Element {
|
||||
input { r#type: "text", name: "full-name" }
|
||||
|
||||
label { r#for: "email", "Email (matching <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" }
|
||||
input { r#type: "password", name: "password" }
|
||||
@@ -122,6 +121,9 @@ fn app() -> Element {
|
||||
input { r#type: "checkbox", name: "cbox", value: "yellow", id: "cbox-yellow" }
|
||||
}
|
||||
|
||||
// File input
|
||||
label { r#for: "headshot", "Headshot" }
|
||||
input { r#type: "file", name: "headshot", id: "headshot", multiple: true, accept: ".png,.jpg,.jpeg" }
|
||||
|
||||
// Buttons will submit your form by default.
|
||||
button { r#type: "submit", value: "Submit", "Submit the form" }
|
||||
|
||||
@@ -154,19 +154,20 @@ impl AppBuilder {
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
builder.start(mode);
|
||||
builder.start(mode, build_id);
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
pub(crate) fn start(&mut self, mode: BuildMode) {
|
||||
pub(crate) fn start(&mut self, mode: BuildMode, build_id: BuildId) {
|
||||
self.build_task = tokio::spawn({
|
||||
let request = self.build.clone();
|
||||
let tx = self.tx.clone();
|
||||
async move {
|
||||
let ctx = BuildContext {
|
||||
mode,
|
||||
build_id,
|
||||
tx: tx.clone(),
|
||||
};
|
||||
request.verify_tooling(&ctx).await?;
|
||||
@@ -290,10 +291,12 @@ impl AppBuilder {
|
||||
update
|
||||
}
|
||||
|
||||
pub(crate) fn patch_rebuild(&mut self, changed_files: Vec<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
|
||||
let Some(artifacts) = self.artifacts.as_ref().cloned() else {
|
||||
tracing::warn!("Ignoring patch rebuild since there is no existing build.");
|
||||
tracing::warn!(
|
||||
"Ignoring patch rebuild for {build_id:?} since there is no existing build."
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -327,6 +330,7 @@ impl AppBuilder {
|
||||
self.build_task = tokio::spawn({
|
||||
let request = self.build.clone();
|
||||
let ctx = BuildContext {
|
||||
build_id,
|
||||
tx: self.tx.clone(),
|
||||
mode: BuildMode::Thin {
|
||||
changed_files,
|
||||
@@ -340,7 +344,7 @@ impl AppBuilder {
|
||||
}
|
||||
|
||||
/// Restart this builder with new build arguments.
|
||||
pub(crate) fn start_rebuild(&mut self, mode: BuildMode) {
|
||||
pub(crate) fn start_rebuild(&mut self, mode: BuildMode, build_id: BuildId) {
|
||||
// Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit
|
||||
// And then start a new build, resetting our progress/stage to the beginning and replacing the old tokio task
|
||||
self.abort_all(BuildStage::Restarting);
|
||||
@@ -351,6 +355,7 @@ impl AppBuilder {
|
||||
let ctx = BuildContext {
|
||||
tx: self.tx.clone(),
|
||||
mode,
|
||||
build_id,
|
||||
};
|
||||
async move { request.build(&ctx).await }
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::{path::PathBuf, process::ExitStatus};
|
||||
pub struct BuildContext {
|
||||
pub tx: ProgressTx,
|
||||
pub mode: BuildMode,
|
||||
pub build_id: BuildId,
|
||||
}
|
||||
|
||||
pub type ProgressTx = UnboundedSender<BuilderUpdate>;
|
||||
@@ -24,8 +25,8 @@ pub type ProgressRx = UnboundedReceiver<BuilderUpdate>;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct BuildId(pub(crate) usize);
|
||||
impl BuildId {
|
||||
pub const CLIENT: Self = Self(0);
|
||||
pub const SERVER: Self = Self(1);
|
||||
pub const PRIMARY: Self = Self(0);
|
||||
pub const SECONDARY: Self = Self(1);
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -78,6 +79,12 @@ pub enum BuilderUpdate {
|
||||
}
|
||||
|
||||
impl BuildContext {
|
||||
/// Returns true if this is a client build - basically, is this the primary build?
|
||||
/// We try not to duplicate work between client and server builds, like asset copying.
|
||||
pub(crate) fn is_primary_build(&self) -> bool {
|
||||
self.build_id == BuildId::PRIMARY
|
||||
}
|
||||
|
||||
pub(crate) fn status_wasm_bindgen_start(&self) {
|
||||
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
|
||||
stage: BuildStage::RunningBindgen,
|
||||
|
||||
@@ -45,7 +45,7 @@ pub(crate) async fn pre_render_static_routes(
|
||||
devserver_ip,
|
||||
Some(fullstack_address),
|
||||
false,
|
||||
BuildId::SERVER,
|
||||
BuildId::SECONDARY,
|
||||
);
|
||||
// Run the server executable
|
||||
let _child = Command::new(&server_exe)
|
||||
|
||||
@@ -320,9 +320,9 @@
|
||||
//! - xbuild: <https://github.com/rust-mobile/xbuild/blob/master/xbuild/src/command/build.rs>
|
||||
|
||||
use crate::{
|
||||
AndroidTools, BuildContext, BundleFormat, DioxusConfig, Error, LinkAction, LinkerFlavor,
|
||||
Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, WasmOptConfig,
|
||||
Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
|
||||
AndroidTools, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction,
|
||||
LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen,
|
||||
WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
|
||||
};
|
||||
use anyhow::{bail, Context};
|
||||
use cargo_metadata::diagnostic::Diagnostic;
|
||||
@@ -445,7 +445,6 @@ pub enum BuildMode {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BuildArtifacts {
|
||||
pub(crate) root_dir: PathBuf,
|
||||
pub(crate) bundle: BundleFormat,
|
||||
pub(crate) exe: PathBuf,
|
||||
pub(crate) direct_rustc: RustcArgs,
|
||||
pub(crate) time_start: SystemTime,
|
||||
@@ -454,6 +453,7 @@ pub struct BuildArtifacts {
|
||||
pub(crate) mode: BuildMode,
|
||||
pub(crate) patch_cache: Option<Arc<HotpatchModuleCache>>,
|
||||
pub(crate) depinfo: RustcDepInfo,
|
||||
pub(crate) build_id: BuildId,
|
||||
}
|
||||
|
||||
impl BuildRequest {
|
||||
@@ -989,10 +989,10 @@ impl BuildRequest {
|
||||
_ = std::fs::File::create_new(self.windows_command_file());
|
||||
|
||||
if !matches!(ctx.mode, BuildMode::Thin { .. }) {
|
||||
self.prepare_build_dir()?;
|
||||
self.prepare_build_dir(ctx)?;
|
||||
}
|
||||
|
||||
if self.bundle == BundleFormat::Server {
|
||||
if !ctx.is_primary_build() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1250,7 +1250,6 @@ impl BuildRequest {
|
||||
let assets = self.collect_assets(&exe, ctx).await?;
|
||||
let time_end = SystemTime::now();
|
||||
let mode = ctx.mode.clone();
|
||||
let bundle = self.bundle;
|
||||
let depinfo = RustcDepInfo::from_file(&exe.with_extension("d")).unwrap_or_default();
|
||||
|
||||
tracing::debug!(
|
||||
@@ -1261,7 +1260,6 @@ impl BuildRequest {
|
||||
|
||||
Ok(BuildArtifacts {
|
||||
time_end,
|
||||
bundle,
|
||||
exe,
|
||||
direct_rustc,
|
||||
time_start,
|
||||
@@ -1270,6 +1268,7 @@ impl BuildRequest {
|
||||
depinfo,
|
||||
root_dir: self.root_dir(),
|
||||
patch_cache: None,
|
||||
build_id: ctx.build_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1460,7 +1459,7 @@ impl BuildRequest {
|
||||
/// Should be the same on all platforms - just copy over the assets from the manifest into the output directory
|
||||
async fn write_assets(&self, ctx: &BuildContext, assets: &AssetManifest) -> Result<()> {
|
||||
// Server doesn't need assets - web will provide them
|
||||
if self.bundle == BundleFormat::Server {
|
||||
if !ctx.is_primary_build() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -4320,14 +4319,14 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
|
||||
/// - extra scaffolding
|
||||
///
|
||||
/// It's not guaranteed that they're different from any other folder
|
||||
pub(crate) fn prepare_build_dir(&self) -> Result<()> {
|
||||
pub(crate) fn prepare_build_dir(&self, ctx: &BuildContext) -> Result<()> {
|
||||
use std::fs::{create_dir_all, remove_dir_all};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static INITIALIZED: OnceLock<Result<()>> = OnceLock::new();
|
||||
|
||||
let success = INITIALIZED.get_or_init(|| {
|
||||
if self.bundle != BundleFormat::Server {
|
||||
if ctx.is_primary_build() {
|
||||
_ = remove_dir_all(self.exe_dir());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use dioxus_dx_wire_format::StructuredBuildArtifacts;
|
||||
|
||||
use crate::{
|
||||
cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildMode, BuildRequest, BundleFormat,
|
||||
TargetArgs, Workspace,
|
||||
cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildId, BuildMode, BuildRequest, BundleFormat,
|
||||
Platform, TargetArgs, Workspace,
|
||||
};
|
||||
|
||||
/// Build the Rust Dioxus app and all of its assets.
|
||||
@@ -128,6 +128,10 @@ impl CommandWithPlatformOverrides<BuildArgs> {
|
||||
BuildRequest::new(&server_args.build_arguments, workspace.clone()).await?,
|
||||
);
|
||||
}
|
||||
None if client_args.platform == Platform::Server => {
|
||||
// If the user requests a server build with `--server`, then we don't need to build a separate server binary.
|
||||
// There's no client to use, so even though fullstack is true, we only build the server.
|
||||
}
|
||||
None => {
|
||||
let mut args = self.shared.build_arguments.clone();
|
||||
args.platform = crate::Platform::Server;
|
||||
@@ -171,7 +175,7 @@ impl CommandWithPlatformOverrides<BuildArgs> {
|
||||
request: &BuildRequest,
|
||||
mode: BuildMode,
|
||||
) -> Result<BuildArtifacts> {
|
||||
AppBuilder::started(request, mode)?
|
||||
AppBuilder::started(request, mode, BuildId::PRIMARY)?
|
||||
.finish_build()
|
||||
.await
|
||||
.inspect(|_| {
|
||||
@@ -189,7 +193,7 @@ impl CommandWithPlatformOverrides<BuildArgs> {
|
||||
};
|
||||
|
||||
// If the server is present, we need to build it as well
|
||||
let mut server_build = AppBuilder::started(server, mode)?;
|
||||
let mut server_build = AppBuilder::started(server, mode, BuildId::SECONDARY)?;
|
||||
let server_artifacts = server_build.finish_build().await?;
|
||||
|
||||
// Run SSG and cache static routes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{AppBuilder, BuildArgs, BuildMode, BuildRequest, BundleFormat};
|
||||
use crate::{AppBuilder, BuildArgs, BuildId, BuildMode, BuildRequest, BundleFormat};
|
||||
use anyhow::{bail, Context};
|
||||
use path_absolutize::Absolutize;
|
||||
use std::collections::HashMap;
|
||||
@@ -40,7 +40,8 @@ impl Bundle {
|
||||
let BuildTargets { client, server } = self.args.into_targets().await?;
|
||||
|
||||
let mut server_artifacts = None;
|
||||
let client_artifacts = AppBuilder::started(&client, BuildMode::Base { run: false })?
|
||||
let client_artifacts =
|
||||
AppBuilder::started(&client, BuildMode::Base { run: false }, BuildId::PRIMARY)?
|
||||
.finish_build()
|
||||
.await?;
|
||||
|
||||
@@ -49,7 +50,7 @@ impl Bundle {
|
||||
if let Some(server) = server.as_ref() {
|
||||
// If the server is present, we need to build it as well
|
||||
server_artifacts = Some(
|
||||
AppBuilder::started(server, BuildMode::Base { run: false })?
|
||||
AppBuilder::started(server, BuildMode::Base { run: false }, BuildId::SECONDARY)?
|
||||
.finish_build()
|
||||
.await?,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildMode,
|
||||
platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildId, BuildMode,
|
||||
HotpatchModuleCache, Result, StructuredOutput,
|
||||
};
|
||||
use anyhow::Context;
|
||||
@@ -34,8 +34,16 @@ impl HotpatchTip {
|
||||
pub async fn run(self) -> Result<StructuredOutput> {
|
||||
let targets = self.build_args.into_targets().await?;
|
||||
|
||||
let patch_server = self.patch_server.unwrap_or(false);
|
||||
|
||||
let build_id = if patch_server {
|
||||
BuildId::SECONDARY
|
||||
} else {
|
||||
BuildId::PRIMARY
|
||||
};
|
||||
|
||||
// Select which target to patch
|
||||
let request = if self.patch_server.unwrap_or_default() {
|
||||
let request = if patch_server {
|
||||
targets.server.as_ref().context("No server to patch!")?
|
||||
} else {
|
||||
&targets.client
|
||||
@@ -68,7 +76,9 @@ impl HotpatchTip {
|
||||
cache: cache.clone(),
|
||||
};
|
||||
|
||||
let artifacts = AppBuilder::started(request, mode)?.finish_build().await?;
|
||||
let artifacts = AppBuilder::started(request, mode, build_id)?
|
||||
.finish_build()
|
||||
.await?;
|
||||
let patch_exe = request.patch_exe(artifacts.time_start);
|
||||
|
||||
Ok(StructuredOutput::Hotpatch {
|
||||
|
||||
@@ -109,12 +109,12 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &TraceController) -> Resu
|
||||
pid,
|
||||
} => {
|
||||
devserver
|
||||
.send_hotreload(builder.applied_hot_reload_changes(BuildId::CLIENT))
|
||||
.send_hotreload(builder.applied_hot_reload_changes(BuildId::PRIMARY))
|
||||
.await;
|
||||
|
||||
if builder.server.is_some() {
|
||||
devserver
|
||||
.send_hotreload(builder.applied_hot_reload_changes(BuildId::SERVER))
|
||||
.send_hotreload(builder.applied_hot_reload_changes(BuildId::SECONDARY))
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,12 +268,12 @@ impl Output {
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
return Ok(Some(ServeUpdate::OpenDebugger {
|
||||
id: BuildId::SERVER,
|
||||
id: BuildId::SECONDARY,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
return Ok(Some(ServeUpdate::OpenDebugger {
|
||||
id: BuildId::CLIENT,
|
||||
id: BuildId::PRIMARY,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
|
||||
@@ -234,9 +234,9 @@ impl AppServer {
|
||||
false => BuildMode::Base { run: true },
|
||||
};
|
||||
|
||||
self.client.start(build_mode.clone());
|
||||
self.client.start(build_mode.clone(), BuildId::PRIMARY);
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
server.start(build_mode);
|
||||
server.start(build_mode, BuildId::SECONDARY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,14 +273,14 @@ impl AppServer {
|
||||
// Wait for the client to finish
|
||||
client_update = client_wait => {
|
||||
ServeUpdate::BuilderUpdate {
|
||||
id: BuildId::CLIENT,
|
||||
id: BuildId::PRIMARY,
|
||||
update: client_update,
|
||||
}
|
||||
}
|
||||
|
||||
Some(server_update) = server_wait => {
|
||||
ServeUpdate::BuilderUpdate {
|
||||
id: BuildId::SERVER,
|
||||
id: BuildId::SECONDARY,
|
||||
update: server_update,
|
||||
}
|
||||
}
|
||||
@@ -491,17 +491,18 @@ impl AppServer {
|
||||
// A full rebuild is required when the user modifies static initializers which we haven't wired up yet.
|
||||
if needs_full_rebuild && self.automatic_rebuilds {
|
||||
if self.use_hotpatch_engine {
|
||||
self.client.patch_rebuild(files.to_vec());
|
||||
self.client.patch_rebuild(files.to_vec(), BuildId::PRIMARY);
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
server.patch_rebuild(files.to_vec());
|
||||
server.patch_rebuild(files.to_vec(), BuildId::SECONDARY);
|
||||
}
|
||||
self.clear_hot_reload_changes();
|
||||
self.clear_cached_rsx();
|
||||
server.send_patch_start().await;
|
||||
} else {
|
||||
self.client.start_rebuild(BuildMode::Base { run: true });
|
||||
self.client
|
||||
.start_rebuild(BuildMode::Base { run: true }, BuildId::PRIMARY);
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
server.start_rebuild(BuildMode::Base { run: true });
|
||||
server.start_rebuild(BuildMode::Base { run: true }, BuildId::SECONDARY);
|
||||
}
|
||||
self.clear_hot_reload_changes();
|
||||
self.clear_cached_rsx();
|
||||
@@ -560,13 +561,14 @@ impl AppServer {
|
||||
devserver: &mut WebServer,
|
||||
) -> Result<()> {
|
||||
// Make sure to save artifacts regardless of if we're opening the app or not
|
||||
match artifacts.bundle {
|
||||
BundleFormat::Server => {
|
||||
match artifacts.build_id {
|
||||
BuildId::PRIMARY => self.client.artifacts = Some(artifacts.clone()),
|
||||
BuildId::SECONDARY => {
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
server.artifacts = Some(artifacts.clone());
|
||||
}
|
||||
}
|
||||
_ => self.client.artifacts = Some(artifacts.clone()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let should_open = self.client.stage == BuildStage::Success
|
||||
@@ -634,7 +636,7 @@ impl AppServer {
|
||||
fullstack_address,
|
||||
false,
|
||||
false,
|
||||
BuildId::SERVER,
|
||||
BuildId::SECONDARY,
|
||||
&self.server_args,
|
||||
)
|
||||
.await?;
|
||||
@@ -649,7 +651,7 @@ impl AppServer {
|
||||
fullstack_address,
|
||||
open_browser,
|
||||
self.always_on_top,
|
||||
BuildId::CLIENT,
|
||||
BuildId::PRIMARY,
|
||||
&self.client_args,
|
||||
)
|
||||
.await?;
|
||||
@@ -696,9 +698,10 @@ impl AppServer {
|
||||
false => BuildMode::Base { run: true },
|
||||
};
|
||||
|
||||
self.client.start_rebuild(build_mode.clone());
|
||||
self.client
|
||||
.start_rebuild(build_mode.clone(), BuildId::PRIMARY);
|
||||
if let Some(s) = self.server.as_mut() {
|
||||
s.start_rebuild(build_mode)
|
||||
s.start_rebuild(build_mode, BuildId::SECONDARY);
|
||||
}
|
||||
|
||||
self.clear_hot_reload_changes();
|
||||
@@ -719,8 +722,8 @@ impl AppServer {
|
||||
.unwrap_or_default();
|
||||
|
||||
let jump_table = match id {
|
||||
BuildId::CLIENT => self.client.hotpatch(bundle, cache).await,
|
||||
BuildId::SERVER => {
|
||||
BuildId::PRIMARY => self.client.hotpatch(bundle, cache).await,
|
||||
BuildId::SECONDARY => {
|
||||
self.server
|
||||
.as_mut()
|
||||
.context("Server not found")?
|
||||
@@ -730,7 +733,7 @@ impl AppServer {
|
||||
_ => bail!("Invalid build id"),
|
||||
}?;
|
||||
|
||||
if id == BuildId::CLIENT {
|
||||
if id == BuildId::PRIMARY {
|
||||
self.applied_client_hot_reload_message.jump_table = self.client.patches.last().cloned();
|
||||
}
|
||||
|
||||
@@ -759,11 +762,16 @@ impl AppServer {
|
||||
.context("Missing server jump table")?;
|
||||
|
||||
devserver
|
||||
.send_patch(server_jump_table, elapsed, BuildId::SERVER, server.pid)
|
||||
.send_patch(server_jump_table, elapsed, BuildId::SECONDARY, server.pid)
|
||||
.await;
|
||||
|
||||
devserver
|
||||
.send_patch(client_jump_table, elapsed, BuildId::CLIENT, self.client.pid)
|
||||
.send_patch(
|
||||
client_jump_table,
|
||||
elapsed,
|
||||
BuildId::PRIMARY,
|
||||
self.client.pid,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -772,8 +780,8 @@ impl AppServer {
|
||||
|
||||
pub(crate) fn get_build(&self, id: BuildId) -> Option<&AppBuilder> {
|
||||
match id {
|
||||
BuildId::CLIENT => Some(&self.client),
|
||||
BuildId::SERVER => self.server.as_ref(),
|
||||
BuildId::PRIMARY => Some(&self.client),
|
||||
BuildId::SECONDARY => self.server.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -791,18 +799,18 @@ impl AppServer {
|
||||
pub(crate) fn applied_hot_reload_changes(&mut self, build: BuildId) -> HotReloadMsg {
|
||||
let mut msg = self.applied_client_hot_reload_message.clone();
|
||||
|
||||
if build == BuildId::CLIENT {
|
||||
if build == BuildId::PRIMARY {
|
||||
msg.jump_table = self.client.patches.last().cloned();
|
||||
msg.for_build_id = Some(BuildId::CLIENT.0 as _);
|
||||
msg.for_build_id = Some(BuildId::PRIMARY.0 as _);
|
||||
if let Some(lib) = msg.jump_table.as_mut() {
|
||||
lib.lib = PathBuf::from("/").join(lib.lib.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if build == BuildId::SERVER {
|
||||
if build == BuildId::SECONDARY {
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
msg.jump_table = server.patches.last().cloned();
|
||||
msg.for_build_id = Some(BuildId::SERVER.0 as _);
|
||||
msg.for_build_id = Some(BuildId::SECONDARY.0 as _);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +836,7 @@ impl AppServer {
|
||||
pid: Option<u32>,
|
||||
) {
|
||||
match build_id {
|
||||
BuildId::CLIENT => {
|
||||
BuildId::PRIMARY => {
|
||||
// multiple tabs on web can cause this to be called incorrectly, and it doesn't
|
||||
// make any sense anyways
|
||||
if self.client.build.bundle != BundleFormat::Web {
|
||||
@@ -840,7 +848,7 @@ impl AppServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
BuildId::SERVER => {
|
||||
BuildId::SECONDARY => {
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
server.aslr_reference = aslr_reference;
|
||||
}
|
||||
@@ -1146,10 +1154,10 @@ impl AppServer {
|
||||
}
|
||||
|
||||
match build {
|
||||
BuildId::CLIENT => {
|
||||
BuildId::PRIMARY => {
|
||||
_ = self.client.open_debugger(dev).await;
|
||||
}
|
||||
BuildId::SERVER => {
|
||||
BuildId::SECONDARY => {
|
||||
if let Some(server) = self.server.as_mut() {
|
||||
_ = server.open_debugger(dev).await;
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ impl WebServer {
|
||||
if let Some(new_socket) = new_hot_reload_socket {
|
||||
let aslr_reference = new_socket.aslr_reference;
|
||||
let pid = new_socket.pid;
|
||||
let id = new_socket.build_id.unwrap_or(BuildId::CLIENT);
|
||||
let id = new_socket.build_id.unwrap_or(BuildId::PRIMARY);
|
||||
|
||||
drop(new_message);
|
||||
self.hot_reload_sockets.push(new_socket);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use dioxus::html::geometry::euclid::Vector3D;
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_desktop::DesktopContext;
|
||||
@@ -448,7 +446,7 @@ fn test_focus_out_div() -> Element {
|
||||
}
|
||||
|
||||
fn test_form_input() -> Element {
|
||||
let mut values = use_signal(HashMap::new);
|
||||
let mut values = use_signal(Vec::new);
|
||||
|
||||
utils::mock_event_with_extra(
|
||||
"form-username",
|
||||
@@ -464,10 +462,10 @@ fn test_form_input() -> Element {
|
||||
|
||||
// And then the value the form gives us should also match
|
||||
values.with_mut(|x| {
|
||||
assert_eq!(x.get("username").unwrap(), "hello");
|
||||
assert_eq!(x.get("full-name").unwrap(), "lorem");
|
||||
assert_eq!(x.get("password").unwrap(), "ipsum");
|
||||
assert_eq!(x.get("color").unwrap(), "red");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "username").unwrap().1, "hello");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "full-name").unwrap().1, "lorem");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "password").unwrap().1, "ipsum");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "color").unwrap().1, "red");
|
||||
});
|
||||
RECEIVED_EVENTS.with_mut(|x| *x += 1);
|
||||
};
|
||||
@@ -505,7 +503,7 @@ fn test_form_input() -> Element {
|
||||
}
|
||||
|
||||
fn test_form_submit() -> Element {
|
||||
let mut values = use_signal(HashMap::new);
|
||||
let mut values = use_signal(Vec::new);
|
||||
|
||||
utils::mock_event_with_extra(
|
||||
"form-submitter",
|
||||
@@ -516,10 +514,10 @@ fn test_form_submit() -> Element {
|
||||
let set_values = move |ev: FormEvent| {
|
||||
values.set(ev.values());
|
||||
values.with_mut(|x| {
|
||||
assert_eq!(x.get("username").unwrap(), "goodbye");
|
||||
assert_eq!(x.get("full-name").unwrap(), "lorem");
|
||||
assert_eq!(x.get("password").unwrap(), "ipsum");
|
||||
assert_eq!(x.get("color").unwrap(), "red");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "username").unwrap().1, "goodbye");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "full-name").unwrap().1, "lorem");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "password").unwrap().1, "ipsum");
|
||||
assert_eq!(x.iter().find(|f| f.0 == "color").unwrap().1, "red");
|
||||
});
|
||||
RECEIVED_EVENTS.with_mut(|x| *x += 1);
|
||||
};
|
||||
|
||||
@@ -2,17 +2,14 @@ use crate::{
|
||||
config::{Config, WindowCloseBehaviour},
|
||||
edits::EditWebsocket,
|
||||
event_handlers::WindowEventHandlers,
|
||||
file_upload::{DesktopFileUploadForm, FileDialogRequest},
|
||||
ipc::{IpcMessage, UserWindowEvent},
|
||||
query::QueryResult,
|
||||
shortcut::ShortcutRegistry,
|
||||
webview::{PendingWebview, WebviewInstance},
|
||||
};
|
||||
use dioxus_core::{consume_context, ElementId, ScopeId, VirtualDom};
|
||||
use dioxus_core::{consume_context, ScopeId, VirtualDom};
|
||||
use dioxus_history::History;
|
||||
use dioxus_html::PlatformEventData;
|
||||
use std::{
|
||||
any::Any,
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
rc::Rc,
|
||||
@@ -409,35 +406,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_file_dialog_msg(&mut self, msg: IpcMessage, window: WindowId) {
|
||||
let Ok(file_dialog) = serde_json::from_value::<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
|
||||
///
|
||||
/// 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
|
||||
|
||||
@@ -2,13 +2,34 @@
|
||||
|
||||
use crate::{
|
||||
element::DesktopElement,
|
||||
file_upload::{DesktopFileDragEvent, DesktopFileUploadForm},
|
||||
file_upload::{DesktopFileDragEvent, DesktopFormData},
|
||||
};
|
||||
use dioxus_html::*;
|
||||
|
||||
pub(crate) struct SerializedHtmlEventConverter;
|
||||
|
||||
impl HtmlEventConverter for SerializedHtmlEventConverter {
|
||||
/// This impl is special because it relies on the renderer to convert from serialized data to native data
|
||||
/// as an intermediate step.
|
||||
fn convert_drag_data(&self, event: &PlatformEventData) -> DragData {
|
||||
event
|
||||
.downcast::<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 {
|
||||
event
|
||||
.downcast::<SerializedAnimationData>()
|
||||
@@ -41,20 +62,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn convert_drag_data(&self, event: &PlatformEventData) -> DragData {
|
||||
// Attempt a simple serialized data conversion
|
||||
if let Some(_data) = event.downcast::<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 {
|
||||
event
|
||||
.downcast::<SerializedFocusData>()
|
||||
@@ -63,20 +70,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn convert_form_data(&self, event: &PlatformEventData) -> FormData {
|
||||
// Attempt a simple serialized form data conversion
|
||||
if let Some(_data) = event.downcast::<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 {
|
||||
event
|
||||
.downcast::<SerializedImageData>()
|
||||
@@ -101,10 +94,6 @@ impl HtmlEventConverter for SerializedHtmlEventConverter {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn convert_mounted_data(&self, event: &PlatformEventData) -> MountedData {
|
||||
event.downcast::<DesktopElement>().cloned().unwrap().into()
|
||||
}
|
||||
|
||||
fn convert_mouse_data(&self, event: &PlatformEventData) -> MouseData {
|
||||
event
|
||||
.downcast::<SerializedMouseData>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(unused)]
|
||||
|
||||
use std::any::Any;
|
||||
use std::{any::Any, collections::HashMap};
|
||||
|
||||
#[cfg(feature = "tokio_runtime")]
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
@@ -11,11 +11,11 @@ use dioxus_html::{
|
||||
point_interaction::{
|
||||
InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction,
|
||||
},
|
||||
FileData, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData,
|
||||
SerializedMouseData, SerializedPointInteraction,
|
||||
FileData, FormValue, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData,
|
||||
SerializedFormData, SerializedFormObject, SerializedMouseData, SerializedPointInteraction,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
path::PathBuf,
|
||||
@@ -25,7 +25,7 @@ use std::{
|
||||
};
|
||||
use wry::DragDropEvent;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) struct FileDialogRequest {
|
||||
#[serde(default)]
|
||||
accept: Option<String>,
|
||||
@@ -34,6 +34,8 @@ pub(crate) struct FileDialogRequest {
|
||||
pub event: String,
|
||||
pub target: usize,
|
||||
pub bubbles: bool,
|
||||
pub target_name: String,
|
||||
pub values: Vec<SerializedFormObject>,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
@@ -61,22 +63,26 @@ impl FileDialogRequest {
|
||||
target_os = "openbsd"
|
||||
))]
|
||||
pub(crate) fn get_file_event(&self) -> Vec<PathBuf> {
|
||||
fn get_file_event_for_folder(
|
||||
request: &FileDialogRequest,
|
||||
dialog: rfd::FileDialog,
|
||||
) -> Vec<PathBuf> {
|
||||
if request.multiple {
|
||||
dialog.pick_folders().into_iter().flatten().collect()
|
||||
let dialog = rfd::FileDialog::new();
|
||||
|
||||
if self.directory {
|
||||
self.get_file_event_for_folder(dialog)
|
||||
} else {
|
||||
dialog.pick_folder().into_iter().collect()
|
||||
self.get_file_event_for_file(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_file_event_for_file(
|
||||
request: &FileDialogRequest,
|
||||
mut dialog: rfd::FileDialog,
|
||||
) -> Vec<PathBuf> {
|
||||
let filters: Vec<_> = request
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
))]
|
||||
fn get_file_event_for_file(&self, mut dialog: rfd::FileDialog) -> Vec<PathBuf> {
|
||||
let filters: Vec<_> = self
|
||||
.accept
|
||||
.as_deref()
|
||||
.unwrap_or(".*")
|
||||
@@ -97,7 +103,7 @@ impl FileDialogRequest {
|
||||
|
||||
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()
|
||||
} else {
|
||||
dialog.pick_file().into_iter().collect()
|
||||
@@ -106,12 +112,20 @@ impl FileDialogRequest {
|
||||
files
|
||||
}
|
||||
|
||||
let dialog = rfd::FileDialog::new();
|
||||
|
||||
if self.directory {
|
||||
get_file_event_for_folder(self, dialog)
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
))]
|
||||
fn get_file_event_for_folder(&self, dialog: rfd::FileDialog) -> Vec<PathBuf> {
|
||||
if self.multiple {
|
||||
dialog.pick_folders().into_iter().flatten().collect()
|
||||
} else {
|
||||
get_file_event_for_file(self, dialog)
|
||||
dialog.pick_folder().into_iter().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,24 +168,53 @@ impl FromStr for Filters {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DesktopFileUploadForm {
|
||||
pub files: Vec<PathBuf>,
|
||||
pub(crate) struct DesktopFormData {
|
||||
pub value: String,
|
||||
pub valid: bool,
|
||||
pub values: Vec<(String, FormValue)>,
|
||||
}
|
||||
|
||||
impl HasFileData for DesktopFileUploadForm {
|
||||
impl DesktopFormData {
|
||||
pub fn new(values: Vec<(String, FormValue)>) -> Self {
|
||||
Self {
|
||||
value: String::new(),
|
||||
valid: true,
|
||||
values,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFileData for DesktopFormData {
|
||||
fn files(&self) -> Vec<FileData> {
|
||||
self.files
|
||||
self.values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|f| FileData::new(DesktopFileData(f)))
|
||||
.filter_map(|(_, v)| {
|
||||
if let FormValue::File(Some(f)) = v {
|
||||
Some(f.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFormData for DesktopFileUploadForm {
|
||||
impl HasFormData for DesktopFormData {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn value(&self) -> String {
|
||||
self.value.clone()
|
||||
}
|
||||
|
||||
fn valid(&self) -> bool {
|
||||
self.valid
|
||||
}
|
||||
|
||||
fn values(&self) -> Vec<(String, FormValue)> {
|
||||
self.values.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
|
||||
@@ -57,7 +57,6 @@ pub struct IpcMessage {
|
||||
/// A set of known messages that we need to respond to
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub enum IpcMethod<'a> {
|
||||
FileDialog,
|
||||
UserEvent,
|
||||
Query,
|
||||
BrowserOpen,
|
||||
@@ -68,7 +67,6 @@ pub enum IpcMethod<'a> {
|
||||
impl IpcMessage {
|
||||
pub(crate) fn method(&self) -> IpcMethod<'_> {
|
||||
match self.method.as_str() {
|
||||
"file_dialog" => IpcMethod::FileDialog,
|
||||
"user_event" => IpcMethod::UserEvent,
|
||||
"query" => IpcMethod::Query,
|
||||
"browser_open" => IpcMethod::BrowserOpen,
|
||||
|
||||
@@ -91,7 +91,6 @@ pub fn launch_virtual_dom_blocking(virtual_dom: VirtualDom, mut desktop_config:
|
||||
|
||||
UserWindowEvent::Ipc { id, msg } => match msg.method() {
|
||||
IpcMethod::Initialize => app.handle_initialize_msg(id),
|
||||
IpcMethod::FileDialog => app.handle_file_dialog_msg(msg, id),
|
||||
IpcMethod::UserEvent => {}
|
||||
IpcMethod::Query => app.handle_query_msg(msg, id),
|
||||
IpcMethod::BrowserOpen => app.handle_browser_open(msg),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::document::NATIVE_EVAL_JS;
|
||||
use crate::{assets::*, webview::WebviewEdits};
|
||||
use crate::{document::NATIVE_EVAL_JS, file_upload::FileDialogRequest};
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use dioxus_core::AnyhowContext;
|
||||
use dioxus_html::{SerializedFileData, SerializedFormObject};
|
||||
use dioxus_interpreter_js::unified_bindings::SLEDGEHAMMER_JS;
|
||||
use dioxus_interpreter_js::NATIVE_JS;
|
||||
use wry::{
|
||||
@@ -8,13 +11,13 @@ use wry::{
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const EVENTS_PATH: &str = "https://dioxus.index.html/__events";
|
||||
const BASE_URI: &str = "https://dioxus.index.html/";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const EVENTS_PATH: &str = "http://dioxus.index.html/__events";
|
||||
const BASE_URI: &str = "http://dioxus.index.html/";
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "windows")))]
|
||||
const EVENTS_PATH: &str = "dioxus://index.html/__events";
|
||||
const BASE_URI: &str = "dioxus://index.html/";
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
static DEFAULT_INDEX: &str = include_str!("./assets/dev.index.html");
|
||||
@@ -58,6 +61,14 @@ pub(super) fn desktop_handler(
|
||||
return edit_state.handle_event(request, responder);
|
||||
}
|
||||
|
||||
// If the request is asking for a file dialog, handle that, returning the list of files selected
|
||||
if trimmed_uri == "__file_dialog" {
|
||||
if let Err(err) = file_dialog_responder(request, responder) {
|
||||
tracing::error!("Failed to handle file dialog request: {err:?}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// todo: we want to move the custom assets onto a different protocol or something
|
||||
if let Some(name) = request.uri().path().split('/').nth(1) {
|
||||
if asset_handlers.has_handler(name) {
|
||||
@@ -104,7 +115,6 @@ fn index_request(
|
||||
|
||||
// Insert a custom head if provided
|
||||
// We look just for the closing head tag. If a user provided a custom index with weird syntax, this might fail
|
||||
|
||||
if let Some(head) = custom_head {
|
||||
index.insert_str(index.find("</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}
|
||||
|
||||
// The native interpreter extends the sledgehammer interpreter with a few extra methods that we use for IPC
|
||||
window.interpreter = new NativeInterpreter("{EVENTS_PATH}", {headless});
|
||||
window.interpreter = new NativeInterpreter("{BASE_URI}", {headless});
|
||||
|
||||
// Wait for the page to load before sending the initialize message
|
||||
window.onload = function() {{
|
||||
let root_element = window.document.getElementById("{root_id}");
|
||||
if (root_element != null) {{
|
||||
window.interpreter.initialize(root_element);
|
||||
window.ipc.postMessage(window.interpreter.serializeIpcMessage("initialize"));
|
||||
window.interpreter.sendIpcMessage("initialize");
|
||||
}}
|
||||
window.interpreter.waitForRequest("{edits_path}", "{expected_key}");
|
||||
}}
|
||||
@@ -166,3 +176,77 @@ fn module_loader(root_id: &str, headless: bool, edit_state: &WebviewEdits) -> St
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn file_dialog_responder(
|
||||
request: wry::http::Request<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(())
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::element::DesktopElement;
|
||||
use crate::file_upload::DesktopFileDragEvent;
|
||||
use crate::file_upload::{DesktopFileData, DesktopFileDragEvent};
|
||||
use crate::menubar::DioxusMenu;
|
||||
use crate::PendingDesktopContext;
|
||||
use crate::WindowCloseBehaviour;
|
||||
@@ -9,12 +8,13 @@ use crate::{
|
||||
DesktopContext, DesktopService,
|
||||
};
|
||||
use crate::{document::DesktopDocument, WeakDesktopContext};
|
||||
use crate::{element::DesktopElement, file_upload::DesktopFormData};
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use dioxus_core::{consume_context, provide_context, Runtime, ScopeId, VirtualDom};
|
||||
use dioxus_document::Document;
|
||||
use dioxus_history::{History, MemoryHistory};
|
||||
use dioxus_hooks::to_owned;
|
||||
use dioxus_html::{HtmlEvent, PlatformEventData};
|
||||
use dioxus_html::{FileData, FormValue, HtmlEvent, PlatformEventData};
|
||||
use futures_util::{pin_mut, FutureExt};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::{cell::OnceCell, time::Duration};
|
||||
@@ -115,7 +115,7 @@ impl WebviewEdits {
|
||||
let desktop_context = desktop_context.upgrade().unwrap();
|
||||
|
||||
let query = desktop_context.query.clone();
|
||||
let recent_file = desktop_context.file_hover.clone();
|
||||
let hovered_file = desktop_context.file_hover.clone();
|
||||
|
||||
// check for a mounted event placeholder and replace it with a desktop specific element
|
||||
let as_any = match data {
|
||||
@@ -123,9 +123,39 @@ impl WebviewEdits {
|
||||
let element = DesktopElement::new(element, desktop_context.clone(), query.clone());
|
||||
Rc::new(PlatformEventData::new(Box::new(element)))
|
||||
}
|
||||
dioxus_html::EventData::Form(form) => {
|
||||
Rc::new(PlatformEventData::new(Box::new(DesktopFormData {
|
||||
value: form.value,
|
||||
valid: form.valid,
|
||||
values: form
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|obj| {
|
||||
if let Some(text) = obj.text {
|
||||
return (obj.key, FormValue::Text(text));
|
||||
}
|
||||
|
||||
if let Some(file_data) = obj.file {
|
||||
if file_data.path.capacity() == 0 {
|
||||
return (obj.key, FormValue::File(None));
|
||||
}
|
||||
|
||||
return (
|
||||
obj.key,
|
||||
FormValue::File(Some(FileData::new(DesktopFileData(
|
||||
file_data.path,
|
||||
)))),
|
||||
);
|
||||
};
|
||||
|
||||
(obj.key, FormValue::Text(String::new()))
|
||||
})
|
||||
.collect(),
|
||||
})))
|
||||
}
|
||||
dioxus_html::EventData::Drag(ref drag) => {
|
||||
// we want to override this with a native file engine, provided by the most recent drag event
|
||||
let file_event = recent_file.current().unwrap();
|
||||
let file_event = hovered_file.current().unwrap();
|
||||
let paths = match file_event {
|
||||
wry::DragDropEvent::Enter { paths, .. } => paths,
|
||||
wry::DragDropEvent::Drop { paths, .. } => paths,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum_core::response::IntoResponse;
|
||||
use futures_util::TryStreamExt;
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
@@ -91,6 +92,28 @@ impl ServerFnError {
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new server error (status code 500) with a message and details.
|
||||
pub async fn from_axum_response(resp: axum_core::response::Response) -> Self {
|
||||
let status = resp.status();
|
||||
let message = resp
|
||||
.into_body()
|
||||
.into_data_stream()
|
||||
.try_fold(Vec::new(), |mut acc, chunk| async move {
|
||||
acc.extend_from_slice(&chunk);
|
||||
Ok(acc)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.unwrap_or_else(|| status.canonical_reason().unwrap_or("").to_string());
|
||||
|
||||
ServerFnError::ServerError {
|
||||
message,
|
||||
code: Some(status.as_u16()),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ServerFnError {
|
||||
@@ -159,7 +182,64 @@ impl From<HttpError> for ServerFnError {
|
||||
|
||||
impl IntoResponse for ServerFnError {
|
||||
fn into_response(self) -> axum_core::response::Response {
|
||||
todo!()
|
||||
match self {
|
||||
Self::ServerError {
|
||||
message,
|
||||
code,
|
||||
details,
|
||||
} => {
|
||||
let status = code
|
||||
.and_then(|c| StatusCode::from_u16(c).ok())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = if let Some(details) = details {
|
||||
serde_json::json!({
|
||||
"error": message,
|
||||
"details": details,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"error": message,
|
||||
})
|
||||
};
|
||||
let body = axum_core::body::Body::from(
|
||||
serde_json::to_string(&body)
|
||||
.unwrap_or_else(|_| "{\"error\":\"Internal Server Error\"}".to_string()),
|
||||
);
|
||||
axum_core::response::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| {
|
||||
axum_core::response::Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum_core::body::Body::from(
|
||||
"{\"error\":\"Internal Server Error\"}",
|
||||
))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let status: StatusCode = self.clone().into();
|
||||
let body = axum_core::body::Body::from(
|
||||
serde_json::json!({
|
||||
"error": self.to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
axum_core::response::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| {
|
||||
axum_core::response::Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum_core::body::Body::from(
|
||||
"{\"error\":\"Internal Server Error\"}",
|
||||
))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use crate::ServerFnError;
|
||||
use axum_core::{extract::FromRequest, response::IntoResponse};
|
||||
use dioxus_core::try_consume_context;
|
||||
use dioxus_signals::{ReadableExt, Signal, WritableExt};
|
||||
use http::request::Parts;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
/// The status of the streaming response
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -13,23 +17,26 @@ pub enum StreamingStatus {
|
||||
}
|
||||
|
||||
/// The context dioxus fullstack provides for the status of streaming responses on the server
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StreamingContext {
|
||||
current_status: Signal<StreamingStatus>,
|
||||
request_headers: Rc<RefCell<http::request::Parts>>,
|
||||
}
|
||||
|
||||
impl Default for StreamingContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
impl PartialEq for StreamingContext {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.current_status == other.current_status
|
||||
&& Rc::ptr_eq(&self.request_headers, &other.request_headers)
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingContext {
|
||||
/// Create a new streaming context. You should not need to call this directly. Dioxus fullstack will
|
||||
/// provide this context for you.
|
||||
pub fn new() -> Self {
|
||||
pub fn new(parts: Parts) -> Self {
|
||||
Self {
|
||||
current_status: Signal::new(StreamingStatus::RenderingInitialChunk),
|
||||
request_headers: Rc::new(RefCell::new(parts)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +55,42 @@ impl StreamingContext {
|
||||
pub fn current_status(&self) -> StreamingStatus {
|
||||
*self.current_status.read()
|
||||
}
|
||||
|
||||
/// Access the http request parts mutably. This will allow you to modify headers and other parts of the request.
|
||||
pub fn parts_mut(&self) -> std::cell::RefMut<'_, http::request::Parts> {
|
||||
self.request_headers.borrow_mut()
|
||||
}
|
||||
|
||||
/// Extract an axum extractor from the current request. This will always use an empty body for the request,
|
||||
/// since it's assumed that rendering the app is done under a `GET` request.
|
||||
pub async fn extract<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
|
||||
|
||||
@@ -305,6 +305,14 @@ fn route_impl_with_route(
|
||||
})
|
||||
.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 tys = body_json_types
|
||||
.iter()
|
||||
@@ -334,28 +342,13 @@ fn route_impl_with_route(
|
||||
};
|
||||
|
||||
// there's no active request on the server, so we just create a dummy one
|
||||
let server_defaults = server_args.iter().map(|arg| {
|
||||
let name = match arg {
|
||||
FnArg::Receiver(_) => panic!("Server args cannot be receiver"),
|
||||
FnArg::Typed(pat_type) => match pat_type.pat.as_ref() {
|
||||
Pat::Ident(pat_ident) => &pat_ident.ident,
|
||||
_ => panic!("Expected Pat::Ident"),
|
||||
},
|
||||
};
|
||||
|
||||
let ty = match arg {
|
||||
FnArg::Receiver(_) => panic!("Server args cannot be receiver"),
|
||||
FnArg::Typed(pat_type) => (*pat_type.ty).clone(),
|
||||
};
|
||||
|
||||
let server_defaults = if server_args.is_empty() {
|
||||
quote! {}
|
||||
} else {
|
||||
quote! {
|
||||
let #name = {
|
||||
use dioxus_fullstack::axum::extract::FromRequest;
|
||||
let __request = dioxus_fullstack::axum::extract::Request::new(dioxus_fullstack::axum::body::Body::empty());
|
||||
<#ty as FromRequest<_, _>>::from_request(__request, &()).await.unwrap()
|
||||
};
|
||||
let (#(#server_names,)*) = dioxus_fullstack::StreamingContext::extract::<(#(#server_types,)*), _>().await?;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let as_axum_path = route.to_axum_path_string();
|
||||
|
||||
@@ -519,7 +512,7 @@ fn route_impl_with_route(
|
||||
)
|
||||
}
|
||||
|
||||
#(#server_defaults)*
|
||||
#server_defaults
|
||||
|
||||
return #fn_name #ty_generics(
|
||||
#(#extracted_idents,)*
|
||||
@@ -1214,6 +1207,15 @@ impl PathParam {
|
||||
ty,
|
||||
Brace(span),
|
||||
)
|
||||
} else if str.starts_with(':') && str.len() > 1 {
|
||||
let str = str.strip_prefix(':').unwrap();
|
||||
Self::Capture(
|
||||
LitStr::new(str, span),
|
||||
Brace(span),
|
||||
Ident::new(str, span),
|
||||
ty,
|
||||
Brace(span),
|
||||
)
|
||||
} else {
|
||||
Self::Static(LitStr::new(str, span))
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ use axum::{
|
||||
};
|
||||
use dioxus_core::{Element, VirtualDom};
|
||||
use futures::Stream;
|
||||
use http::header::*;
|
||||
use http::{header::*, request::Parts};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tower::util::MapResponse;
|
||||
@@ -297,13 +297,8 @@ impl RenderHandleState {
|
||||
};
|
||||
|
||||
let (parts, _) = request.into_parts();
|
||||
let url = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.ok_or(StatusCode::BAD_REQUEST)?
|
||||
.to_string();
|
||||
|
||||
match state.render(url, cfg, build_virtual_dom).await {
|
||||
match state.render(parts, cfg, build_virtual_dom).await {
|
||||
Ok((freshness, rx)) => {
|
||||
let mut response =
|
||||
axum::response::Html::from(Body::from_stream(rx)).into_response();
|
||||
@@ -338,7 +333,7 @@ impl RenderHandleState {
|
||||
/// Render the application to HTML.
|
||||
pub async fn render<'a>(
|
||||
&'a self,
|
||||
route: String,
|
||||
parts: Parts,
|
||||
cfg: &'a ServeConfig,
|
||||
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
|
||||
) -> Result<
|
||||
@@ -350,7 +345,7 @@ impl RenderHandleState {
|
||||
> {
|
||||
self.renderers
|
||||
.clone()
|
||||
.render_to(cfg, route, virtual_dom_factory)
|
||||
.render_to(parts, cfg, virtual_dom_factory)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use dioxus_router::ParseRouteError;
|
||||
use dioxus_ssr::Renderer;
|
||||
use futures_channel::mpsc::Sender;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use http::StatusCode;
|
||||
use http::{request::Parts, StatusCode};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Write,
|
||||
@@ -98,8 +98,8 @@ impl SsrRendererPool {
|
||||
/// The streaming is canceled when the stream the function returns is dropped
|
||||
pub(crate) async fn render_to(
|
||||
self: Arc<Self>,
|
||||
parts: Parts,
|
||||
cfg: &ServeConfig,
|
||||
route: String,
|
||||
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
|
||||
) -> Result<
|
||||
(
|
||||
@@ -133,6 +133,15 @@ impl SsrRendererPool {
|
||||
}
|
||||
}
|
||||
|
||||
let route = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.ok_or_else(|| SSRError::HttpError {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: None,
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let (mut into, rx) =
|
||||
futures_channel::mpsc::channel::<Result<String, IncrementalRendererError>>(1000);
|
||||
|
||||
@@ -177,9 +186,10 @@ impl SsrRendererPool {
|
||||
};
|
||||
|
||||
// Provide the document and streaming context to the root of the app
|
||||
let streaming_context = virtual_dom.in_scope(ScopeId::ROOT, StreamingContext::new);
|
||||
let streaming_context =
|
||||
virtual_dom.in_scope(ScopeId::ROOT, || StreamingContext::new(parts));
|
||||
virtual_dom.provide_root_context(document.clone() as Rc<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, || {
|
||||
// Wrap the memory history in a fullstack history provider to provide the initial route for hydration
|
||||
|
||||
@@ -37,7 +37,7 @@ serde_json = { workspace = true }
|
||||
pin-project = { workspace = true }
|
||||
serde_qs = { workspace = true, default-features = true}
|
||||
mime = "0.3.17"
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls", "cookies", "stream"] }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls", "cookies", "stream", "multipart"] }
|
||||
url = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
form_urlencoded = { workspace = true }
|
||||
|
||||
@@ -161,6 +161,49 @@ impl ClientRequest {
|
||||
builder
|
||||
}
|
||||
|
||||
/// Sends the request with multipart/form-data body constructed from the given FormData.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn send_multipart(
|
||||
self,
|
||||
form: &dioxus_html::FormData,
|
||||
) -> Result<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> {
|
||||
// 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.
|
||||
|
||||
@@ -95,7 +95,11 @@ impl<S> IntoRequest for MultipartFormData<S> {
|
||||
// and construct a multipart form body manually.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
todo!("Properly implement multipart form data for non-web wasm32 clients");
|
||||
let data = self._client.clone().ok_or_else(|| {
|
||||
RequestError::Builder("Failed to get FormData from event".into())
|
||||
})?;
|
||||
|
||||
return _req.send_multipart(&data).await;
|
||||
}
|
||||
|
||||
unimplemented!("Non web wasm32 clients are not supported yet")
|
||||
|
||||
+237
-194
@@ -1,77 +1,16 @@
|
||||
use crate::file_data::HasFileData;
|
||||
use crate::FileData;
|
||||
use std::{collections::HashMap, fmt::Debug, ops::Deref};
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
use crate::SerializedFileData;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use dioxus_core::Event;
|
||||
|
||||
pub type FormEvent = Event<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 */
|
||||
pub struct FormData {
|
||||
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 {
|
||||
/// Create a new form event
|
||||
pub fn new(event: impl HasFormData + 'static) -> Self {
|
||||
@@ -104,10 +43,25 @@ impl FormData {
|
||||
/// Collect all the named form values from the containing form.
|
||||
///
|
||||
/// Every input must be named!
|
||||
pub fn values(&self) -> HashMap<String, FormValue> {
|
||||
pub fn values(&self) -> Vec<(String, FormValue)> {
|
||||
self.inner.values()
|
||||
}
|
||||
|
||||
/// Get the first value with the given name
|
||||
pub fn get_first(&self, name: &str) -> Option<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
|
||||
pub fn files(&self) -> Vec<FileData> {
|
||||
self.inner.files()
|
||||
@@ -125,170 +79,259 @@ 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 {
|
||||
#[cfg(feature = "serialize")]
|
||||
/// 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>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
use crate::SerializedFileData;
|
||||
|
||||
let values = &self.values();
|
||||
|
||||
let mut map = serde_json::Map::new();
|
||||
for (key, value) in values {
|
||||
if value.0.len() == 1 {
|
||||
map.insert(key.clone(), serde_json::Value::String(value.0[0].clone()));
|
||||
} else {
|
||||
let arr = value
|
||||
.0
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_json::Value::String)
|
||||
.collect();
|
||||
map.insert(key.clone(), serde_json::Value::Array(arr));
|
||||
let entry = map
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
|
||||
|
||||
match value {
|
||||
FormValue::Text(text) => {
|
||||
entry
|
||||
.as_array_mut()
|
||||
.expect("entry should be an array")
|
||||
.push(serde_json::Value::String(text.clone()));
|
||||
}
|
||||
// we create the serialized variant with no bytes
|
||||
// SerializedFileData, if given a real path, will read the bytes from disk (synchronously)
|
||||
FormValue::File(Some(file_data)) => {
|
||||
let serialized = SerializedFileData {
|
||||
path: file_data.path().to_owned(),
|
||||
size: file_data.size(),
|
||||
last_modified: file_data.last_modified(),
|
||||
content_type: file_data.content_type(),
|
||||
contents: None,
|
||||
};
|
||||
entry
|
||||
.as_array_mut()
|
||||
.expect("entry should be an array")
|
||||
.push(serde_json::to_value(&serialized).unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
FormValue::File(None) => {
|
||||
entry
|
||||
.as_array_mut()
|
||||
.expect("entry should be an array")
|
||||
.push(
|
||||
serde_json::to_value(SerializedFileData::empty())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
/// A serialized form data object
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
|
||||
pub struct SerializedFormData {
|
||||
#[serde(default)]
|
||||
value: String,
|
||||
|
||||
#[serde(default)]
|
||||
values: HashMap<String, FormValue>,
|
||||
|
||||
#[serde(default)]
|
||||
valid: bool,
|
||||
|
||||
#[serde(default)]
|
||||
files: Vec<SerializedFileData>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
impl SerializedFormData {
|
||||
/// Create a new serialized form data object
|
||||
pub fn new(value: String, values: HashMap<String, FormValue>) -> Self {
|
||||
Self {
|
||||
value,
|
||||
values,
|
||||
valid: true,
|
||||
files: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Add files to the serialized form data object
|
||||
pub fn with_files(mut self, files: Vec<SerializedFileData>) -> Self {
|
||||
self.files = files;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a new serialized form data object from a traditional form data object
|
||||
pub async fn async_from(data: &FormData) -> Result<Self, dioxus_core::Error> {
|
||||
Ok(Self {
|
||||
value: data.value(),
|
||||
values: data.values(),
|
||||
valid: data.valid(),
|
||||
files: {
|
||||
let mut out = vec![];
|
||||
for file in data.files() {
|
||||
out.push(SerializedFileData {
|
||||
name: file.name(),
|
||||
size: file.size(),
|
||||
content_type: file.content_type(),
|
||||
last_modified: file.last_modified(),
|
||||
path: file.pathbuf(),
|
||||
contents: file.read_bytes().await?,
|
||||
});
|
||||
}
|
||||
out
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn from_lossy(data: &FormData) -> Self {
|
||||
Self {
|
||||
value: data.value(),
|
||||
values: data.values(),
|
||||
valid: data.valid(),
|
||||
files: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
impl HasFormData for SerializedFormData {
|
||||
fn value(&self) -> String {
|
||||
self.value.clone()
|
||||
}
|
||||
|
||||
fn values(&self) -> HashMap<String, FormValue> {
|
||||
self.values.clone()
|
||||
}
|
||||
|
||||
fn valid(&self) -> bool {
|
||||
self.valid
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
impl HasFileData for SerializedFormData {
|
||||
fn files(&self) -> Vec<FileData> {
|
||||
self.files
|
||||
.iter()
|
||||
.map(|f| FileData::new(f.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFileData for FormData {
|
||||
fn files(&self) -> Vec<FileData> {
|
||||
self.inner.files()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
impl serde::Serialize for FormData {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
SerializedFormData::from_lossy(self).serialize(serializer)
|
||||
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")]
|
||||
impl<'de> serde::Deserialize<'de> for FormData {
|
||||
pub use serialize::*;
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
mod serialize {
|
||||
use crate::SerializedFileData;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A serialized form data object
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
|
||||
pub struct SerializedFormData {
|
||||
#[serde(default)]
|
||||
pub value: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub values: Vec<SerializedFormObject>,
|
||||
|
||||
#[serde(default)]
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
|
||||
pub struct SerializedFormObject {
|
||||
pub key: String,
|
||||
pub text: Option<String>,
|
||||
pub file: Option<SerializedFileData>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
impl SerializedFormData {
|
||||
/// Create a new serialized form data object
|
||||
pub fn new(value: String, values: Vec<SerializedFormObject>) -> Self {
|
||||
Self {
|
||||
value,
|
||||
values,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a serialized form data object from a form data object
|
||||
fn from_form_lossy(data: &FormData) -> Self {
|
||||
if let Some(data) = data.downcast::<SerializedFormData>() {
|
||||
return data.clone();
|
||||
}
|
||||
|
||||
let values = data
|
||||
.values()
|
||||
.iter()
|
||||
.map(|(key, value)| match value {
|
||||
FormValue::Text(s) => SerializedFormObject {
|
||||
key: key.clone(),
|
||||
text: Some(s.to_string()),
|
||||
file: None,
|
||||
},
|
||||
FormValue::File(f) => SerializedFormObject {
|
||||
key: key.clone(),
|
||||
text: None,
|
||||
file: if let Some(f) = f {
|
||||
Some(SerializedFileData {
|
||||
path: f.path(),
|
||||
size: f.size(),
|
||||
last_modified: f.last_modified(),
|
||||
content_type: f.content_type(),
|
||||
contents: None,
|
||||
})
|
||||
} else {
|
||||
Some(SerializedFileData::empty())
|
||||
},
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
values,
|
||||
value: data.value(),
|
||||
valid: data.valid(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFormData for SerializedFormData {
|
||||
fn value(&self) -> String {
|
||||
self.value.clone()
|
||||
}
|
||||
|
||||
fn values(&self) -> Vec<(String, FormValue)> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn valid(&self) -> bool {
|
||||
self.valid
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFileData for SerializedFormData {
|
||||
fn files(&self) -> Vec<FileData> {
|
||||
self.values
|
||||
.iter()
|
||||
.filter_map(|v| v.file.as_ref().map(|f| FileData::new(f.clone())))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for FormData {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
SerializedFormData::from_form_lossy(self).serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for FormData {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let data = SerializedFormData::deserialize(deserializer)?;
|
||||
Ok(Self {
|
||||
inner: Box::new(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl_event! {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::Stream;
|
||||
use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FileData {
|
||||
@@ -49,11 +48,20 @@ impl FileData {
|
||||
self.inner.inner()
|
||||
}
|
||||
|
||||
pub fn pathbuf(&self) -> PathBuf {
|
||||
pub fn path(&self) -> PathBuf {
|
||||
self.inner.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for FileData {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name() == other.name()
|
||||
&& self.size() == other.size()
|
||||
&& self.last_modified() == other.last_modified()
|
||||
&& self.path() == other.path()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NativeFileData: Send + Sync {
|
||||
fn name(&self) -> String;
|
||||
fn size(&self) -> u64;
|
||||
@@ -82,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 {
|
||||
fn files(&self) -> Vec<FileData>;
|
||||
}
|
||||
|
||||
/// A serializable representation of file data\
|
||||
#[cfg(feature = "serialize")]
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
|
||||
pub struct SerializedFileData {
|
||||
pub name: String,
|
||||
pub use serialize::*;
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
mod serialize {
|
||||
use super::*;
|
||||
|
||||
/// A serializable representation of file data
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
|
||||
pub struct SerializedFileData {
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
pub last_modified: u64,
|
||||
pub content_type: Option<String>,
|
||||
pub contents: bytes::Bytes,
|
||||
}
|
||||
pub contents: Option<bytes::Bytes>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serialize")]
|
||||
impl NativeFileData for SerializedFileData {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NativeFileData for SerializedFileData {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
self.path()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn size(&self) -> u64 {
|
||||
@@ -124,22 +145,64 @@ impl NativeFileData for SerializedFileData {
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Bytes, dioxus_core::Error>> + 'static>> {
|
||||
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(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, dioxus_core::Error>> + 'static>> {
|
||||
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(
|
||||
&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();
|
||||
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 {
|
||||
@@ -153,4 +216,15 @@ impl NativeFileData for SerializedFileData {
|
||||
fn content_type(&self) -> Option<String> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ fn main() {
|
||||
// If any TS files change, re-run the build script
|
||||
lazy_js_bundle::LazyTypeScriptBindings::new()
|
||||
.with_watching("./src/ts")
|
||||
.with_binding("./src/ts/common.ts", "./src/js/common.js")
|
||||
.with_binding("./src/ts/set_attribute.ts", "./src/js/set_attribute.js")
|
||||
.with_binding("./src/ts/native.ts", "./src/js/native.js")
|
||||
.with_binding("./src/ts/core.ts", "./src/js/core.js")
|
||||
.with_binding("./src/ts/hydrate.ts", "./src/js/hydrate.js")
|
||||
|
||||
@@ -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 @@
|
||||
[6449103750905854967, 17669692872757955279, 4701826235737143966, 11420464406527728232, 3770103091118609057, 5444526391971481782, 14692042304579321130, 5052021921702764563, 7041565893588933645, 11339769846046015954]
|
||||
[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 8889858244860485542, 5052021921702764563, 5113938988622536969, 11339769846046015954]
|
||||
@@ -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};
|
||||
@@ -32,13 +32,10 @@ pub mod minimal_bindings {
|
||||
/// 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
|
||||
#[wasm_bindgen(module = "/src/js/common.js")]
|
||||
#[wasm_bindgen(module = "/src/js/set_attribute.js")]
|
||||
extern "C" {
|
||||
/// Set the attribute of the node
|
||||
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")]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { setAttributeInner } from "./set_attribute";
|
||||
export { retrieveFormValues } from "./form";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// provide since it doesn't have access to the dom.
|
||||
|
||||
import { BaseInterpreter, NodeId } from "./core";
|
||||
import { SerializedEvent, serializeEvent } from "./serialize";
|
||||
import { SerializedEvent, serializeEvent, SerializedFileData, extractSerializedFormValues, SerializedFormObject } from "./serialize";
|
||||
|
||||
// okay so, we've got this JSChannel thing from sledgehammer, implicitly imported into our scope
|
||||
// we want to extend it, and it technically extends base interpreter. To make typescript happy,
|
||||
@@ -21,6 +21,7 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
intercept_link_redirects: boolean;
|
||||
ipc: any;
|
||||
edits: WebSocket;
|
||||
baseUri: string;
|
||||
eventsPath: string;
|
||||
headless: boolean;
|
||||
kickStylesheets: boolean;
|
||||
@@ -30,9 +31,10 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
// however, for now we need to support it since WebSockets in fullstack doesn't exist yet
|
||||
liveview: boolean;
|
||||
|
||||
constructor(eventsPath: string, headless: boolean) {
|
||||
constructor(baseUri: string, headless: boolean) {
|
||||
super();
|
||||
this.eventsPath = eventsPath;
|
||||
this.baseUri = baseUri;
|
||||
this.eventsPath = `${baseUri}/__events`;
|
||||
this.kickStylesheets = false;
|
||||
this.headless = headless;
|
||||
}
|
||||
@@ -79,20 +81,76 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
// Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it
|
||||
let target_id = getTargetId(target);
|
||||
if (target_id !== null) {
|
||||
const message = this.serializeIpcMessage("file_dialog", {
|
||||
// Handle file inputs specifically, since we want to get the real file inputs from the native
|
||||
// dialog and then set the form inputs accordingly.
|
||||
if (
|
||||
target instanceof HTMLInputElement &&
|
||||
target.getAttribute("type") === "file"
|
||||
) {
|
||||
// Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it
|
||||
event.preventDefault();
|
||||
|
||||
const contents = serializeEvent(event, target);
|
||||
|
||||
const target_name = target.getAttribute("name") || "";
|
||||
|
||||
let requestData = {
|
||||
event: "change&input",
|
||||
accept: target.getAttribute("accept"),
|
||||
directory: target.getAttribute("webkitdirectory") === "true",
|
||||
multiple: target.hasAttribute("multiple"),
|
||||
target: target_id,
|
||||
bubbles: event.bubbles,
|
||||
target_name,
|
||||
values: contents.values,
|
||||
};
|
||||
|
||||
this.fetchAgainstHost("__file_dialog", requestData).then(response => response.json()).then(resp => {
|
||||
const formObjects: SerializedFormObject[] = resp.values;
|
||||
|
||||
// Create a new DataTransfer to hold the files
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// We name the file the path, so we can just use the path as the name later on.
|
||||
for (let formObject of formObjects) {
|
||||
if (formObject.key == target_name && formObject.file != null) {
|
||||
const file = new File([], formObject.file.path, {
|
||||
type: formObject.file.content_type,
|
||||
lastModified: formObject.file.last_modified,
|
||||
});
|
||||
this.ipc.postMessage(message);
|
||||
event.preventDefault();
|
||||
dataTransfer.items.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the files on the input
|
||||
target.files = dataTransfer.files;
|
||||
|
||||
let body = {
|
||||
data: contents,
|
||||
element: target_id,
|
||||
bubbles: event.bubbles,
|
||||
};
|
||||
|
||||
// And then dispatch the actual event against the dom
|
||||
contents.values = formObjects;
|
||||
this.sendSerializedEvent({
|
||||
...body,
|
||||
name: "input",
|
||||
});
|
||||
this.sendSerializedEvent({
|
||||
...body,
|
||||
name: "change",
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// @ts-ignore - wry gives us this
|
||||
this.ipc = window.ipc;
|
||||
|
||||
@@ -103,8 +161,23 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
super.initialize(root, handler);
|
||||
}
|
||||
|
||||
serializeIpcMessage(method: string, params = {}) {
|
||||
return JSON.stringify({ method, params });
|
||||
fetchAgainstHost(path: string, data: { [key: string]: any }): Promise<Response> {
|
||||
let encoded_data = new TextEncoder().encode(JSON.stringify(data));
|
||||
const base64data = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(encoded_data))
|
||||
);
|
||||
|
||||
return fetch(`${this.baseUri}/${path}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-dioxus-data": base64data
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendIpcMessage(method: string, params = {}) {
|
||||
const body = JSON.stringify({ method, params });
|
||||
this.ipc.postMessage(body);
|
||||
}
|
||||
|
||||
scrollTo(id: NodeId, options: ScrollIntoViewOptions): boolean {
|
||||
@@ -181,11 +254,14 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
|
||||
// Windows drag-n-drop fix code. Called by wry drag-n-drop handler over the event loop.
|
||||
handleWindowsDragDrop() {
|
||||
// @ts-ignore
|
||||
if (window.dxDragLastElement) {
|
||||
const dragLeaveEvent = new DragEvent("dragleave", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
|
||||
|
||||
let data = new DataTransfer();
|
||||
@@ -199,23 +275,29 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
cancelable: true,
|
||||
dataTransfer: data,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement.dispatchEvent(dragDropEvent);
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleWindowsDragOver(xPos, yPos) {
|
||||
handleWindowsDragOver(xPos: number, yPos: number) {
|
||||
const displayScaleFactor = window.devicePixelRatio || 1;
|
||||
xPos /= displayScaleFactor;
|
||||
yPos /= displayScaleFactor;
|
||||
const element = document.elementFromPoint(xPos, yPos);
|
||||
|
||||
// @ts-ignore
|
||||
if (element != window.dxDragLastElement) {
|
||||
// @ts-ignore
|
||||
if (window.dxDragLastElement) {
|
||||
const dragLeaveEvent = new DragEvent("dragleave", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
|
||||
}
|
||||
|
||||
@@ -224,17 +306,21 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
cancelable: true,
|
||||
});
|
||||
element.dispatchEvent(dragOverEvent);
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement = element;
|
||||
}
|
||||
}
|
||||
|
||||
handleWindowsDragLeave() {
|
||||
// @ts-ignore
|
||||
if (window.dxDragLastElement) {
|
||||
const dragLeaveEvent = new DragEvent("dragleave", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
|
||||
// @ts-ignore
|
||||
window.dxDragLastElement = null;
|
||||
}
|
||||
}
|
||||
@@ -266,31 +352,30 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
|
||||
handleEvent(event: Event, name: string, bubbles: boolean) {
|
||||
const target = event.target!;
|
||||
const realId = getTargetId(target)!;
|
||||
const element = getTargetId(target)!;
|
||||
const contents = serializeEvent(event, target);
|
||||
|
||||
// Handle the event on the virtualdom and then preventDefault if it also preventsDefault
|
||||
// Some listeners
|
||||
let body = {
|
||||
name: name,
|
||||
name,
|
||||
data: contents,
|
||||
element: realId,
|
||||
element,
|
||||
bubbles,
|
||||
};
|
||||
|
||||
// liveview does not have synchronous event handling, so we need to send the event to the host
|
||||
if (this.liveview) {
|
||||
// Okay, so the user might've requested some files to be read
|
||||
if (
|
||||
this.liveview &&
|
||||
target instanceof HTMLInputElement &&
|
||||
(event.type === "change" || event.type === "input")
|
||||
) {
|
||||
if (target.getAttribute("type") === "file") {
|
||||
this.readFiles(target, contents, bubbles, realId, name);
|
||||
this.readFiles(target, contents, bubbles, element, name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = this.sendSerializedEvent(body);
|
||||
// capture/prevent default of the event if the virtualdom wants to
|
||||
if (response) {
|
||||
@@ -316,8 +401,7 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
bubbles: boolean;
|
||||
}): EventSyncResult | void {
|
||||
if (this.liveview) {
|
||||
const message = this.serializeIpcMessage("user_event", body);
|
||||
this.ipc.postMessage(message);
|
||||
this.sendIpcMessage("user_event", body);
|
||||
} else {
|
||||
// Run the event handler on the virtualdom
|
||||
return handleVirtualdomEventSync(this.eventsPath, JSON.stringify(body));
|
||||
@@ -337,9 +421,7 @@ export class NativeInterpreter extends JSChannel_ {
|
||||
|
||||
const href = a_element.getAttribute("href");
|
||||
if (href !== "" && href !== null && href !== undefined) {
|
||||
this.ipc.postMessage(
|
||||
this.serializeIpcMessage("browser_open", { href })
|
||||
);
|
||||
this.sendIpcMessage("browser_open", { href })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Handle serialization of the event data across the IPC boundarytype SerializedEvent = {};
|
||||
|
||||
import { retrieveSelectValue, retrieveValues } from "./form";
|
||||
// Handle serialization of the event data across the IPC boundary
|
||||
//
|
||||
// Note that for files, we don't serialize their contents, expecting the renderer itself to handle
|
||||
// reading the file contents if needed. Reading files is an async operation, and should be done lazily
|
||||
// if the app needs it.
|
||||
|
||||
export type AppTouchEvent = TouchEvent;
|
||||
|
||||
export type SerializedEvent = {
|
||||
values?: { [key: string]: FormDataEntryValue[] };
|
||||
values?: SerializedFormObject[];
|
||||
value?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
@@ -17,7 +19,7 @@ export function serializeEvent(
|
||||
let contents = {};
|
||||
|
||||
// merge the object into the contents
|
||||
let extend = (obj: any) => (contents = { ...contents, ...obj });
|
||||
let extend = (obj: SerializedEvent) => (contents = { ...contents, ...obj });
|
||||
|
||||
if (event instanceof WheelEvent) {
|
||||
extend(serializeWheelEvent(event));
|
||||
@@ -84,22 +86,20 @@ export function serializeEvent(
|
||||
|
||||
// If there's any files, we need to serialize them
|
||||
if (event instanceof DragEvent) {
|
||||
// let files: { [key: string]: Uint8Array } = {};
|
||||
// if (event.dataTransfer && event.dataTransfer.files) {
|
||||
// files["a"] = new Uint8Array(0);
|
||||
// // files = {
|
||||
// // entries: Array.from(event.dataTransfer.files).map((file) => {
|
||||
// // return {
|
||||
// // name: file.name,
|
||||
// // type: file.type,
|
||||
// // size: file.size,
|
||||
// // last_modified: file.lastModified,
|
||||
// // };
|
||||
// // }
|
||||
// // };
|
||||
// // files = await serializeFileList(event.dataTransfer.files);
|
||||
// }
|
||||
// extend({ files: files });
|
||||
let files: SerializedFormObject[] = [];
|
||||
if (event.dataTransfer && event.dataTransfer.files) {
|
||||
for (let i = 0; i < event.dataTransfer.files.length; i++) {
|
||||
let file = event.dataTransfer.files[i];
|
||||
let data: SerializedFileData = {
|
||||
path: file.name,
|
||||
size: file.size,
|
||||
last_modified: file.lastModified,
|
||||
content_type: file.type,
|
||||
}
|
||||
files.push({ key: file.name, file: data });
|
||||
}
|
||||
}
|
||||
extend({ files });
|
||||
}
|
||||
|
||||
if (event.type === "scroll") {
|
||||
@@ -173,7 +173,7 @@ function serializeInputEvent(
|
||||
|
||||
// Attempt to retrieve the values from the form
|
||||
if (target instanceof HTMLElement) {
|
||||
let values = retrieveValues(event, target);
|
||||
let values = extractSerializedFormValues(event, target);
|
||||
contents.values = values.values;
|
||||
contents.valid = values.valid;
|
||||
}
|
||||
@@ -377,3 +377,84 @@ function serializeScrollEvent(event: Event): SerializedEvent {
|
||||
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;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use futures_util::{pin_mut, FutureExt};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::LazyLock;
|
||||
use std::task::{Context as TaskContext, Waker};
|
||||
use std::{any::Any, collections::HashMap, rc::Rc};
|
||||
use std::{any::Any, rc::Rc};
|
||||
|
||||
fn wrap_event_data<T: Any>(value: T) -> Rc<dyn Any> {
|
||||
Rc::new(PlatformEventData::new(Box::new(value)))
|
||||
@@ -262,7 +262,7 @@ impl EventHandler for DioxusEventHandler<'_> {
|
||||
|
||||
DomEventData::Input(data) => Some(wrap_event_data(NativeFormData {
|
||||
value: data.value.clone(),
|
||||
values: HashMap::new(),
|
||||
values: vec![],
|
||||
})),
|
||||
|
||||
// TODO: Implement IME handling
|
||||
|
||||
@@ -13,7 +13,6 @@ use dioxus_html::{
|
||||
};
|
||||
use keyboard_types::{Code, Key, Location, Modifiers};
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct NativeConverter {}
|
||||
|
||||
@@ -110,7 +109,7 @@ impl HtmlEventConverter for NativeConverter {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NativeFormData {
|
||||
pub value: String,
|
||||
pub values: HashMap<String, FormValue>,
|
||||
pub values: Vec<(String, FormValue)>,
|
||||
}
|
||||
|
||||
impl HasFormData for NativeFormData {
|
||||
@@ -122,9 +121,13 @@ impl HasFormData for NativeFormData {
|
||||
self.value.clone()
|
||||
}
|
||||
|
||||
fn values(&self) -> HashMap<String, FormValue> {
|
||||
fn values(&self) -> Vec<(String, FormValue)> {
|
||||
self.values.clone()
|
||||
}
|
||||
fn valid(&self) -> bool {
|
||||
// todo: actually implement validation here.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFileData for NativeFormData {
|
||||
|
||||
@@ -85,7 +85,8 @@ features = [
|
||||
"File",
|
||||
"FileList",
|
||||
"FileReader",
|
||||
"ReadableStream"
|
||||
"ReadableStream",
|
||||
"FormData"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use std::{any::Any, collections::HashMap};
|
||||
|
||||
use super::WebEventExt;
|
||||
use crate::WebFileData;
|
||||
use dioxus_html::{FileData, FormValue, HasFileData, HasFormData};
|
||||
use js_sys::Array;
|
||||
use wasm_bindgen::JsValue;
|
||||
use std::any::Any;
|
||||
use wasm_bindgen::{prelude::wasm_bindgen, JsCast};
|
||||
use web_sys::{Element, Event};
|
||||
|
||||
use super::WebEventExt;
|
||||
use web_sys::{Element, Event, FileReader};
|
||||
|
||||
pub(crate) struct WebFormData {
|
||||
element: Element,
|
||||
@@ -67,26 +65,29 @@ impl HasFormData for WebFormData {
|
||||
.expect("only an InputElement or TextAreaElement or an element with contenteditable=true can have an oninput event listener")
|
||||
}
|
||||
|
||||
fn values(&self) -> HashMap<String, FormValue> {
|
||||
let mut values = HashMap::new();
|
||||
|
||||
fn insert_value(map: &mut HashMap<String, FormValue>, key: String, new_value: String) {
|
||||
map.entry(key.clone()).or_default().0.push(new_value);
|
||||
}
|
||||
fn values(&self) -> Vec<(String, FormValue)> {
|
||||
let mut values = Vec::new();
|
||||
|
||||
// try to fill in form values
|
||||
if let Some(form) = self.element.dyn_ref::<web_sys::HtmlFormElement>() {
|
||||
let form_data = get_form_data(form);
|
||||
for value in form_data.entries().into_iter().flatten() {
|
||||
if let Ok(array) = value.dyn_into::<Array>() {
|
||||
let form_data = web_sys::FormData::new_with_form(form).unwrap();
|
||||
|
||||
for entry in form_data.entries().into_iter().flatten() {
|
||||
if let Ok(array) = entry.dyn_into::<Array>() {
|
||||
if let Some(name) = array.get(0).as_string() {
|
||||
if let Ok(item_values) = array.get(1).dyn_into::<Array>() {
|
||||
item_values
|
||||
.iter()
|
||||
.filter_map(|v| v.as_string())
|
||||
.for_each(|v| insert_value(&mut values, name.clone(), v));
|
||||
} else if let Ok(item_value) = array.get(1).dyn_into::<JsValue>() {
|
||||
insert_value(&mut values, name, item_value.as_string().unwrap());
|
||||
let value = array.get(1);
|
||||
if let Some(file) = value.dyn_ref::<web_sys::File>() {
|
||||
if file.name().is_empty() {
|
||||
values.push((name, FormValue::File(None)));
|
||||
} else {
|
||||
let data =
|
||||
WebFileData::new(file.clone(), FileReader::new().unwrap());
|
||||
let as_file = FileData::new(data);
|
||||
|
||||
values.push((name, FormValue::File(Some(as_file))));
|
||||
}
|
||||
} else if let Some(s) = value.as_string() {
|
||||
values.push((name, FormValue::Text(s)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +95,9 @@ impl HasFormData for WebFormData {
|
||||
} else if let Some(select) = self.element.dyn_ref::<web_sys::HtmlSelectElement>() {
|
||||
// try to fill in select element values
|
||||
let options = get_select_data(select);
|
||||
values.insert("options".to_string(), FormValue(options));
|
||||
for option in &options {
|
||||
values.push((select.name(), FormValue::Text(option.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
values
|
||||
@@ -103,6 +106,14 @@ impl HasFormData for WebFormData {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
&self.event as &dyn Any
|
||||
}
|
||||
|
||||
fn valid(&self) -> bool {
|
||||
self.event
|
||||
.target()
|
||||
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
|
||||
.map(|input| input.check_validity())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFileData for WebFormData {
|
||||
@@ -135,20 +146,3 @@ export function get_select_data(select) {
|
||||
extern "C" {
|
||||
fn get_select_data(select: &web_sys::HtmlSelectElement) -> Vec<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;
|
||||
}
|
||||
Reference in new issue
Block a user