More fullstack fixes (#4737)

* allow `--server` only builds!

* rollback example

* capture `/:route` routes

* fix: properly implement file uploads in forms

* implement desktop form uploads

* Performant serialization of form bytes

* finish up file stuff

* cleanups

* add request headers to streaming context

* remove some unwraps
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-10-07 23:07:43 -07:00
1 parent fc01a002fd
commit 92792aded9
47 files changed
+1241 -703

No files matched your search

+10 -12
View File
@@ -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);
};
+1 -33
View File
@@ -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
+22 -33
View File
@@ -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>()
+102 -59
View File
@@ -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,57 +63,69 @@ 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()
} else {
dialog.pick_folder().into_iter().collect()
}
}
fn get_file_event_for_file(
request: &FileDialogRequest,
mut dialog: rfd::FileDialog,
) -> Vec<PathBuf> {
let filters: Vec<_> = request
.accept
.as_deref()
.unwrap_or(".*")
.split(',')
.filter_map(|s| Filters::from_str(s.trim()).ok())
.collect();
let file_extensions: Vec<_> = filters
.iter()
.flat_map(|f| f.as_extensions().into_iter())
.collect();
let filter_name = file_extensions
.iter()
.map(|extension| format!("*.{extension}"))
.collect::<Vec<_>>()
.join(", ");
dialog = dialog.add_filter(filter_name, file_extensions.as_slice());
let files: Vec<_> = if request.multiple {
dialog.pick_files().into_iter().flatten().collect()
} else {
dialog.pick_file().into_iter().collect()
};
files
}
let dialog = rfd::FileDialog::new();
if self.directory {
get_file_event_for_folder(self, dialog)
self.get_file_event_for_folder(dialog)
} else {
get_file_event_for_file(self, dialog)
self.get_file_event_for_file(dialog)
}
}
#[cfg(any(
target_os = "windows",
target_os = "macos",
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
fn get_file_event_for_file(&self, mut dialog: rfd::FileDialog) -> Vec<PathBuf> {
let filters: Vec<_> = self
.accept
.as_deref()
.unwrap_or(".*")
.split(',')
.filter_map(|s| Filters::from_str(s.trim()).ok())
.collect();
let file_extensions: Vec<_> = filters
.iter()
.flat_map(|f| f.as_extensions().into_iter())
.collect();
let filter_name = file_extensions
.iter()
.map(|extension| format!("*.{extension}"))
.collect::<Vec<_>>()
.join(", ");
dialog = dialog.add_filter(filter_name, file_extensions.as_slice());
let files: Vec<_> = if self.multiple {
dialog.pick_files().into_iter().flatten().collect()
} else {
dialog.pick_file().into_iter().collect()
};
files
}
#[cfg(any(
target_os = "windows",
target_os = "macos",
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
fn get_file_event_for_folder(&self, dialog: rfd::FileDialog) -> Vec<PathBuf> {
if self.multiple {
dialog.pick_folders().into_iter().flatten().collect()
} else {
dialog.pick_folder().into_iter().collect()
}
}
}
@@ -154,24 +168,53 @@ impl FromStr for Filters {
}
#[derive(Clone)]
pub(crate) struct DesktopFileUploadForm {
pub files: Vec<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)]
-2
View File
@@ -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,
-1
View File
@@ -91,7 +91,6 @@ pub fn launch_virtual_dom_blocking(virtual_dom: VirtualDom, mut desktop_config:
UserWindowEvent::Ipc { id, msg } => match msg.method() {
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),
+91 -7
View File
@@ -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(())
}
+35 -5
View File
@@ -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,